From c938067700fdb5b2a3f7704d5986b08b3f0bb16e Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 1 Oct 2026 11:42:47 +0200 Subject: [PATCH 1/2] fix(security): block IN-table, REGEXP and operator bypasses in assertSafeQuery --- src/runtime/internal/security.ts | 20 +++++++++++++++++ test/unit/assertSafeQuery.test.ts | 37 ++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index f2e21241f..9732ff489 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -5,6 +5,15 @@ const SQL_COUNT_REGEX = /^COUNT\((DISTINCT )?([a-z_]\w+|\*)\) as count$/i const SQL_WHERE_PAREN_KEYWORDS = /\b(?:WHERE|AND|OR|IN)\s*\(/gi // Bare identifiers use a word boundary; quoted/bracketed forms match the whole identifier unit. const SQL_FUNCTION_CALL = /(?:\b[A-Z_]\w*|["`[][A-Z_]\w*["`\]])\s*\(/i +// Outside quotes the query builder only emits these keywords in WHERE (fields are always quoted). +// Blocks bare table/pragma names and operators such as REGEXP, GLOB, MATCH. +const SQL_WHERE_BARE_WORD = /\b[A-Z_]\w*/gi +const SQL_WHERE_ALLOWED_WORDS = new Set(['WHERE', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN', 'IS', 'NULL']) +// Outside quotes the builder only emits comparison operators, grouping and list commas. +// Blocks ||, ->, ->>, arithmetic, bitwise operators, etc. +const SQL_WHERE_UNSAFE_CHARS = /[^\w\s(),=<>]/ +// `x IN table_name` is an implicit subquery in SQLite; IN must be followed by a list. +const SQL_IN_WITHOUT_LIST = /\bIN(?!\s*\()/i /** * Hard ceiling on SQL statement length accepted from the client. @@ -191,6 +200,17 @@ export function assertSafeQuery(sql: string, collection: string) { if (noString.match(SQL_COMMANDS)) { throw new Error('Invalid query: WHERE clause contains unsafe SQL commands') } + if (SQL_WHERE_UNSAFE_CHARS.test(noString)) { + throw new Error('Invalid query: WHERE clause contains unsupported operators') + } + if (SQL_IN_WITHOUT_LIST.test(noString)) { + throw new Error('Invalid query: IN must be followed by a list of values') + } + for (const [word] of noString.matchAll(SQL_WHERE_BARE_WORD)) { + if (!SQL_WHERE_ALLOWED_WORDS.has(word.toUpperCase())) { + throw new Error(`Invalid query: WHERE clause contains unsupported keyword '${word}'`) + } + } // Block SQLite function calls (randomblob, zeroblob, hex, length, …), // including quoted/bracketed forms SQLite accepts as identifiers: "abs"(, [abs](, `abs`(. // Only single-quoted value literals are stripped so identifier quotes stay visible to the matcher. diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts index 2c8a7fb52..9ca5c14bc 100644 --- a/test/unit/assertSafeQuery.test.ts +++ b/test/unit/assertSafeQuery.test.ts @@ -35,7 +35,8 @@ describe('decompressSQLDump', () => { 'SELECT * FROM _content_test ORDER BY id DESC LIMIT 10 OFFSET 10': true, // Where clause should follow query builder syntax 'SELECT * FROM _content_test WHERE id = 1 ORDER BY id DESC LIMIT 10 OFFSET 10': false, - 'SELECT * FROM _content_test WHERE (id = 1) ORDER BY id DESC LIMIT 10 OFFSET 10': true, + 'SELECT * FROM _content_test WHERE (id = 1) ORDER BY id DESC LIMIT 10 OFFSET 10': false, + 'SELECT * FROM _content_test WHERE ("id" = 1) ORDER BY id DESC LIMIT 10 OFFSET 10': true, 'SELECT * FROM _content_test WHERE (id = \'");\'); select * from ((SELECT * FROM sqlite_master where 1 <> "") as t) ORDER BY type DESC': false, 'SELECT "body" FROM _content_test ORDER BY body ASC': true, // Advanced @@ -81,6 +82,34 @@ describe('decompressSQLDump', () => { 'SELECT * FROM _content_test WHERE ("x" BETWEEN \'1\' AND \'2\') ORDER BY stem ASC': true, 'SELECT * FROM _content_test WHERE ("x" IS NULL) ORDER BY stem ASC': true, 'SELECT * FROM _content_test WHERE ("x" IS NOT NULL) ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("x" NOT BETWEEN \'1\' AND \'2\') ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("x" NOT LIKE \'%a\') ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("x" >= \'1\' OR "x" <= \'2\' OR "x" <> \'3\' OR "x" > \'4\' OR "x" < \'5\') ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("id" IN ()) ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("id" = \'a -- b /* c */\') ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("id" = \'x\') AND (("a" = \'1\') OR ("b" IS NULL)) ORDER BY stem ASC': true, + // `IN table_name` is an implicit subquery in SQLite (incl. eponymous pragma_* tables) + 'SELECT * FROM _content_test WHERE (\'ok\' IN pragma_integrity_check) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ((\'ok\' IN pragma_integrity_check) AND (\'ok\' IN pragma_integrity_check)) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" IN pragma_integrity_check) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" IN "pragma_integrity_check") ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" IN [pragma_quick_check]) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" NOT IN app_flags) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" IN _content_other) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" IN (pragma_integrity_check)) ORDER BY stem ASC': false, + // Infix operators the builder never emits + 'SELECT * FROM _content_test WHERE (\'aaaaaaaaaaX\' REGEXP \'(a?){500}a{500}$\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" REGEXP \'a\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" GLOB \'*\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" MATCH \'a\') ORDER BY stem ASC': false, + + 'SELECT * FROM _content_test WHERE ("id" LIKE \'a\' ESCAPE \'\\\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" IN (\'a\') AND \'x\' IN "app_flags") ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" COLLATE NOCASE = \'a\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE (EXISTS (\'a\')) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" = "body" || "body") ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" = "body" ->> \'$.a\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" = "stem" + 1) ORDER BY stem ASC': false, } Object.entries(queries).forEach(([query, isValid]) => { @@ -151,6 +180,12 @@ describe('decompressSQLDump', () => { expect(() => assertSafeQuery(sql, 'test')).toThrow(/maximum allowed length/) }) + it('rejects balanced pragma_integrity_check trees (DoS)', () => { + const build = (n: number): string => n === 1 ? '(\'ok\' IN pragma_integrity_check)' : `(${build(n / 2)} AND ${build(n / 2)})` + const sql = `SELECT * FROM _content_test WHERE ${build(256)} ORDER BY stem ASC` + expect(() => assertSafeQuery(sql, 'test')).toThrow() + }) + it('rejects ReDoS-shaped payloads in linear time', () => { // Former catastrophic-backtracking shape against SQL_SELECT_REGEX: // repeated " FROM x WHERE ORDER BY " forces nested quantifiers to explore From c7bd82c269dd648da1eb71ee7a449786c2e66a30 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 1 Oct 2026 11:54:03 +0200 Subject: [PATCH 2/2] fix(security): stop treating `[ ]` and backticks as quotes in the `WHERE` keyword check --- src/runtime/internal/security.ts | 9 ++++++--- test/unit/assertSafeQuery.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/runtime/internal/security.ts b/src/runtime/internal/security.ts index 9732ff489..a9436db76 100644 --- a/src/runtime/internal/security.ts +++ b/src/runtime/internal/security.ts @@ -196,7 +196,10 @@ export function assertSafeQuery(sql: string, collection: string) { if (!where.startsWith(' WHERE (') || !where.endsWith(')')) { throw new Error('Invalid query: WHERE clause must be properly enclosed in parentheses') } - const noString = cleanupQuery(where, { removeString: true }) + // Only strip the quote styles the builder emits ('value', "field"). `[` and backtick are + // identifier quotes in SQLite but not in PostgreSQL, where `[...]` is an executable array + // subscript; keeping them visible rejects them via SQL_WHERE_UNSAFE_CHARS on every adapter. + const noString = cleanupQuery(where, { removeString: true, standardQuotesOnly: true }) if (noString.match(SQL_COMMANDS)) { throw new Error('Invalid query: WHERE clause contains unsafe SQL commands') } @@ -241,7 +244,7 @@ export function assertSafeQuery(sql: string, collection: string) { return true } -function cleanupQuery(query: string, options: { removeString?: boolean, removeSingleQuoted?: boolean } = {}) { +function cleanupQuery(query: string, options: { removeString?: boolean, removeSingleQuoted?: boolean, standardQuotesOnly?: boolean } = {}) { // Track every SQL quote fence so comments/apostrophes inside identifiers // ("…", `…`, […]) cannot terminate or re-open the scanner early. let fence: '\'' | '"' | '`' | '[' | null = null @@ -297,7 +300,7 @@ function cleanupQuery(query: string, options: { removeString?: boolean, removeSi continue } - if (char === '\'' || char === '"' || char === '`' || char === '[') { + if (char === '\'' || char === '"' || (!options.standardQuotesOnly && (char === '`' || char === '['))) { fence = char if (!strippingFence(fence)) { result += char diff --git a/test/unit/assertSafeQuery.test.ts b/test/unit/assertSafeQuery.test.ts index 9ca5c14bc..69bed0225 100644 --- a/test/unit/assertSafeQuery.test.ts +++ b/test/unit/assertSafeQuery.test.ts @@ -110,6 +110,17 @@ describe('decompressSQLDump', () => { 'SELECT * FROM _content_test WHERE ("id" = "body" || "body") ORDER BY stem ASC': false, 'SELECT * FROM _content_test WHERE ("id" = "body" ->> \'$.a\') ORDER BY stem ASC': false, 'SELECT * FROM _content_test WHERE ("id" = "stem" + 1) ORDER BY stem ASC': false, + // PostgreSQL array subscripts: `[...]` is executable code there, not an identifier quote + 'SELECT * FROM _content_test WHERE ((\'{a,b}\'::text[])[(SELECT CASE WHEN 1=1 THEN 1 ELSE 2 END)] = \'a\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("meta"[(SELECT CASE WHEN 1=1 THEN 1 ELSE 2 END)] IS NULL) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("meta"[(SELECT 1 FROM app_users LIMIT 1)] = \'a\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE (("meta")[(SELECT 1)] IS NOT NULL) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ("id" = \'a\'[(SELECT 1)]) ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE ([id] = \'a\') ORDER BY stem ASC': false, + 'SELECT * FROM _content_test WHERE (`id` = \'a\') ORDER BY stem ASC': false, + // Brackets / backticks inside value literals and quoted fields remain allowed + 'SELECT * FROM _content_test WHERE ("id" = \'[a] `b`\') ORDER BY stem ASC': true, + 'SELECT * FROM _content_test WHERE ("we[ir]d" = \'a\') ORDER BY stem ASC': true, } Object.entries(queries).forEach(([query, isValid]) => {