carlos / platform Public

carlos db query: see pragmas through EXPLAIN and a byte-order mark

SQLite applies a pragma when it prepares the statement, even under
EXPLAIN [QUERY PLAN], and skips a leading byte-order mark, so
'EXPLAIN PRAGMA query_only=0' and a BOM before 'PRAGMA query_only=0'
passed the prefix check. The check now strips a leading BOM, comments,
SQLite's whitespace and an optional EXPLAIN [QUERY PLAN] before it looks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a93f8f6391901c2612e21536a5caa2d31e7cc4be parent c4c85da
2 files changed, +45 −4
  • db.go +21 −4
  • db_test.go +24 −0
diff --git a/db.go b/db.go
index 796578588..0456dffc1 100644
--- a/db.go
+++ b/db.go
@@ -422,13 +422,20 @@ func checkQuerySQL(q string) error {
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])) {
+ // SQLite skips a leading byte-order mark and treats space, tab, CR, LF
+ // and form feed as whitespace, and it applies a pragma at prepare even
+ // under EXPLAIN [QUERY PLAN]. Normalize all three away before looking.
+ stmt := strings.ToLower(stripSQLComments(strings.TrimPrefix(first, "\uFEFF")))
+ stmt = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(stmt), ";"))
+ stmt = cutSQLKeyword(stmt, "explain")
+ if rest := cutSQLKeyword(stmt, "query"); rest != stmt {
+ stmt = cutSQLKeyword(rest, "plan")
+ }
+ body := cutSQLKeyword(stmt, "pragma")
+ if body == stmt {
return nil
}
// PRAGMA [schema.]name [= value | (value)]
- body := strings.TrimSpace(stmt[6:])
n := 0
for n < len(body) && (isSQLIdentByte(body[n]) || body[n] == '.') {
n++
@@ -447,6 +454,16 @@ func checkQuerySQL(q string) error {
return errPragmaChange
}
+// cutSQLKeyword removes a leading keyword (already lower case) and the
+// whitespace after it, or returns s unchanged when it does not start with
+// that whole word.
+func cutSQLKeyword(s, kw string) string {
+ if !strings.HasPrefix(s, kw) || (len(s) > len(kw) && isSQLIdentByte(s[len(kw)])) {
+ return s
+ }
+ return strings.TrimSpace(s[len(kw):])
+}
+
func isSQLIdentByte(c byte) bool {
return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
}
diff --git a/db_test.go b/db_test.go
index f904e1ef3..243eab0ce 100644
--- a/db_test.go
+++ b/db_test.go
@@ -469,3 +469,27 @@ func TestQueryKeepsTempStorageInMemory(t *testing.T) {
t.Fatalf("PRAGMA temp_store = %v %q, want 2 (memory)", err, out.String())
}
}
+
+// SQLite applies a pragma when it prepares the statement, EXPLAIN or not,
+// and skips a leading byte-order mark and every kind of SQL whitespace. The
+// pragma check must see through all of them.
+func TestQueryRefusesDisguisedPragmaChanges(t *testing.T) {
+ path := fixtureDB(t)
+ for _, stmt := range []string{
+ "EXPLAIN PRAGMA query_only=0",
+ "explain query plan PRAGMA query_only=0",
+ "EXPLAIN\fQUERY\tPLAN\nPRAGMA temp_store=FILE",
+ "\uFEFFPRAGMA query_only=0",
+ "\uFEFF EXPLAIN /* x */ PRAGMA writable_schema=1",
+ "\fPRAGMA\fquery_only\f=\f0",
+ } {
+ 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{"EXPLAIN SELECT * FROM users", "EXPLAIN QUERY PLAN SELECT * FROM users", "\uFEFFSELECT 1", "EXPLAIN PRAGMA table_info(users)"} {
+ if err := runQuery(&bytes.Buffer{}, path, stmt); err != nil {
+ t.Errorf("%q: error = %v, want it to run", stmt, err)
+ }
+ }
+}