Skip to content

Commit 1516f07

Browse files
fix(tables): bound predicate validation
1 parent ca7c8d1 commit 1516f07

4 files changed

Lines changed: 77 additions & 40 deletions

File tree

apps/sim/lib/api/contracts/tables.ts

Lines changed: 6 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import {
3333
TABLE_LIMITS,
3434
} from '@/lib/table/constants'
3535
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
36-
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
36+
import {
37+
getTablePredicateTreeSizeError,
38+
MAX_PREDICATE_GROUP_SIZE,
39+
normalizeTablePredicate,
40+
} from '@/lib/table/query-builder/predicate'
3741

3842
export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
3943

@@ -422,45 +426,8 @@ const filterSchema = domainObjectSchema<Filter>()
422426
*/
423427
export const TABLE_QUERY_MAX_BODY_BYTES = 1024 * 1024
424428

425-
/** Max members in one `all`/`any` group — a generous bound against pathological trees. */
426-
const MAX_PREDICATE_GROUP_SIZE = 100
427429
/** Max sort keys — more than a few is already a smell. */
428430
const MAX_SORT_KEYS = 16
429-
/** Max nesting levels of `all`/`any` groups. Ten is already unreadable. */
430-
const MAX_PREDICATE_DEPTH = 10
431-
/** Max nodes in the whole tree, so a wide-but-shallow tree can't amplify either. */
432-
const MAX_PREDICATE_NODES = 500
433-
434-
/**
435-
* Iterative depth/size walk over an unvalidated predicate tree. Runs BEFORE the
436-
* recursive Zod schema: a few thousand nested `{all:[...]}` levels overflow the
437-
* stack inside `safeParse`, and a `RangeError` from a parser is a 500, not a 400.
438-
* The walk itself must stay iterative for the same reason.
439-
*/
440-
function predicateTreeTooLarge(root: unknown): string | null {
441-
const stack: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 1 }]
442-
let nodes = 0
443-
444-
while (stack.length > 0) {
445-
const { node, depth } = stack.pop()!
446-
if (++nodes > MAX_PREDICATE_NODES) {
447-
return `Filter has too many conditions (max ${MAX_PREDICATE_NODES})`
448-
}
449-
if (depth > MAX_PREDICATE_DEPTH) {
450-
return `Filter nesting is too deep (max ${MAX_PREDICATE_DEPTH} levels)`
451-
}
452-
if (typeof node !== 'object' || node === null) continue
453-
const group = node as { all?: unknown; any?: unknown }
454-
const members = Array.isArray(group.all)
455-
? group.all
456-
: Array.isArray(group.any)
457-
? group.any
458-
: null
459-
if (!members) continue
460-
for (const member of members) stack.push({ node: member, depth: depth + 1 })
461-
}
462-
return null
463-
}
464431

465432
/**
466433
* v2 filter wire format: the typed `{ all | any: [...] }` predicate tree (same
@@ -512,7 +479,7 @@ const predicateTreeSchema: z.ZodType<TablePredicate> = z.lazy(() =>
512479
const predicateGroupSchema = predicateTreeSchema
513480

514481
const predicateBoundarySchema = z.unknown().superRefine((value, ctx) => {
515-
const problem = predicateTreeTooLarge(value)
482+
const problem = getTablePredicateTreeSizeError(value)
516483
if (problem) ctx.addIssue({ code: 'custom', message: problem })
517484
})
518485

apps/sim/lib/table/query-builder/__tests__/validate.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,22 @@ describe('empty groups are rejected at every layer', () => {
213213
expect(() => validatePredicate({ all: [] }, COLS)).toThrow(/at least one condition/)
214214
})
215215
})
216+
217+
describe('predicate complexity limits', () => {
218+
it('rejects deeply nested untrusted input before recursive validation', () => {
219+
let predicate: unknown = { field: 'status', op: 'eq', value: 'active' }
220+
for (let depth = 0; depth < 20_000; depth++) predicate = { all: [predicate] }
221+
222+
expect(() => validatePredicateShape(predicate as never)).toThrow(/Filter nesting is too deep/)
223+
})
224+
225+
it('rejects oversized groups at the shared runtime boundary', () => {
226+
const conditions = Array.from({ length: 101 }, () => ({
227+
field: 'status',
228+
op: 'eq' as const,
229+
value: 'active',
230+
}))
231+
232+
expect(() => validatePredicateShape({ all: conditions })).toThrow(/at most 100 conditions/)
233+
})
234+
})

apps/sim/lib/table/query-builder/predicate.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
11
import type { TablePredicate, TablePredicateInput } from '@/lib/table/types'
22

3+
/** Max members in one `all`/`any` group. */
4+
export const MAX_PREDICATE_GROUP_SIZE = 100
5+
6+
const MAX_PREDICATE_DEPTH = 10
7+
const MAX_PREDICATE_NODES = 500
8+
9+
/**
10+
* Returns the predicate size-limit violation for an untrusted tree, if any.
11+
* The walk stays iterative so pathological input cannot overflow the call stack
12+
* before the caller turns the result into its boundary-specific validation error.
13+
*/
14+
export function getTablePredicateTreeSizeError(root: unknown): string | null {
15+
const stack: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 1 }]
16+
let nodes = 0
17+
18+
while (stack.length > 0) {
19+
const { node, depth } = stack.pop()!
20+
if (++nodes > MAX_PREDICATE_NODES) {
21+
return `Filter has too many conditions (max ${MAX_PREDICATE_NODES})`
22+
}
23+
if (depth > MAX_PREDICATE_DEPTH) {
24+
return `Filter nesting is too deep (max ${MAX_PREDICATE_DEPTH} levels)`
25+
}
26+
if (typeof node !== 'object' || node === null) continue
27+
28+
const group = node as { all?: unknown; any?: unknown }
29+
const members = Array.isArray(group.all)
30+
? group.all
31+
: Array.isArray(group.any)
32+
? group.any
33+
: null
34+
if (!members) continue
35+
if (members.length > MAX_PREDICATE_GROUP_SIZE) {
36+
return `A filter group can contain at most ${MAX_PREDICATE_GROUP_SIZE} conditions`
37+
}
38+
for (const member of members) stack.push({ node: member, depth: depth + 1 })
39+
}
40+
41+
return null
42+
}
43+
344
/**
445
* Converts the readable single-condition v2 input into the grouped shape every
546
* downstream table path stores and executes. Callers validate untrusted input

apps/sim/lib/table/query-builder/validate.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isRecordLike } from '@sim/utils/object'
22
import { getColumnId } from '@/lib/table/column-keys'
33
import { NAME_PATTERN } from '@/lib/table/constants'
44
import { TableQueryValidationError } from '@/lib/table/errors'
5+
import { getTablePredicateTreeSizeError } from '@/lib/table/query-builder/predicate'
56
import type {
67
ColumnDefinition,
78
ColumnType,
@@ -119,6 +120,15 @@ export function validatePredicateShape(predicate: TablePredicateInput): void {
119120
}
120121

121122
function validateNode(node: PredicateNode, typeByName: Map<string, ColumnType> | null): void {
123+
const sizeError = getTablePredicateTreeSizeError(node)
124+
if (sizeError) throw new TableQueryValidationError(sizeError, 'INVALID_FILTER')
125+
validateNodeStructure(node, typeByName)
126+
}
127+
128+
function validateNodeStructure(
129+
node: PredicateNode,
130+
typeByName: Map<string, ColumnType> | null
131+
): void {
122132
// Guard before the `in` checks below: an untrusted caller (copilot args, a raw
123133
// block value) can hand us a string/number/null, where `'all' in node` throws
124134
// a raw TypeError. Fail with a clean, actionable message instead.
@@ -166,7 +176,7 @@ function validateNode(node: PredicateNode, typeByName: Map<string, ColumnType> |
166176
'INVALID_FILTER'
167177
)
168178
}
169-
for (const child of members) validateNode(child, typeByName)
179+
for (const child of members) validateNodeStructure(child, typeByName)
170180
return
171181
}
172182
// Neither a group nor a leaf. Overwhelmingly this is the legacy `$`-grammar

0 commit comments

Comments
 (0)