Skip to content

fix(parser): reject over-deep and oversized input instead of overflowing the stack - #295

Merged
jsam merged 3 commits into
mainfrom
fm/fix-parser-depth
Oct 5, 2026
Merged

jsam merged 3 commits into
mainfrom
fm/fix-parser-depth

Conversation

@jsam

@jsam jsam commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

An authenticated client of any role could abort the whole server with one frame. A 20 KB program of 4,000 nested calls (+nr(abs(abs(...abs(1)...)))) overflowed the tokio worker's 2 MiB stack in the recursive term parser. Rust aborts the process on a stack overflow, so every connection and subscription in the cell went down with it.

The audit of other recursion over client input found more of the same:

  • Rule and query bodies. The plan nests one level per body predicate and per constant. A body of 128 atoms already overflowed a 2 MiB stack.
  • Rule chains. Stratification's recursive Tarjan walk overflowed on a chain of about 8,000 rules.
  • Type expressions. list[list[...]] had no depth limit.
  • Restart. A persisted rule with 40 or more nested calls overflowed serde_json's 128-level limit on reload, so StorageEngine::new failed with WalUnreadable and the engine would not start.

Fix

  • Terms and type expressions nest at most storage.performance.max_nesting_depth levels. The default is 128 and the cap is 1024; 0 resets to the default. Each call, arithmetic operator, parenthesized group, list[...] and record adds a level.
    • Deeper input fails with a validation error: Expression nesting exceeds the limit of 128 levels (storage.performance.max_nesting_depth).
    • An O(n) parenthesis-depth check refuses deep calls and groups before descending.
  • A rule or query body may hold at most 4,096 elements, counting each predicate plus each body-atom argument (Rule body is too large: N elements (body predicates plus body-atom arguments); the limit is 4096). The widest rule in the repo's examples is about 21.
    • Without the cap, 4,096-element bodies of every shape complete without overflow on a 16 MiB stack in release. These include 2,048 self-joins and 2,048 distinct atoms, which take about 150 s of CPU but don't crash.
    • On a 64 MiB stack in debug, 4,096 comparisons, constants and negations complete, as does a 4,096-element body holding a 1,024-deep term. 8,192 elements overflow there.
  • Tarjan SCC walks with an explicit frame stack.
  • The server runtime and the incremental worker run on 64 MiB stacks (ENGINE_THREAD_STACK_BYTES). This is the budget the limits were measured against. The extra space is virtual and only committed when used. The server's startup, including WAL and catalog replay, also runs as a runtime task, so it gets the same stack.
  • WAL records and the rule catalog decode through storage::nested_json. JSON within serde_json's limit takes the old path. Deeper records, up to a bound derived from the nesting ceiling, decode on an engine-sized stack using serde_json's unbounded_depth feature.

Evidence

Live, release build, over /ws:

program main (26565e7) this branch
+nr(abs^4000(1)) (the audit's repro) has overflowed its stack, fatal runtime error: stack overflow, aborting validation in 3 ms, server up
abs nesting 100,000 / parens 100,000 / sum chain 100,000 / 50,000 body atoms / list[ nesting 100,000 (not run, would abort) validation, 13-560 ms each, server up
rule with nesting at the limit (128); rule with a 256-atom body (512 elements, within the 4,096 cap) registered and evaluated; both reload correctly after a restart

Cheap query ?r(X), three alternating rounds of 300 queries: p50 was 0.137-0.145 ms on this branch and 0.138-0.151 ms on main. That is the same within noise.

Tests:

  • tests/nesting_depth_tests.rs checks deep input on every statement form, that each limit is inclusive (at the limit evaluates, one past is refused), the configured limit up to the ceiling, type expressions, and a restart with rules at both limits. It runs on a runtime set up like the server's.
  • Parser unit tests cover depth 4,000 and 100,000.
  • A proptest fuzz target, deep_nesting_is_refused_not_overflowed, generates nested calls, groups and operator chains (seeded around the limit, at 4,000, and up to 20,000) in five statement forms.
  • Also added: a 200,000-node chain SCC test and nested_json bound tests.

Follow-ups filed while landing: #301 (fuzz harness) and #302 (HNSW recall test flake). The CI sharing-test flake is tracked in #289.

jsam added 2 commits October 5, 2026 06:32
…wing the stack

A 20 KB program of 4,000 nested calls overflowed the parser's stack and
aborted the whole server. Recursion over client input is now bounded:

- Terms and type expressions nest at most storage.performance.max_nesting_depth
  levels (default 128, capped at 1024); deeper input is a validation error.
- Rule and query bodies hold at most 512 predicates plus atom arguments,
  since the query plan and every pass over it nest one level per element.
- Stratification's Tarjan walk is iterative, so long rule chains no longer
  recurse once per link.
- Server runtime threads and the incremental worker get 16 MiB stacks, the
  budget the limits above are measured against.
- Persisted rules nest past serde_json's 128-level limit, which made the WAL
  unreadable and the engine unable to start; deep records now decode on an
  engine-sized stack, bounded by the parser's ceiling.
@jsam
jsam force-pushed the fm/fix-parser-depth branch from cbac248 to 54f5173 Compare October 5, 2026 07:16
@jsam jsam changed the title fix(parser): refuse over-deep and over-large input instead of overflowing the stack fix(parser): reject over-deep and oversized input instead of overflowing the stack Oct 5, 2026
@jsam
jsam marked this pull request as ready for review October 5, 2026 08:06
@jsam
jsam merged commit e65a8e7 into main Oct 5, 2026
41 of 45 checks passed
@jsam
jsam deleted the fm/fix-parser-depth branch October 5, 2026 08:07
jsam added a commit that referenced this pull request Oct 6, 2026
Three targets run the server's own code through a test-support seam
(src/fuzzing.rs) on an engine-sized stack at the nesting ceiling:
iql_statement, iql_program (split, parse, bind params, authorize) and
ws_client_frame (pre-auth decode, id probe, request classification).
Beyond crashes they check that a persistent rule reloads unchanged and
that a request run read-only holds only queries.

fuzz/seeds.py seeds them from the example programs, every /ws frame type
and the #295 deep-nesting and oversized-body cases; fuzz/run.sh (make
fuzz) runs a campaign that keeps going past findings.
jsam added a commit that referenced this pull request Oct 6, 2026
…n-finite float literals (#301, #397) (#405)

* test: cargo-fuzz targets for the IQL parser and /ws client frames (#301)

Three targets run the server's own code through a test-support seam
(src/fuzzing.rs) on an engine-sized stack at the nesting ceiling:
iql_statement, iql_program (split, parse, bind params, authorize) and
ws_client_frame (pre-auth decode, id probe, request classification).
Beyond crashes they check that a persistent rule reloads unchanged and
that a request run read-only holds only queries.

fuzz/seeds.py seeds them from the example programs, every /ws frame type
and the #295 deep-nesting and oversized-body cases; fuzz/run.sh (make
fuzz) runs a campaign that keeps going past findings.

* docs(storage_engine): unlink the private revisions module from public docs

make doc-check failed on main since #390: rustdoc denies a public doc
linking to a private item.

* fix(parser): refuse float literals beyond f64's range (#397)

A persistent rule holding 1e400 (or inf, NaN) was stored with a JSON
null, which failed to decode on the next start: WalUnreadable, and the
engine would not start. Every float literal now goes through
float_literal: decimal syntax only, and a literal beyond f64's range is a
validation error. Rust's inf/NaN spellings are identifiers again.
Negating i64::MIN in a literal no longer overflows.

Found by the iql_statement fuzz target; its inputs are kept in
fuzz/regressions/.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant