Skip to content

fix(odata): bound $filter recursion + stop tokenizer $-loop (ARN-176)#344

Open
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-176-odata-filter-dos
Open

fix(odata): bound $filter recursion + stop tokenizer $-loop (ARN-176)#344
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-176-odata-filter-dos

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes ARN-176 — two denial-of-service bugs reachable from the public OData $filter query 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 $filter value 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 a debug_assert!). A stray $ now falls through to InvalidFilter (HTTP 400).

Bug 2 — unbounded parser recursion

Deeply nested parentheses (((((…))))), not chains (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: FilterParser now tracks recursion depth, bounded by MAX_FILTER_DEPTH = 128, via descend()/ascend() wrapped around each of the three recursion sites (parenthesized primary, not operand, function argument list). The pairing counts nesting depth but not width, so legitimate wide-but-shallow filters are unaffected. Over-deep filters return InvalidFilter, 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 returns InvalidFilter.

Red-green evidence (exploit-first)

Tests were written first and confirmed to hang/crash on the pre-fix code, then pass after the fix:

Exploit Pre-fix behavior (observed) Post-fix
Name eq $foo 5.01s watchdog fired — parse_filter never returned (infinite loop) InvalidFilter, returns instantly
((((…)))) ×100k stack overflow, aborting → SIGABRT (signal 6) InvalidFilter
not not … ×100k stack overflow, aborting → SIGABRT (signal 6) InvalidFilter
f(f(…)) ×100k unbounded recursion → stack overflow InvalidFilter
Name eq 'foo (unterminated) silently accepted (assert failed) InvalidFilter

The $ 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-odata60 passed, 0 failed (7 new regression tests), suite finishes in ~0.08s (no hang/overflow). cargo fmt --check and cargo clippy clean.

Reviews

  • Independent code review (code-reviewer agent): PASS, no Critical/Important findings. Verified all three unbounded-recursion paths are guarded, the tokenizer advances on every branch, $ removal loses no capability, and there is no off-by-one.
  • DST self-review: temper-odata is not a simulation-visible crate; production changes are deterministic (Vec/String/usize only); the watchdog thread is test-only.

Notes

  • Bug fix in a single file — no ADR required per the repo rule.
  • not precedence (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 $filter tokenizer and recursive-descent parser, plus a silent acceptance of unterminated string literals — all in crates/temper-odata/src/query/filter.rs.

  • Tokenizer $ loop: $ is removed from identifier-start characters; it now falls through to InvalidFilter (HTTP 400) instead of spinning the tokenizer forever. A debug_assert! documents the forward-progress invariant.
  • Unbounded parser recursion: A depth counter is added to FilterParser, bounded by MAX_FILTER_DEPTH = 128, guarded at all three recursive sites — parenthesized sub-expressions, not operands, and function-call argument lists — via matching descend()/ascend() pairs. Width (many siblings at the same level) correctly does not consume depth budget.
  • Unterminated string literals: A closed flag now requires the tokenizer to have seen a matching closing quote before emitting the token; missing close quotes return InvalidFilter.

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

Filename Overview
crates/temper-odata/src/query/filter.rs Single-file security fix: removes $ from identifier-start chars to stop infinite loop, adds descend()/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
Loading
%%{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 --> Parser
Loading

Comments Outside Diff (2)

  1. crates/temper-odata/src/query/filter.rs, line 432 (link)

    P2 File exceeds 500-line module split rule

    filter.rs is 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 to src/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
    This is a comment left during a code review.
    Path: crates/temper-odata/src/query/filter.rs
    Line: 432
    
    Comment:
    **File exceeds 500-line module split rule**
    
    `filter.rs` is 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 to `src/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](https://app.greptile.com/arni-labs/github/nerdsane/temper/-/custom-context?memory=c3ad5080-2c13-46c8-b4c1-9e266e4df914))
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex Fix in Cursor

  2. crates/temper-odata/src/query/filter.rs, line 1 (link)

    P2 File exceeds 500-line split threshold

    CLAUDE.md / AGENTS.md require 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 a filter/ directory module with tokenizer.rs, parser.rs, and tests/ (or an inline tests.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
    This is a comment left during a code review.
    Path: crates/temper-odata/src/query/filter.rs
    Line: 1
    
    Comment:
    **File exceeds 500-line split threshold**
    
    `CLAUDE.md` / `AGENTS.md` require 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 a `filter/` directory module with `tokenizer.rs`, `parser.rs`, and `tests/` (or an inline `tests.rs`). This doesn't need to block the security fix, but a follow-up split should be tracked.
    
    **Context Used:** CLAUDE.md ([source](https://app.greptile.com/arni-labs/github/nerdsane/temper/-/custom-context?memory=c3ad5080-2c13-46c8-b4c1-9e266e4df914))
    
    How can I resolve this? If you propose a fix, please make it concise.

    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!

    Fix in Claude Code Fix in Codex Fix in Cursor

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
crates/temper-odata/src/query/filter.rs:1
**File exceeds 500-line split threshold**

`CLAUDE.md` / `AGENTS.md` require 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 a `filter/` directory module with `tokenizer.rs`, `parser.rs`, and `tests/` (or an inline `tests.rs`). This doesn't need to block the security fix, but a follow-up split should be tracked.

Reviews (2): Last reviewed commit: "fix: bound OData $filter recursion and s..." | Re-trigger Greptile

Context used:

  • Context used - CLAUDE.md (source)

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]>
@rita-aga rita-aga marked this pull request as ready for review July 7, 2026 07:08
@rita-aga

rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile review

@rita-aga

Copy link
Copy Markdown
Collaborator Author

ARN-165 principle audit — request changes

The concrete $ infinite loop, several recursion cases, and unterminated-string handling are fixed. The parser is still not bounded as a request workload, so ARN-176 remains open.

Blocking findings

  • There is no shared byte, token, AST-node, argument, sibling-width, or evaluation-step budget.
  • Very wide left-associated expressions still build and evaluate recursively; a depth guard does not bound width or total work.
  • A watchdog that abandons a worker does not stop an allocator or CPU loop; it can turn request timeout into detached resource consumption.
  • The reported not precedence discrepancy remains.
  • The parser file remains roughly 763 lines, violating the repository's 500-line rule and making grammar/budget review harder.

Required direction

Introduce 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.

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