carlos / platform Public

carlos db query: one statement, no pragma changes, temp storage in memory

'PRAGMA query_only=0; CREATE TEMP TABLE stolen AS SELECT * FROM
main.users; UPDATE stolen ...' succeeded: mode=ro covers main only, the
attach limit does not cover the built-in temp schema, and the driver
runs every statement in a list. modernc.org/sqlite v1.55.0 exposes no
authorizer and no sqlite3_stmt_readonly, so the guard is ours:

- one statement per query, found by a splitter that ignores semicolons
  inside strings, quoted identifiers and comments;
- no pragma given a value, except the read-only schema and check
  pragmas (table_info, index_list, integrity_check and the like), so
  query_only, temp_store, temp_store_directory, writable_schema and the
  rest cannot be changed;
- temp_store=MEMORY on open, so a large sort cannot spill plaintext to a
  file outside the private directory.

mode=ro, query_only and the attach limit stay. A single statement
cannot loosen them for a second one, because there is no second one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ab02ff45f8fcf6066bcdfdda99bb0c481984fd56 parent e3f2660
2 files changed, +266 −4
  • db.go +152 −1
  • db_test.go +114 −3
diff --git a/db.go b/db.go
index 48cb325ba..796578588 100644
--- a/db.go
+++ b/db.go
@@ -300,7 +300,13 @@ func runQuery(w io.Writer, path, sqlText string) error {
// tab-separated line per row. A write fails with SQLite's own "attempt to
// write a readonly database", returned as it is: the error is the truth.
func runQueryContext(ctx context.Context, w io.Writer, path, sqlText string) error {
- db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(5000)")
+ if err := checkQuerySQL(sqlText); err != nil {
+ return err
+ }
+ // temp_store(2): sorts and temp b-trees stay in memory, so a large
+ // query cannot spill plaintext pages to a file outside the private
+ // directory.
+ db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(5000)&_pragma=temp_store(2)")
if err != nil {
return err
}
@@ -386,3 +392,148 @@ var dbStopSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, s
func dbSignalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), dbStopSignals...)
}
+
+// errOneStatement and errPragmaChange are query's two refusals before
+// SQLite sees anything.
+var (
+ errOneStatement = errors.New("carlos db query runs one statement at a time. Run each statement on its own")
+ errPragmaChange = errors.New("this pragma changes how the copy is read, so carlos db query does not run it. Reading a pragma's value is fine")
+)
+
+// dbReadPragmas are the pragmas that may take an argument here: each reads
+// the schema or checks the file, and none changes the connection. Any
+// other pragma may be read bare but not given a value.
+var dbReadPragmas = map[string]bool{
+ "table_info": true, "table_xinfo": true, "table_list": true,
+ "index_info": true, "index_xinfo": true, "index_list": true,
+ "foreign_key_list": true, "foreign_key_check": true,
+ "integrity_check": true, "quick_check": true,
+}
+
+// checkQuerySQL is the guard modernc.org/sqlite v1.55.0 leaves to its
+// caller: it exposes no authorizer and no sqlite3_stmt_readonly, and it runs
+// every statement in a list. mode=ro, query_only and the attach limit hold
+// for any ONE statement, so the rule is one statement, and no pragma that
+// changes the connection (query_only, temp_store, temp_store_directory,
+// writable_schema and the rest), since a pragma is the one statement that
+// can loosen what follows it.
+func checkQuerySQL(q string) error {
+ first, rest := splitFirstStatement(q)
+ if strings.TrimSpace(stripSQLComments(rest)) != "" {
+ return errOneStatement
+ }
+ stmt := strings.ToLower(strings.TrimSpace(stripSQLComments(first)))
+ stmt = strings.TrimSpace(strings.TrimSuffix(stmt, ";"))
+ if !strings.HasPrefix(stmt, "pragma") || (len(stmt) > 6 && isSQLIdentByte(stmt[6])) {
+ return nil
+ }
+ // PRAGMA [schema.]name [= value | (value)]
+ body := strings.TrimSpace(stmt[6:])
+ n := 0
+ for n < len(body) && (isSQLIdentByte(body[n]) || body[n] == '.') {
+ n++
+ }
+ name := body[:n]
+ if i := strings.LastIndexByte(name, '.'); i >= 0 {
+ name = name[i+1:]
+ }
+ after := strings.TrimSpace(body[n:])
+ if after == "" {
+ return nil // a bare read of the pragma's value
+ }
+ if dbReadPragmas[name] && strings.HasPrefix(after, "(") {
+ return nil
+ }
+ return errPragmaChange
+}
+
+func isSQLIdentByte(c byte) bool {
+ return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
+}
+
+// splitFirstStatement returns the text up to and including the first
+// top-level semicolon, and everything after it. Semicolons inside string
+// literals, quoted identifiers and comments do not count.
+func splitFirstStatement(q string) (first, rest string) {
+ for i := 0; i < len(q); i++ {
+ switch c := q[i]; c {
+ case '\'', '"', '`':
+ i = skipQuoted(q, i, c)
+ case '[':
+ if j := strings.IndexByte(q[i:], ']'); j >= 0 {
+ i += j
+ } else {
+ i = len(q)
+ }
+ case '-':
+ if i+1 < len(q) && q[i+1] == '-' {
+ if j := strings.IndexByte(q[i:], '\n'); j >= 0 {
+ i += j
+ } else {
+ i = len(q)
+ }
+ }
+ case '/':
+ if i+1 < len(q) && q[i+1] == '*' {
+ if j := strings.Index(q[i+2:], "*/"); j >= 0 {
+ i += j + 3
+ } else {
+ i = len(q)
+ }
+ }
+ case ';':
+ return q[:i+1], q[i+1:]
+ }
+ }
+ return q, ""
+}
+
+// skipQuoted returns the index of the quote closing the one at i, where a
+// doubled quote is an escaped one, or len(q) when it never closes.
+func skipQuoted(q string, i int, quote byte) int {
+ for j := i + 1; j < len(q); j++ {
+ if q[j] == quote {
+ if j+1 < len(q) && q[j+1] == quote {
+ j++
+ continue
+ }
+ return j
+ }
+ }
+ return len(q)
+}
+
+// stripSQLComments removes -- and /* */ comments outside quotes, leaving a
+// space in their place.
+func stripSQLComments(q string) string {
+ var b strings.Builder
+ for i := 0; i < len(q); i++ {
+ c := q[i]
+ switch {
+ case c == '\'' || c == '"' || c == '`':
+ j := skipQuoted(q, i, c)
+ if j >= len(q) {
+ j = len(q) - 1
+ }
+ b.WriteString(q[i : j+1])
+ i = j
+ case c == '-' && i+1 < len(q) && q[i+1] == '-':
+ j := strings.IndexByte(q[i:], '\n')
+ if j < 0 {
+ return b.String()
+ }
+ b.WriteByte(' ')
+ i += j
+ case c == '/' && i+1 < len(q) && q[i+1] == '*':
+ j := strings.Index(q[i+2:], "*/")
+ if j < 0 {
+ return b.String()
+ }
+ b.WriteByte(' ')
+ i += j + 3
+ default:
+ b.WriteByte(c)
+ }
+ }
+ return b.String()
+}
diff --git a/db_test.go b/db_test.go
index 7dbf11c13..f904e1ef3 100644
--- a/db_test.go
+++ b/db_test.go
@@ -16,6 +16,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "slices"
"strings"
"testing"
@@ -75,9 +76,10 @@ func TestQueryRefusesAWriteWithSQLitesOwnError(t *testing.T) {
// write refused, and the data unchanged.
func TestQueryCannotTurnOffItsOwnGuard(t *testing.T) {
path := fixtureDB(t)
- err := runQuery(&bytes.Buffer{}, path, "PRAGMA query_only=0; UPDATE users SET name='x'")
- if err == nil || !strings.Contains(err.Error(), "attempt to write a readonly database") {
- t.Fatalf("error = %v, want SQLite's readonly refusal", err)
+ for _, stmt := range []string{"PRAGMA query_only=0; UPDATE users SET name='x'", "PRAGMA query_only=0"} {
+ if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil {
+ t.Fatalf("%q: succeeded, want a refusal", stmt)
+ }
}
var out bytes.Buffer
if err := runQuery(&out, path, "SELECT name FROM users ORDER BY id"); err != nil || out.String() != "name\nada\ngrace\n" {
@@ -358,3 +360,112 @@ func TestQueryCleansUpWhenStdoutIsClosed(t *testing.T) {
t.Fatalf("private directory survived a closed stdout (%v)", err)
}
}
+
+// dirEntries lists a directory's names, sorted.
+func dirEntries(t *testing.T, dir string) []string {
+ t.Helper()
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out []string
+ for _, e := range ents {
+ out = append(out, e.Name())
+ }
+ return out
+}
+
+// The review's reproduction, verbatim. mode=ro covers main only, the
+// attach limit does not cover the built-in temp schema, and the driver runs
+// every statement in a list, so switching query_only off first let a temp
+// copy of the data be written and changed with no error. It must fail, and
+// leave no file anywhere: not beside the copy, not in the temp directory.
+func TestQueryRefusesTheTempTableBypass(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("TMPDIR", tmp)
+ path := fixtureDB(t)
+ before := dirEntries(t, filepath.Dir(path))
+ err := runQuery(&bytes.Buffer{}, path,
+ "PRAGMA query_only=0; CREATE TEMP TABLE stolen AS SELECT * FROM main.users; UPDATE stolen SET name='x'")
+ if err == nil {
+ t.Fatal("the temp-table bypass succeeded")
+ }
+ if got := dirEntries(t, tmp); len(got) != 0 {
+ t.Fatalf("temp directory holds %v after the refusal", got)
+ }
+ after := dirEntries(t, filepath.Dir(path))
+ for _, name := range after {
+ if !slices.Contains(before, name) && name != "copy.db-wal" && name != "copy.db-shm" {
+ t.Fatalf("a new file %s appeared beside the copy", name)
+ }
+ }
+}
+
+// One statement per query. The guards that matter (mode=ro, query_only)
+// hold for a single statement; a list is how one statement turns them off
+// for the next.
+func TestQueryRunsOneStatementOnly(t *testing.T) {
+ path := fixtureDB(t)
+ for _, stmt := range []string{
+ "SELECT 1; SELECT 2",
+ "PRAGMA query_only=0; UPDATE users SET name='x'",
+ "SELECT 1;\n-- trailing comment\nDELETE FROM users",
+ "SELECT 1; /* c */ SELECT 2",
+ } {
+ if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil || !strings.Contains(err.Error(), "one statement") {
+ t.Errorf("%q: error = %v, want the one-statement refusal", stmt, err)
+ }
+ }
+ for _, stmt := range []string{
+ "SELECT name FROM users;",
+ "SELECT name FROM users; -- trailing comment",
+ "SELECT ';' AS semi, \"a;b\" FROM (SELECT 1 AS \"a;b\")",
+ "SELECT name FROM users /* ; */",
+ "WITH x AS (SELECT 1) SELECT * FROM x",
+ } {
+ if err := runQuery(&bytes.Buffer{}, path, stmt); err != nil {
+ t.Errorf("%q: error = %v, want it to run", stmt, err)
+ }
+ }
+}
+
+// A pragma that takes an argument can change how this connection reads or
+// where it spills (query_only, temp_store, temp_store_directory,
+// writable_schema...). Only the read-only introspection pragmas may take
+// one; bare reads of any pragma are fine.
+func TestQueryRefusesPragmaChanges(t *testing.T) {
+ path := fixtureDB(t)
+ for _, stmt := range []string{
+ "PRAGMA query_only=0",
+ "PRAGMA query_only = OFF",
+ "pragma temp_store=FILE",
+ "PRAGMA temp_store_directory='/tmp/x'",
+ "PRAGMA main.writable_schema=1",
+ "PRAGMA journal_mode(DELETE)",
+ " /* lead */ PRAGMA user_version=9",
+ } {
+ if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil || !strings.Contains(err.Error(), "pragma") {
+ t.Errorf("%q: error = %v, want the pragma refusal", stmt, err)
+ }
+ }
+ for _, stmt := range []string{
+ "PRAGMA table_info(users)",
+ "PRAGMA main.table_info('users')",
+ "PRAGMA query_only",
+ "PRAGMA integrity_check",
+ "SELECT * FROM pragma_table_info('users')",
+ } {
+ if err := runQuery(&bytes.Buffer{}, path, stmt); err != nil {
+ t.Errorf("%q: error = %v, want it to run", stmt, err)
+ }
+ }
+}
+
+// Sorts and temp b-trees stay in memory, so a big query cannot spill
+// plaintext pages to a file outside the private directory.
+func TestQueryKeepsTempStorageInMemory(t *testing.T) {
+ var out bytes.Buffer
+ if err := runQuery(&out, fixtureDB(t), "PRAGMA temp_store"); err != nil || out.String() != "temp_store\n2\n" {
+ t.Fatalf("PRAGMA temp_store = %v %q, want 2 (memory)", err, out.String())
+ }
+}