-
Notifications
You must be signed in to change notification settings - Fork 1
Sentinel: Fix SQL injection bypass in read-only query validation #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -449,6 +449,182 @@ async function assertTableExists( | |
| return rows.length > 0; | ||
| } | ||
|
|
||
| /** | ||
| * Validate a SQL query to ensure it is read-only. | ||
| * Returns an error message if the query contains mutation keywords or dangerous functions. | ||
| * Returns null if the query appears safe (read-only). | ||
| * | ||
| * Security note: This validator attempts to strip comments and string literals | ||
| * before checking for keywords. It is not a full SQL parser and may be bypassed | ||
| * by sufficiently complex obfuscation. However, it blocks common attacks and | ||
| * accidental mutations. | ||
| */ | ||
| export function validateReadOnlyQuery(sqlText: string): string | null { | ||
| // Strip block comments (/* ... */) and line comments (-- ...). | ||
| // Replace with a space to prevent keyword concatenation (e.g. DELETE/**/FROM -> DELETE FROM). | ||
| const stripped = sqlText | ||
| .replace(/\/\*[\s\S]*?\*\//g, " ") | ||
| .replace(/--.*$/gm, " ") | ||
| .trim(); | ||
|
|
||
| // Strip string literals so that mutation keywords/functions inside quoted | ||
| // strings are ignored. Handles single-quoted ('...'), dollar-quoted | ||
| // ($$...$$), and tagged dollar-quoted ($tag$...$tag$) strings. | ||
| const noLiterals = stripped | ||
| .replace(/\$([A-Za-z0-9_]*)\$[\s\S]*?\$\1\$/g, " ") | ||
| .replace(/'(?:[^']|'')*'/g, " "); | ||
|
|
||
| // For keyword checks, also strip double-quoted identifiers to avoid | ||
| // matching words inside quoted table/column names. | ||
| const noStrings = noLiterals.replace(/"(?:[^"]|"")*"/g, " "); | ||
|
|
||
| const mutationKeywords = [ | ||
| // ββ DML ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "INSERT", | ||
| "UPDATE", | ||
| "DELETE", | ||
| "INTO", | ||
| "COPY", | ||
| "MERGE", | ||
| // ββ DDL ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "DROP", | ||
| "ALTER", | ||
| "TRUNCATE", | ||
| "CREATE", | ||
| "COMMENT", | ||
| // ββ Admin / privilege ββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "GRANT", | ||
| "REVOKE", | ||
| "SET", | ||
| "RESET", | ||
| "LOAD", | ||
| // ββ Maintenance ββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "VACUUM", | ||
| "REINDEX", | ||
| "CLUSTER", | ||
| "REFRESH", | ||
| "DISCARD", | ||
| // ββ Procedural βββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "CALL", | ||
| "DO", | ||
| // ββ Async notifications (side-effects) βββββββββββββββββββββββββββββ | ||
| "LISTEN", | ||
| "UNLISTEN", | ||
| "NOTIFY", | ||
| // ββ Prepared statements (can wrap mutations) βββββββββββββββββββββββ | ||
| "PREPARE", | ||
| "EXECUTE", | ||
| "DEALLOCATE", | ||
| // ββ Locking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "LOCK", | ||
| ]; | ||
| // Match mutation keywords as whole words (word boundary) anywhere in the | ||
| // query, catching them inside CTEs, subqueries, etc. | ||
| const mutationPattern = new RegExp( | ||
| `\\b(${mutationKeywords.join("|")})\\b`, | ||
| "i", | ||
| ); | ||
| const match = mutationPattern.exec(noStrings); | ||
| if (match) { | ||
| return `Query rejected: "${match[1].toUpperCase()}" is a mutation keyword. Set readOnly: false to execute mutations.`; | ||
| } | ||
|
|
||
| // PostgreSQL built-in functions that can read/write server files, mutate | ||
| // server state, or cause denial of service. These appear inside otherwise | ||
| // valid SELECT expressions, so keyword checks alone won't catch them. | ||
| // | ||
| // ββ File I/O (arbitrary file read/write on the DB server) βββββββββ | ||
| // lo_import('/etc/passwd') β load file into large object | ||
| // lo_export(oid, '/tmp/evil') β write large object to file | ||
| // lo_unlink(oid) β delete large object | ||
| // pg_read_file('/etc/passwd') β read server file (superuser) | ||
| // pg_read_binary_file(...) β same, binary | ||
| // pg_write_file(...) β write to server files (ext. module) | ||
| // pg_stat_file(...) β stat a server file | ||
| // pg_ls_dir(...) β list server directory | ||
| // | ||
| // ββ Sequence / state mutation ββββββββββββββββββββββββββββββββββββ | ||
| // nextval('seq'), setval('seq', n) | ||
| // | ||
| // ββ Denial of service ββββββββββββββββββββββββββββββββββββββββββββ | ||
| // pg_sleep(n) β block connection for n seconds | ||
| // pg_sleep_for(interval) β same, interval version | ||
| // pg_sleep_until(timestamp) β same, deadline version | ||
| // | ||
| // ββ Session / backend control ββββββββββββββββββββββββββββββββββββ | ||
| // pg_terminate_backend(pid) β kill another connection | ||
| // pg_cancel_backend(pid) β cancel a running query | ||
| // pg_reload_conf() β reload server configuration | ||
| // pg_rotate_logfile() β rotate the server log | ||
| // set_config(name, value, local) β SET equivalent as function | ||
| // | ||
| // ββ Advisory locks (can deadlock other connections) βββββββββββββββ | ||
| // pg_advisory_lock(key) β session-level advisory lock | ||
| // pg_advisory_lock_shared(key) | ||
| // pg_try_advisory_lock(key) | ||
| const dangerousFunctions = [ | ||
| // File I/O | ||
| "lo_import", | ||
| "lo_export", | ||
| "lo_unlink", | ||
| "lo_put", | ||
| "lo_from_bytea", | ||
| "pg_read_file", | ||
| "pg_read_binary_file", | ||
| "pg_write_file", | ||
| "pg_stat_file", | ||
| "pg_ls_dir", | ||
| "pg_ls_logdir", | ||
| "pg_ls_waldir", | ||
| "pg_ls_tmpdir", | ||
| "pg_ls_archive_statusdir", | ||
| // Sequence / state mutation | ||
| "nextval", | ||
| "setval", | ||
| // Denial of service | ||
| "pg_sleep", | ||
| "pg_sleep_for", | ||
| "pg_sleep_until", | ||
| // Session / backend control | ||
| "pg_terminate_backend", | ||
| "pg_cancel_backend", | ||
| "pg_reload_conf", | ||
| "pg_rotate_logfile", | ||
| "set_config", | ||
| // Advisory locks | ||
| "pg_advisory_lock", | ||
| "pg_advisory_lock_shared", | ||
| "pg_try_advisory_lock", | ||
| "pg_try_advisory_lock_shared", | ||
| "pg_advisory_xact_lock", | ||
| "pg_advisory_xact_lock_shared", | ||
| "pg_advisory_unlock", | ||
| "pg_advisory_unlock_shared", | ||
| "pg_advisory_unlock_all", | ||
| ]; | ||
| const dangerousFnPattern = new RegExp( | ||
| `(?:^|[^\\w$])"?(?:${dangerousFunctions.join("|")})"?\\s*\\(`, | ||
| "i", | ||
| ); | ||
| const fnMatch = dangerousFnPattern.exec(noLiterals); | ||
| if (fnMatch) { | ||
| // Extract the function name from the match for the error message. | ||
| const fnNameMatch = fnMatch[0].match( | ||
| new RegExp(`(${dangerousFunctions.join("|")})`, "i"), | ||
| ); | ||
| const fnName = fnNameMatch ? fnNameMatch[1].toUpperCase() : "UNKNOWN"; | ||
| return `Query rejected: "${fnName}" is a dangerous function that can modify server state. Set readOnly: false to execute this query.`; | ||
| } | ||
|
|
||
| // Reject multi-statement queries (naive: any semicolon not at the very end) | ||
| const trimmedForSemicolon = stripped.replace(/;\s*$/, ""); | ||
| if (trimmedForSemicolon.includes(";")) { | ||
| return "Query rejected: multi-statement queries are not allowed in read-only mode."; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
Comment on lines
+462
to
+626
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security: Read-Only Query Validation is Not a Full SQL ParserThe Recommendation:
|
||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Route handlers | ||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -1062,183 +1238,10 @@ async function handleQuery( | |
| const sqlText = body.sql.trim(); | ||
|
|
||
| // If readOnly mode, reject mutation statements. | ||
| // Strip SQL comments, then scan for mutation keywords *anywhere* in the | ||
| // query β not just the leading keyword. This prevents bypass via CTEs | ||
| // (WITH ... AS (DELETE ...)) and other SQL constructs that nest mutations. | ||
| if (body.readOnly !== false) { | ||
| // Strip block comments (/* ... */) and line comments (-- ...). | ||
| // Use empty-string replacement (not space) to mirror how PostgreSQL | ||
| // concatenates tokens across comments β e.g. DE/* */LETE β DELETE. | ||
| // A space replacement would turn it into "DE LETE", hiding the keyword. | ||
| const stripped = sqlText | ||
| .replace(/\/\*[\s\S]*?\*\//g, "") | ||
| .replace(/--.*$/gm, "") | ||
| .trim(); | ||
|
|
||
| // Strip string literals so that mutation keywords/functions inside quoted | ||
| // strings are ignored. Handles single-quoted ('...'), dollar-quoted | ||
| // ($$...$$), and tagged dollar-quoted ($tag$...$tag$) strings. | ||
| const noLiterals = stripped | ||
| .replace(/\$([A-Za-z0-9_]*)\$[\s\S]*?\$\1\$/g, " ") | ||
| .replace(/'(?:[^']|'')*'/g, " "); | ||
|
|
||
| // For keyword checks, also strip double-quoted identifiers to avoid | ||
| // matching words inside quoted table/column names. | ||
| const noStrings = noLiterals.replace(/"(?:[^"]|"")*"/g, " "); | ||
|
|
||
| const mutationKeywords = [ | ||
| // ββ DML ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "INSERT", | ||
| "UPDATE", | ||
| "DELETE", | ||
| "INTO", | ||
| "COPY", | ||
| "MERGE", | ||
| // ββ DDL ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "DROP", | ||
| "ALTER", | ||
| "TRUNCATE", | ||
| "CREATE", | ||
| "COMMENT", | ||
| // ββ Admin / privilege ββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "GRANT", | ||
| "REVOKE", | ||
| "SET", | ||
| "RESET", | ||
| "LOAD", | ||
| // ββ Maintenance ββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "VACUUM", | ||
| "REINDEX", | ||
| "CLUSTER", | ||
| "REFRESH", | ||
| "DISCARD", | ||
| // ββ Procedural βββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "CALL", | ||
| "DO", | ||
| // ββ Async notifications (side-effects) βββββββββββββββββββββββββββββ | ||
| "LISTEN", | ||
| "UNLISTEN", | ||
| "NOTIFY", | ||
| // ββ Prepared statements (can wrap mutations) βββββββββββββββββββββββ | ||
| "PREPARE", | ||
| "EXECUTE", | ||
| "DEALLOCATE", | ||
| // ββ Locking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| "LOCK", | ||
| ]; | ||
| // Match mutation keywords as whole words (word boundary) anywhere in the | ||
| // query, catching them inside CTEs, subqueries, etc. | ||
| const mutationPattern = new RegExp( | ||
| `\\b(${mutationKeywords.join("|")})\\b`, | ||
| "i", | ||
| ); | ||
| const match = mutationPattern.exec(noStrings); | ||
| if (match) { | ||
| sendJsonError( | ||
| res, | ||
| `Query rejected: "${match[1].toUpperCase()}" is a mutation keyword. Set readOnly: false to execute mutations.`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // PostgreSQL built-in functions that can read/write server files, mutate | ||
| // server state, or cause denial of service. These appear inside otherwise | ||
| // valid SELECT expressions, so keyword checks alone won't catch them. | ||
| // | ||
| // ββ File I/O (arbitrary file read/write on the DB server) βββββββββ | ||
| // lo_import('/etc/passwd') β load file into large object | ||
| // lo_export(oid, '/tmp/evil') β write large object to file | ||
| // lo_unlink(oid) β delete large object | ||
| // pg_read_file('/etc/passwd') β read server file (superuser) | ||
| // pg_read_binary_file(...) β same, binary | ||
| // pg_write_file(...) β write to server files (ext. module) | ||
| // pg_stat_file(...) β stat a server file | ||
| // pg_ls_dir(...) β list server directory | ||
| // | ||
| // ββ Sequence / state mutation ββββββββββββββββββββββββββββββββββββ | ||
| // nextval('seq'), setval('seq', n) | ||
| // | ||
| // ββ Denial of service ββββββββββββββββββββββββββββββββββββββββββββ | ||
| // pg_sleep(n) β block connection for n seconds | ||
| // pg_sleep_for(interval) β same, interval version | ||
| // pg_sleep_until(timestamp) β same, deadline version | ||
| // | ||
| // ββ Session / backend control ββββββββββββββββββββββββββββββββββββ | ||
| // pg_terminate_backend(pid) β kill another connection | ||
| // pg_cancel_backend(pid) β cancel a running query | ||
| // pg_reload_conf() β reload server configuration | ||
| // pg_rotate_logfile() β rotate the server log | ||
| // set_config(name, value, local) β SET equivalent as function | ||
| // | ||
| // ββ Advisory locks (can deadlock other connections) βββββββββββββββ | ||
| // pg_advisory_lock(key) β session-level advisory lock | ||
| // pg_advisory_lock_shared(key) | ||
| // pg_try_advisory_lock(key) | ||
| const dangerousFunctions = [ | ||
| // File I/O | ||
| "lo_import", | ||
| "lo_export", | ||
| "lo_unlink", | ||
| "lo_put", | ||
| "lo_from_bytea", | ||
| "pg_read_file", | ||
| "pg_read_binary_file", | ||
| "pg_write_file", | ||
| "pg_stat_file", | ||
| "pg_ls_dir", | ||
| "pg_ls_logdir", | ||
| "pg_ls_waldir", | ||
| "pg_ls_tmpdir", | ||
| "pg_ls_archive_statusdir", | ||
| // Sequence / state mutation | ||
| "nextval", | ||
| "setval", | ||
| // Denial of service | ||
| "pg_sleep", | ||
| "pg_sleep_for", | ||
| "pg_sleep_until", | ||
| // Session / backend control | ||
| "pg_terminate_backend", | ||
| "pg_cancel_backend", | ||
| "pg_reload_conf", | ||
| "pg_rotate_logfile", | ||
| "set_config", | ||
| // Advisory locks | ||
| "pg_advisory_lock", | ||
| "pg_advisory_lock_shared", | ||
| "pg_try_advisory_lock", | ||
| "pg_try_advisory_lock_shared", | ||
| "pg_advisory_xact_lock", | ||
| "pg_advisory_xact_lock_shared", | ||
| "pg_advisory_unlock", | ||
| "pg_advisory_unlock_shared", | ||
| "pg_advisory_unlock_all", | ||
| ]; | ||
| const dangerousFnPattern = new RegExp( | ||
| `(?:^|[^\\w$])"?(?:${dangerousFunctions.join("|")})"?\\s*\\(`, | ||
| "i", | ||
| ); | ||
| const fnMatch = dangerousFnPattern.exec(noLiterals); | ||
| if (fnMatch) { | ||
| // Extract the function name from the match for the error message. | ||
| const fnNameMatch = fnMatch[0].match( | ||
| new RegExp(`(${dangerousFunctions.join("|")})`, "i"), | ||
| ); | ||
| const fnName = fnNameMatch ? fnNameMatch[1].toUpperCase() : "UNKNOWN"; | ||
| sendJsonError( | ||
| res, | ||
| `Query rejected: "${fnName}" is a dangerous function that can modify server state. Set readOnly: false to execute this query.`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // Reject multi-statement queries (naive: any semicolon not at the very end) | ||
| const trimmedForSemicolon = stripped.replace(/;\s*$/, ""); | ||
| if (trimmedForSemicolon.includes(";")) { | ||
| sendJsonError( | ||
| res, | ||
| "Query rejected: multi-statement queries are not allowed in read-only mode.", | ||
| ); | ||
| const error = validateReadOnlyQuery(sqlText); | ||
| if (error) { | ||
| sendJsonError(res, error); | ||
| return; | ||
| } | ||
| } | ||
|
Comment on lines
1238
to
1247
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security: Reliance on
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can make this code more efficient and readable by using a capturing group in the
dangerousFnPatternregex to extract the function name directly, instead of running a second regex match. This avoids the need forfnMatch[0].match(...).