Skip to content

fix(swift): remove catastrophic backtracking in Vapor route regex - #1547

Open
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1544-vapor-route-redos
Open

fix(swift): remove catastrophic backtracking in Vapor route regex#1547
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1544-vapor-route-redos

Conversation

@maxmilian

Copy link
Copy Markdown
Contributor

Fixes #1544.

Problem

vaporResolver.extract matches Vapor routes with

/\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,\s*)*)use:\s*([A-Za-z_][\w.]*)/g

The arg-list group (?:[^,()]+,\s*)* is ambiguous: the trailing \s* and the
next iteration's [^,()]+ can both claim the same run of spaces, so there are
exponentially many ways to partition the same input. When a .METHOD(...) call
has many comma-separated labelled args and never reaches use: — exactly the
generated request-builder shape in the issue — the engine explores all of them
before failing.

Measurements

app.get(arg0: value0, arg1: value1, …) with n args and no use:
(Node v24.14.0, macOS):

n input len before after
16 243 2.6 ms 0.005 ms
20 307 40.3 ms 0.003 ms
24 371 647 ms 0.004 ms
30 467 41.7 s 0.004 ms
60 947 no result after 120 s 0.005 ms

Time roughly ×4 per +2 args before the fix. After the fix it is linear:
1000 args / 17,787 chars → 0.090 ms.

Since index, sync and the MCP connect-time catch-up sync all run this
resolver, one such file hangs the process indefinitely — which is what bricked
the reporter's MCP integration.

Fix

Anchor every repetition at a comma:

-((?:[^,()]+,\s*)*)
+((?:[^,()]+,)*\s*)

, is excluded from the character class, so each repetition ends at the next
comma and the split is unique — there is nothing left to re-partition, and
matching is linear. The trailing \s* absorbs the whitespace after the final
comma, which the old pattern captured inside the group; whitespace after
non-final commas is absorbed by the following [^,()]+, so capture group 3 is
byte-for-byte identical.

Why \s* and not the [^,()]*? tail from the issue

The issue suggests (?:[^,()]+,)*[^,()]*?, and that is the right idea — the
whole fix comes from it. Both versions kill the exponential blow-up by the same
mechanism: every repetition is forced to end at a comma, and , is excluded
from [^,()], so there is exactly one way to split the arg list and nothing to
backtrack over. I only narrowed the tail by one notch, and I want to be explicit
about why in case you'd rather have the looser form.

[^,()]*? widens the match set: use: no longer has to be preceded by a comma,
so text that the old regex rejected starts matching. Two cases from my
equivalence run:

input old …,)*[^,()]*? …,)*\s* (this PR)
app.get("a" use: h) (missing comma, not valid Swift) 0 matches 1 match 0 matches
req.get(foo.use: bar) 0 matches 1 match 0 matches

(With the lazy tail the second one is indexed as a route node with handler
reference bar — a false route in the graph, not just a wasted match.)

\s* instead corresponds exactly to what the old pattern's final \s* did —
"the whitespace after the last comma" — so the match set is unchanged rather
than merely a superset. That kept the equivalence check below a strict
comparison (identical index and identical capture groups) instead of "no
regressions on the routes we happen to test".

If you'd prefer the more permissive tail — e.g. you want a route with a missing
comma to still be indexed — say so and I'll swap it; the performance
characteristics are the same either way.

Behaviour is unchanged

Old and new regex produce identical index + all four capture groups on:

  • 18 hand-written Vapor shapes — no args (admin.get(use: self.list)),
    single/multiple path segments, User.parameter, ":id", dotted and
    self.-prefixed handlers, irregular whitespace, multi-line calls, several
    routes on one line, plus the non-route cases Environment.get("X"),
    req.parameters.get("id"), app.get("a" use: h) and
    app.get("a", foo(bar), use: h) (all 0 matches on both);
  • 200,000 fuzzed inputs assembled from route-ish tokens.

Tests

Two regression tests in __tests__/frameworks.test.ts:

  1. does not backtrack exponentially on a long arg list without use:
    60 args, no use:, asserts an explicit < 250 ms wall-clock bound. On the
    old regex this input never returns (>120 s); dialled down to 24 args so the
    old code can finish at all, it fails with
    expected 668.119334 to be less than 250. The committed 60-arg version
    finishes in 0.005 ms, ~50,000× under the bound, so it is not flaky.
  2. still parses every Vapor route shape after the arg-list rewrite
    asserts the 5 route names and 5 handler references for no-arg, single-arg,
    multi-arg + X.parameter, irregular-whitespace and multi-line calls, so a
    future ReDoS-style rewrite cannot silently break parsing.

__tests__/frameworks.test.ts 115/115 pass, the four Swift-related test files
162/162 pass, and tsc --noEmit is clean. The 54 pre-existing failures in the
full suite (cli-*, mcp-*, index-command, status-json, *-watchdog
they exec the built dist/ CLI) reproduce identically on an unmodified
main.

The arg-list group `(?:[^,()]+,\s*)*` was ambiguous: the trailing `\s*`
and the next iteration's `[^,()]+` could both claim the same run of
spaces, so a `.METHOD(...)` call with many comma-separated args that
never reaches `use:` forced an exponential search. Measured on
`app.get(arg0: value0, ...)`: 40ms at 20 args, 647ms at 24, 41.7s at 30,
and no result after 120s at 60.

Anchoring each repetition at a comma (`(?:[^,()]+,)*\s*`) makes the split
unique — `,` is outside the char class, so there is nothing to
re-partition. Same input is now 0.09ms at 1000 args.

Match behaviour is unchanged: all four capture groups are identical on 18
hand-written Vapor route shapes (no args, single/multi path segments,
`X.parameter`, multi-line calls, Environment.get non-matches) and on
200k fuzzed inputs.

Fixes colbymchenry#1544

@codegraph-impact codegraph-impact Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeGraph review

Overall risk: 🟡 Low — Only the Vapor route-extraction regex changed, and new tests cover both the prior long-argument hang and representative valid route forms.

Worth double-checking

  • Vapor route extraction through the shared registry
What to look for in each
  • Vapor route extraction through the shared registry — The only production change is src/resolution/frameworks/swift.ts switching routeRegex to /\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,)*\s*)use:\s*([A-Za-z_][\w.]*)/g; verify a Swift Vapor project indexed via the normal frameworks entrypoint in src/resolution/frameworks/index.ts still emits the same routes and handler references for grouped, multiline, and mixed string/non-string path arguments.
Business rules — 2 not applicable
Status Rule Note
— Not applicable Surfaces This change only adjusts Vapor route parsing inside src/resolution/frameworks/swift.ts; it does not modify the CLI, MCP, or library surface definitions governed by src/resolution/frameworks/index.ts.
— Not applicable Source strings must exclude interpolated template literals The diff does not touch source-string extraction or template-literal handling; it only changes Swift Vapor route matching.
Full assessment

This PR rewrites the Vapor route-matching regex in Swift framework extraction to remove the ambiguous whitespace/comma split that previously caused exponential backtracking on long argument lists without use:. It also adds focused tests for the hang scenario and for several valid Vapor route shapes after the rewrite. The main thing to scrutinize is whether the tighter regex still matches real-world Vapor route declarations when indexing runs through the shared framework registry.

QA checklist — 1 thing to verify in the running product
  • cli — Index a small Swift Vapor project that contains a deliberately malformed route call with a very long comma-separated argument list but no handler label, and confirm the indexing command returns promptly instead of appearing hung. (Before this PR, that shape could trigger exponential regex backtracking; after this PR, the same input should fail fast and produce no extracted route from that line.)
Blast radius: 1 file affected beyond the diff · 4 symbols · 1 test file selected

Tests to run:

  • __tests__/frameworks.test.ts

Full report

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.

[Bug] Vapor route detector regex has catastrophic backtracking, hangs index/sync/MCP on generated Swift files

1 participant