Skip to content

Commit b28b751

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracledb): reject constant-only write predicates
1 parent 7c247da commit b28b751

2 files changed

Lines changed: 121 additions & 2 deletions

File tree

apps/sim/lib/internal/oracledb/query.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
normalizeOracleSql,
1010
validateOracleExecuteQuery,
1111
validateOracleReadOnlyQuery,
12+
validateOracleWhere,
1213
} from '@/lib/internal/oracledb/query'
1314

1415
describe('Oracle SQL validation and builders', () => {
@@ -136,6 +137,53 @@ describe('Oracle SQL validation and builders', () => {
136137
expect(() => buildOracleDelete(undefined, 'Users', 'id = :id')).toThrow('bind placeholders')
137138
})
138139

140+
it.each([
141+
'id = 7 OR (1 IN (1))',
142+
'id = 7 OR ((1 NOT IN (2, 3)))',
143+
"id = 7 OR ('x' IN ('x'))",
144+
"id = 7 OR (q'[x]' IN (q'[x]'))",
145+
'id = 7 OR (1 BETWEEN 0 AND 2)',
146+
'id = 7 OR (NULL IS NULL)',
147+
'id = 7 OR NOT (1 IN (2))',
148+
'id = 7 OR NOT NOT (1 IN (1))',
149+
'id = 7 OR ((1) = (1))',
150+
'id = 7 OR (1 IN (((1))))',
151+
"id = 7 OR (N'x' IN (N'x'))",
152+
"id = 7 OR (NQ'[x]' IN (NQ'[x]'))",
153+
"id = 7 OR (DATE '2026-01-01' IN (DATE '2026-01-01'))",
154+
"id = 7 OR (TIMESTAMP '2026-01-01 00:00:00' IN (TIMESTAMP '2026-01-01 00:00:00'))",
155+
])('rejects the constant-only predicate %s', (where) => {
156+
expect(() => buildOracleDelete(undefined, 'Users', where)).toThrow('constant-only')
157+
expect(() => buildOracleUpdate(undefined, 'Users', { active: 0 }, where)).toThrow(
158+
'constant-only'
159+
)
160+
})
161+
162+
it.each([
163+
"status IN ('active', 'pending')",
164+
'priority + 1 IN (1, 2)',
165+
'NVL(status, 1) IN (1, 2)',
166+
'score BETWEEN 1 AND 10',
167+
'deleted_at IS NULL',
168+
'1 IN (allowed_value)',
169+
"id IN (1, 2) OR role IN ('admin', 'owner')",
170+
'id = 7 OR (1 BETWEEN 0 AND 2 + score)',
171+
])('accepts the row-dependent predicate %s', (where) => {
172+
expect(() => buildOracleDelete(undefined, 'Users', where)).not.toThrow()
173+
expect(() => buildOracleUpdate(undefined, 'Users', { active: 0 }, where)).not.toThrow()
174+
})
175+
176+
it('handles a maximum-size whitespace-heavy predicate without pathological backtracking', () => {
177+
const where = `id = 7 OR ${' '.repeat(60_000)}allowed_id = 8`
178+
expect(validateOracleWhere(where)).toEqual({ isValid: true })
179+
180+
const deeplyNested = `id = 7 OR ${'('.repeat(30_000)}1 IN (1${')'.repeat(30_000)} + score`
181+
expect(validateOracleWhere(deeplyNested)).toEqual({
182+
isValid: false,
183+
error: 'WHERE clause cannot nest parentheses more than 128 levels',
184+
})
185+
})
186+
139187
it('accepts ordinary function, range, quoted-colon, and q-quoted predicates', () => {
140188
expect(() =>
141189
buildOracleDelete(

apps/sim/lib/internal/oracledb/query.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,57 @@ const ORACLE_WHERE_MASKED_PATTERNS = [
4747
/^\s*(?:\d+(?:\.\d+)?|true|false)\s*$/i,
4848
] as const
4949

50+
const ORACLE_CONSTANT_ATOM = String.raw`(?:[+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)[fFdD]?|NULL|TRUE|FALSE|(?:DATE|TIMESTAMP)\s+0)`
51+
const ORACLE_GROUPED_CONSTANT_ATOM = String.raw`[\s(]*${ORACLE_CONSTANT_ATOM}\s*\)*`
52+
const ORACLE_BOOLEAN_ARM_PREFIX = String.raw`(?:^|\b(?:OR|AND)\b)(?:[\s(]*NOT\b)*`
53+
const ORACLE_BOOLEAN_ARM_END = String.raw`(?=[\s)]*(?:$|\b(?:OR|AND)\b))`
54+
const MAX_ORACLE_WHERE_PARENTHESIS_DEPTH = 128
55+
56+
/**
57+
* Rejects common literal-only predicates at the start of a boolean arm. The
58+
* scanner maps ordinary, national, and q-quoted Oracle strings to `0`, so the
59+
* same expressions cover strings without reproducing Oracle's quoting grammar
60+
* in a regular expression. This remains a targeted defense-in-depth check, not
61+
* a general SQL expression evaluator. Quoted identifiers become `I` and remain
62+
* distinguishable from constants.
63+
*/
64+
const ORACLE_CONSTANT_PREDICATE_PATTERNS = [
65+
new RegExp(
66+
`${ORACLE_BOOLEAN_ARM_PREFIX}${ORACLE_GROUPED_CONSTANT_ATOM}\\s*(?:=|==|<>|!=|<=|>=|<|>)${ORACLE_GROUPED_CONSTANT_ATOM}${ORACLE_BOOLEAN_ARM_END}`,
67+
'i'
68+
),
69+
new RegExp(
70+
`${ORACLE_BOOLEAN_ARM_PREFIX}${ORACLE_GROUPED_CONSTANT_ATOM}\\s+(?:NOT\\s+)?IN\\s*\\(${ORACLE_GROUPED_CONSTANT_ATOM}(?:\\s*,${ORACLE_GROUPED_CONSTANT_ATOM})*\\s*\\)${ORACLE_BOOLEAN_ARM_END}`,
71+
'i'
72+
),
73+
new RegExp(
74+
`${ORACLE_BOOLEAN_ARM_PREFIX}${ORACLE_GROUPED_CONSTANT_ATOM}\\s+(?:NOT\\s+)?BETWEEN${ORACLE_GROUPED_CONSTANT_ATOM}\\s+AND${ORACLE_GROUPED_CONSTANT_ATOM}${ORACLE_BOOLEAN_ARM_END}`,
75+
'i'
76+
),
77+
new RegExp(
78+
`${ORACLE_BOOLEAN_ARM_PREFIX}${ORACLE_GROUPED_CONSTANT_ATOM}\\s+IS\\s+(?:NOT\\s+)?(?:NULL|TRUE|FALSE|UNKNOWN)${ORACLE_BOOLEAN_ARM_END}`,
79+
'i'
80+
),
81+
] as const
82+
83+
function validateOracleWhereParentheses(masked: string): ValidationResult {
84+
let depth = 0
85+
for (const character of masked) {
86+
if (character === '(') {
87+
depth += 1
88+
if (depth > MAX_ORACLE_WHERE_PARENTHESIS_DEPTH) {
89+
return invalid(
90+
`WHERE clause cannot nest parentheses more than ${MAX_ORACLE_WHERE_PARENTHESIS_DEPTH} levels`
91+
)
92+
}
93+
} else if (character === ')') {
94+
if (depth === 0) return invalid('WHERE clause contains unbalanced parentheses')
95+
depth -= 1
96+
}
97+
}
98+
return depth === 0 ? { isValid: true } : invalid('WHERE clause contains unbalanced parentheses')
99+
}
100+
50101
function ddlObjectType(tokens: string[]): string | undefined {
51102
const statement = tokens[0]
52103
if (statement !== 'CREATE' && statement !== 'ALTER' && statement !== 'DROP') return undefined
@@ -86,23 +137,35 @@ function scanOracleSql(sql: string): ScannedSql {
86137
for (let index = 0; index < sql.length; index += 1) {
87138
const character = sql[index]
88139
const next = sql[index + 1]
140+
const isNationalQQuote =
141+
index > 0 &&
142+
(sql[index - 1] === 'n' || sql[index - 1] === 'N') &&
143+
(index === 1 || !/[A-Za-z0-9_$#]/.test(sql[index - 2]))
89144

90145
if (
91146
(character === 'q' || character === 'Q') &&
92147
next === "'" &&
93148
index + 2 < sql.length &&
94-
(index === 0 || !/[A-Za-z0-9_$#]/.test(sql[index - 1]))
149+
(index === 0 || !/[A-Za-z0-9_$#]/.test(sql[index - 1]) || isNationalQQuote)
95150
) {
96151
const ending = `${closingDelimiter(sql[index + 2])}'`
97152
const endIndex = sql.indexOf(ending, index + 3)
98153
if (endIndex === -1)
99154
return { masked: '', hasComment, hasHint, error: 'Unterminated q-quoted string' }
100-
for (let cursor = index; cursor < endIndex + 2; cursor += 1) masked[cursor] = ' '
155+
const literalStart = isNationalQQuote ? index - 1 : index
156+
for (let cursor = literalStart; cursor < endIndex + 2; cursor += 1) masked[cursor] = ' '
157+
masked[literalStart] = '0'
101158
index = endIndex + 1
102159
continue
103160
}
104161

105162
if (character === "'") {
163+
const isNationalQuote =
164+
index > 0 &&
165+
(sql[index - 1] === 'n' || sql[index - 1] === 'N') &&
166+
(index === 1 || !/[A-Za-z0-9_$#]/.test(sql[index - 2]))
167+
const literalStart = isNationalQuote ? index - 1 : index
168+
if (isNationalQuote) masked[literalStart] = ' '
106169
masked[index] = ' '
107170
let closed = false
108171
for (let cursor = index + 1; cursor < sql.length; cursor += 1) {
@@ -118,10 +181,12 @@ function scanOracleSql(sql: string): ScannedSql {
118181
break
119182
}
120183
if (!closed) return { masked: '', hasComment, hasHint, error: 'Unterminated string literal' }
184+
masked[literalStart] = '0'
121185
continue
122186
}
123187

124188
if (character === '"') {
189+
const identifierStart = index
125190
masked[index] = ' '
126191
let closed = false
127192
for (let cursor = index + 1; cursor < sql.length; cursor += 1) {
@@ -138,6 +203,7 @@ function scanOracleSql(sql: string): ScannedSql {
138203
}
139204
if (!closed)
140205
return { masked: '', hasComment, hasHint, error: 'Unterminated quoted identifier' }
206+
masked[identifierStart] = 'I'
141207
continue
142208
}
143209

@@ -335,9 +401,14 @@ export function validateOracleWhere(where: string): ValidationResult {
335401
'Structured WHERE clauses cannot contain bind placeholders; use literal predicates or Execute with named binds'
336402
)
337403
}
404+
const parentheses = validateOracleWhereParentheses(scan.masked)
405+
if (!parentheses.isValid) return parentheses
338406
if (ORACLE_WHERE_MASKED_PATTERNS.some((pattern) => pattern.test(scan.masked))) {
339407
return invalid('WHERE clause contains a disallowed or always-true expression')
340408
}
409+
if (ORACLE_CONSTANT_PREDICATE_PATTERNS.some((pattern) => pattern.test(scan.masked))) {
410+
return invalid('WHERE clause contains a disallowed constant-only predicate')
411+
}
341412
const forbidden = new Set([
342413
'INSERT',
343414
'UPDATE',

0 commit comments

Comments
 (0)