Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/runtime/internal/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -187,10 +196,24 @@ 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')
}
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.
Expand Down Expand Up @@ -221,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
Expand Down Expand Up @@ -277,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
Expand Down
48 changes: 47 additions & 1 deletion test/unit/assertSafeQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +82,45 @@ 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,
// 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]) => {
Expand Down Expand Up @@ -151,6 +191,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
Expand Down
Loading