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>
2 files changed,
+45
−4
db.go+21 −4db_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) |
| + } |
| + } |
| +} |