fix(odata): bound $filter recursion + stop tokenizer $-loop (ARN-176)#344
Open
rita-aga wants to merge 1 commit into
Open
fix(odata): bound $filter recursion + stop tokenizer $-loop (ARN-176)#344rita-aga wants to merge 1 commit into
rita-aga wants to merge 1 commit into
Conversation
Two denial-of-service bugs were reachable from the public OData `$filter` query surface (crates/temper-odata/src/query/filter.rs): 1. Tokenizer infinite loop: `$` was accepted as an identifier-start character, but the identifier continue-loop never consumed it, so the tokenizer never advanced — an infinite loop that also grew the token vector without bound. Any `$filter` value containing a bare `$` hung the request thread. Fix: `$` is no longer an identifier-start char, so the start set is now a strict subset of the continue set and the loop always makes forward progress; a stray `$` returns InvalidFilter (400). 2. Unbounded parser recursion: deeply nested parentheses, `not`, or function-call arguments recursed without limit and overflowed the request thread's stack, aborting the process. Fix: FilterParser now tracks recursion depth bounded by MAX_FILTER_DEPTH (128) via descend()/ascend() wrapped around each recursion site; over-deep filters return InvalidFilter instead of crashing. Also fixes a related low-severity bug in the same tokenizer: an unterminated string literal was silently accepted as if closed; it now returns InvalidFilter. Adds red-green regression tests: the `$` case uses a watchdog thread so a regression fails fast instead of hanging the suite; the nesting cases assert an error is returned rather than overflowing the stack; a guard test confirms legitimate deep-but-bounded and wide filters still parse. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Collaborator
Author
|
@greptile review |
Collaborator
Author
ARN-165 principle audit — request changesThe concrete Blocking findings
Required directionIntroduce one explicit budget consumed by lexing, parsing, and evaluation; reject before allocation/work when exhausted. Prefer iterative or provably bounded evaluation, add property/fuzz cases for width and mixed nesting, and split the module along lexer/parser/AST/evaluator responsibilities. Keep the good regression tests, but do not declare the DoS class resolved from the original three payloads alone. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes ARN-176 — two denial-of-service bugs reachable from the public OData
$filterquery surface, both confirmed with running repros. All changes are in a single file,crates/temper-odata/src/query/filter.rs.Bug 1 — tokenizer infinite loop on
$$was accepted as an identifier-start character, but the identifier continue-loop never included$, so the tokenizer never advanced past it — an infinite loop that also grew the token vector without bound. Any$filtervalue containing a bare$(e.g.Name eq $foo) hung the request thread.Fix:
$is no longer an identifier-start character. The start set (alphabetic | '_') is now a strict subset of the continue set, so the identifier branch always consumes at least one character (documented with adebug_assert!). A stray$now falls through toInvalidFilter(HTTP 400).Bug 2 — unbounded parser recursion
Deeply nested parentheses (
((((…))))),notchains (not not not …), or nested function-call arguments (f(f(f(…)))) recursed without limit and overflowed the request thread's stack, aborting the process (SIGABRT).Fix:
FilterParsernow tracks recursion depth, bounded byMAX_FILTER_DEPTH = 128, viadescend()/ascend()wrapped around each of the three recursion sites (parenthesized primary,notoperand, function argument list). The pairing counts nesting depth but not width, so legitimate wide-but-shallow filters are unaffected. Over-deep filters returnInvalidFilter, not a crash. The bound is inclusive (128 levels accepted, 129 rejected) and generously under any stack limit.Also fixed (LOW, same issue)
Unterminated string literal (e.g.
Name eq 'foo) was silently accepted as if closed; it now returnsInvalidFilter.Red-green evidence (exploit-first)
Tests were written first and confirmed to hang/crash on the pre-fix code, then pass after the fix:
Name eq $fooparse_filternever returned (infinite loop)InvalidFilter, returns instantly((((…))))×100kstack overflow, aborting→ SIGABRT (signal 6)InvalidFilternot not … ×100kstack overflow, aborting→ SIGABRT (signal 6)InvalidFilterf(f(…)) ×100kInvalidFilterName eq 'foo(unterminated)InvalidFilterThe
$regression test uses a watchdog thread + timeout so a future regression fails fast instead of hanging the suite. A guard test (filter_moderately_nested_parens_still_parse) and an exact-boundary test (filter_depth_bound_is_inclusive_at_the_limit) confirm legitimate deep-but-bounded and wide filters still parse.Test results
cargo test -p temper-odata— 60 passed, 0 failed (7 new regression tests), suite finishes in ~0.08s (no hang/overflow).cargo fmt --checkandcargo clippyclean.Reviews
$removal loses no capability, and there is no off-by-one.temper-odatais not a simulation-visible crate; production changes are deterministic (Vec/String/usizeonly); the watchdog thread is test-only.Notes
notprecedence (a separate LOW noted in the issue) is intentionally out of scope: changing it is a behavioral/semantics change, not a security fix, and would alter existing parse results.Closes ARN-176 once merged. Draft — do not merge yet.
🤖 Generated with Claude Code
Greptile Summary
This PR fixes two confirmed denial-of-service bugs in the OData
$filtertokenizer and recursive-descent parser, plus a silent acceptance of unterminated string literals — all incrates/temper-odata/src/query/filter.rs.$loop:$is removed from identifier-start characters; it now falls through toInvalidFilter(HTTP 400) instead of spinning the tokenizer forever. Adebug_assert!documents the forward-progress invariant.depthcounter is added toFilterParser, bounded byMAX_FILTER_DEPTH = 128, guarded at all three recursive sites — parenthesized sub-expressions,notoperands, and function-call argument lists — via matchingdescend()/ascend()pairs. Width (many siblings at the same level) correctly does not consume depth budget.closedflag now requires the tokenizer to have seen a matching closing quote before emitting the token; missing close quotes returnInvalidFilter.Confidence Score: 4/5
The security fixes are correct and well-tested; the only concern is a pre-existing file-length rule violation that this PR worsens but did not introduce.
All three recursive descent sites are guarded with matching descend/ascend pairs. The depth counter unwinds correctly during error propagation. The tokenizer now makes guaranteed forward progress on every branch. The seven new tests include a boundary test, a width-vs-depth test, and a watchdog for the infinite-loop regression. The post-patch file is 763 lines against a 500-line split rule — pre-existing but made larger by the test additions — which warrants a follow-up but does not affect correctness.
crates/temper-odata/src/query/filter.rs — now 763 lines, should be split into a directory module in a follow-up.
Important Files Changed
$from identifier-start chars to stop infinite loop, addsdescend()/ascend()depth guard at all three recursive sites (parens,not, function args), and rejects unterminated string literals. Seven new regression tests including boundary and width-vs-depth cases. Post-patch line count (763) exceeds the 500-line split threshold from project conventions.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["tokenize_filter(input)"] --> B{char type?} B -- whitespace --> C[skip i++] C --> B B -- single-quote --> D[parse string literal] D --> E{closing quote found?} E -- yes --> F[push Token] E -- "no / EOF" --> G["InvalidFilter: unterminated literal"] B -- "alpha or underscore" --> J[parse identifier loop] J --> K[push Token] B -- "dollar-sign or other" --> M["InvalidFilter: unexpected char"] B -- digit --> I[parse number, push Token] B -- "paren or comma" --> H[push single-char Token] subgraph Parser["FilterParser depth-bounded at 128"] P["parse_or"] --> Q["parse_and"] Q --> R["parse_not"] R -- "not keyword" --> S["descend then recurse parse_not then ascend"] S --> T{depth exceeded?} T -- yes --> U["InvalidFilter: nesting too deep"] T -- no --> R R --> V["parse_comparison"] V --> W["parse_primary"] W -- "open-paren" --> X["descend then parse_or then ascend"] X --> T W -- "function name" --> Y["parse_argument_list: descend per arg then ascend"] Y --> T end A --> Parser%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A["tokenize_filter(input)"] --> B{char type?} B -- whitespace --> C[skip i++] C --> B B -- single-quote --> D[parse string literal] D --> E{closing quote found?} E -- yes --> F[push Token] E -- "no / EOF" --> G["InvalidFilter: unterminated literal"] B -- "alpha or underscore" --> J[parse identifier loop] J --> K[push Token] B -- "dollar-sign or other" --> M["InvalidFilter: unexpected char"] B -- digit --> I[parse number, push Token] B -- "paren or comma" --> H[push single-char Token] subgraph Parser["FilterParser depth-bounded at 128"] P["parse_or"] --> Q["parse_and"] Q --> R["parse_not"] R -- "not keyword" --> S["descend then recurse parse_not then ascend"] S --> T{depth exceeded?} T -- yes --> U["InvalidFilter: nesting too deep"] T -- no --> R R --> V["parse_comparison"] V --> W["parse_primary"] W -- "open-paren" --> X["descend then parse_or then ascend"] X --> T W -- "function name" --> Y["parse_argument_list: descend per arg then ascend"] Y --> T end A --> ParserComments Outside Diff (2)
crates/temper-odata/src/query/filter.rs, line 432 (link)filter.rsis now 762 lines, crossing the 500-line threshold from CLAUDE.md/AGENTS.md ("Files > 500 lines must be split into directory modules"). The pre-existing code was already ~538 lines; this PR adds ~163 test lines, widening the gap. The test module (lines 432–762, roughly 330 lines) is the natural split point — moving it tosrc/query/filter/tests.rs(or a#[path]submodule) would bring the main file back well under 500 while keeping all regression tests discoverable.Context Used: CLAUDE.md (source)
Prompt To Fix With AI
crates/temper-odata/src/query/filter.rs, line 1 (link)CLAUDE.md/AGENTS.mdrequire files over 500 lines to be split into directory modules. The file was already above the limit before this PR; the 163 lines of new tests bring it to 763 lines. The natural split would be afilter/directory module withtokenizer.rs,parser.rs, andtests/(or an inlinetests.rs). This doesn't need to block the security fix, but a follow-up split should be tracked.Context Used: CLAUDE.md (source)
Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix All With AI
Reviews (2): Last reviewed commit: "fix: bound OData $filter recursion and s..." | Re-trigger Greptile
Context used: