@@ -206,6 +206,112 @@ export async function validateDatabaseHost(
206206 }
207207}
208208
209+ /**
210+ * Patterns run against the WHERE clause with string/identifier literals masked
211+ * out (so an attacker cannot smuggle `OR 1` or `; DROP` inside a quoted value).
212+ *
213+ * The connector-literal rules below are intentionally `OR`-only: only an
214+ * `OR <truthy>` term broadens a mutation to every row. `AND <number>` is a no-op
215+ * for broadening and is also exactly what `BETWEEN low AND high` produces, so
216+ * matching it would reject legitimate range filters (e.g. `id BETWEEN 1 AND 10`).
217+ */
218+ const SQL_WHERE_MASKED_PATTERNS : readonly RegExp [ ] = [
219+ / ; \s * \w / , // stacked statement
220+ / \b u n i o n \s + (?: a l l \s + ) ? s e l e c t \b / i,
221+ / \b i n t o \s + (?: o u t | d u m p ) f i l e \b / i,
222+ / - - / ,
223+ / \/ \* / ,
224+ / \* \/ / ,
225+ / \b (?: s l e e p | p g _ s l e e p | b e n c h m a r k ) \s * \( / i,
226+ / \b ( \w + ) \s * = \s * \1\b / i, // same (unquoted) operand both sides: x=x, 1=1
227+ / \b \d + (?: \. \d + ) ? \s * (?: = | = = | < > | ! = | < = | > = | < | > ) \s * \d + (?: \. \d + ) ? \b / , // constant vs constant: 1=1, 1<2, 2>1
228+ / \b o r \s + (?: t r u e | f a l s e ) \b / i, // OR TRUE / OR FALSE
229+ / \b o r \s + \d + (?: \. \d + ) ? \b (? ! \s * [ = < > ! + \- * / % ] ) / i, // standalone truthy literal after OR: OR 1, OR 42
230+ / ^ \s * (?: \d + (?: \. \d + ) ? | t r u e | f a l s e ) \s * $ / i, // bare constant: "1" / "true" / "false"
231+ ]
232+
233+ /**
234+ * Patterns run against the raw WHERE clause (need the literal contents intact),
235+ * e.g. equality between two identical string literals.
236+ */
237+ const SQL_WHERE_RAW_PATTERNS : readonly RegExp [ ] = [
238+ / ( [ ' " ] ) ( [ ^ ' " ] * ) \1\s * (?: = | = = | < > | ! = ) \s * \1\2\1/ , // 'a'='a' / "x"="x"
239+ ]
240+
241+ /**
242+ * Replaces the contents of string literals ('...'), double-quoted and
243+ * backtick-quoted identifiers with spaces (preserving length) so structural
244+ * scans do not treat data inside quotes as SQL. Comments are intentionally left
245+ * intact so comment-injection sequences are still detected.
246+ */
247+ function maskSqlStringLiterals ( sql : string ) : string {
248+ let out = ''
249+ let i = 0
250+ while ( i < sql . length ) {
251+ const ch = sql [ i ]
252+ if ( ch === "'" || ch === '"' || ch === '`' ) {
253+ out += ' '
254+ i ++
255+ while ( i < sql . length && sql [ i ] !== ch ) {
256+ if ( ch !== '`' && sql [ i ] === '\\' ) {
257+ out += ' '
258+ i += 2
259+ continue
260+ }
261+ out += ' '
262+ i ++
263+ }
264+ if ( i < sql . length ) {
265+ out += ' '
266+ i ++
267+ }
268+ continue
269+ }
270+ out += ch
271+ i ++
272+ }
273+ return out
274+ }
275+
276+ /**
277+ * Validates a free-form SQL `WHERE` condition for injection and always-true
278+ * tautology patterns. Returns a {@link ValidationResult}; callers decide whether
279+ * to throw or surface the error.
280+ *
281+ * IMPORTANT: this is **defense-in-depth, not a security boundary**. A free-form
282+ * SQL condition cannot be exhaustively validated against every always-true
283+ * expression (e.g. `OR 2 > 1`, `OR (1)`, `OR NOT 0`, `OR length(x) >= 0`). The
284+ * real boundary is that the caller supplies their own database credentials and
285+ * could run equivalent SQL directly (e.g. via a raw-SQL/execute operation). This
286+ * guard stops the easy, obvious ways an injected condition broadens a mutation
287+ * to every row; it is not a substitute for constraining untrusted input upstream.
288+ *
289+ * @param where - The WHERE clause condition (without the `WHERE` keyword)
290+ * @param paramName - Label used in the error message
291+ */
292+ export function validateSqlWhereClause (
293+ where : string | null | undefined ,
294+ paramName = 'WHERE clause'
295+ ) : ValidationResult {
296+ if ( typeof where !== 'string' || where . trim ( ) . length === 0 ) {
297+ return { isValid : false , error : `${ paramName } is required` }
298+ }
299+
300+ const masked = maskSqlStringLiterals ( where )
301+ const matched =
302+ SQL_WHERE_MASKED_PATTERNS . some ( ( pattern ) => pattern . test ( masked ) ) ||
303+ SQL_WHERE_RAW_PATTERNS . some ( ( pattern ) => pattern . test ( where ) )
304+
305+ if ( matched ) {
306+ return {
307+ isValid : false ,
308+ error : `${ paramName } contains a disallowed or always-true expression` ,
309+ }
310+ }
311+
312+ return { isValid : true }
313+ }
314+
209315export interface SecureFetchOptions {
210316 method ?: string
211317 headers ?: Record < string , string >
0 commit comments