Skip to content

FileSystemRouter: reset route params when a candidate route does not match - #33455

Closed
robobun wants to merge 3 commits into
mainfrom
farm/d3b53d96/router-param-leak
Closed

robobun wants to merge 3 commits into
mainfrom
farm/d3b53d96/router-param-leak

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #12206
Fixes #15554

Bun.FileSystemRouter reports parameters belonging to routes that did not match, and segfaults on .params for a route tree containing both a [x] and a [...x] route.

Repro

import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";

function router(files) {
  const d = mkdtempSync(tmpdir() + "/fsr-");
  for (const f of files) {
    mkdirSync(d + "/" + f.split("/").slice(0, -1).join("/"), { recursive: true });
    writeFileSync(d + "/" + f, "export default 1;\n");
  }
  return new Bun.FileSystemRouter({ style: "nextjs", dir: d });
}

// "user" comes from [user]/settings, which does not match this path
const a = router(["[user]/settings.tsx", "help/[...usertopic].tsx"]).match("/help/settings/a");
console.log(a.name, JSON.stringify(a.params));

// the catch-all's value is prefixed with the losing route's value
const b = router(["[topic]/settings.tsx", "help/[...topic].tsx"]).match("/help/settings/a");
console.log(b.name, JSON.stringify(b.params));

// segfault
const c = router(["[user]/settings.tsx", "help/[...topic].tsx"]).match("/help/settings/a");
console.log(JSON.stringify(c.params));

On 1.4.0 and on main:

/help/[...usertopic] {"user":"help","usertopic":"settings/a"}
/help/[...topic] {"topic":["help","settings/a"]}
panic(main thread): Segmentation fault at address 0x0

Under a debug build the same input trips the invariant the crash violates:

panic: assertion failed: name_slice.length > 0
<bun_url::Iterator>::next                                    src/url/lib.rs:1314
...QueryObjectCreator as ObjectInitializer>::create          src/runtime/api/filesystem_router.rs:925
<MatchedRoute>::get_params                                   src/runtime/api/filesystem_router.rs:1032

Cause

Routes::match_dynamic walks the candidate routes in sort order and hands each one the same params scratch list. Pattern::match_ pushes into that list as it consumes segments, but it only truncated the list on two of its six failure paths:

  • the static segment mismatched, and
  • a dynamic segment was followed by more path but the pattern had ended.

The other four leave whatever was pushed in place. The repro hits the one where the pattern runs out while path segments remain: [user]/settings consumes help as user, matches settings, still has /a left over, falls out of the loop and returns false with user=help still in the list. help/[...usertopic] then matches and inherits it. A catch-all reached with nothing left to consume ([a]/b/[...rest] against /x/b) leaks the same way.

This is also what #12206 and #15554 report. When the losing route and the winning
route happen to name their parameter the same thing, the two values collapse into
one key and .params hands back a string[] where the docs promise a string:
/admin/[businessId]/providers/create matched against
/admin/6679fbe17b41431a977163fd/providers/create yields
{ businessId: ["6679fbe17b41431a977163fd", "6679fbe17b41431a977163fd"] }.

The crash is the downstream consequence. PathnameScanner resolves each parameter's name against the winning route's name, so a leaked name that the winner does not contain resolves to a zero-length StringPointer. toStringCopy turns that into a null WTF::String, and JSC__JSObject__putRecord passes it to Identifier::fromString, which dereferences the null StringImpl. When the leaked name happens to be a substring of the winner's name (user inside [...usertopic]) there is no crash, just a bogus parameter.

Fix

Pattern::match_ becomes a single-exit wrapper that records params.len() on entry and truncates back to it whenever the inner matcher returns false. The two scattered truncate(0) calls go away, and every failure path is covered by construction rather than by enumeration.

This has been there since the original implementation (the Rust port is faithful to the Zig Pattern.match), so it is not a regression; the test lives in the module's own test file.

Verification

test/js/bun/util/filesystem_router.test.ts gains four cases, all of which fail on the unfixed build:

before / after
$ git stash push -- src/ && bun bd test test/js/bun/util/filesystem_router.test.ts
(fail) does not leak params from a probed route into /help/[...usertopic]
    "params": {
+     "user": "help",
      "usertopic": "settings/a",
(fail) does not leak params from a probed route into /help/[...topic]
-     "topic": "settings/a",
+     "topic": [ "help", "settings/a" ],
(fail) does not leak params from a probed route into /[c]/[...d]
-     "c": "x",
+     "c": [ "x", "x" ],
(fail) does not crash reading .params when a probed route's param name is absent from the match
+ panic: assertion failed: name_slice.length > 0
 27 pass, 4 fail

$ git stash pop && bun bd test test/js/bun/util/filesystem_router.test.ts
 31 pass, 0 fail

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0eee9046-2acd-478b-a530-6172f12eb72f

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 821f9b1.

📒 Files selected for processing (2)
  • src/router/lib.rs
  • test/js/bun/util/filesystem_router.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:14 AM PT - Jul 6th, 2026

❌ @robobun, your commit 821f9b1 has 1 failures in Build #68990 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33455

That installs a local version of the PR into your bun-33455 executable, so you can run:

bun-33455 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Bun.FileSystemRouter router match unknown behaviour #12206 - businessId param appears as duplicated array instead of string when multiple dynamic route segments exist — same leaked-params root cause
  2. Array instead of string in MatchedRoute when specific file structure #15554 - Param a appears as ["1", "1"] array instead of "1" with nested [a]/test/[b] file structure — stale params from failed candidate routes

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #12206
Fixes #15554

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the single-exit wrapper covers every failure path by construction, and the tests exercise each leaking case plus the crash.

Extended reasoning...

Overview

Two files touched. src/router/lib.rs renames the body of Pattern::match_ to a private match_inner and wraps it in a 6-line shim that records params.len() on entry and truncates back to that length whenever the inner matcher returns false. The two ad-hoc params.truncate(0) calls inside the loop are removed as redundant. test/js/bun/util/filesystem_router.test.ts gains three it.each cases asserting the full { name, params } shape for each leaking failure path, plus a subprocess test for the two route trees that previously segfaulted.

Security risks

None. This is pure route-matching bookkeeping over an in-memory scratch Vec; no untrusted input parsing, allocation sizing, or privilege boundaries change. If anything the fix removes a null-deref crash reachable from user-controlled route trees.

Level of scrutiny

Low-to-medium. The Rust change is mechanical: the old body is moved verbatim into match_inner (minus two truncate(0) lines), and the new wrapper is a save-len / call / restore-on-false pattern that is strictly more conservative than what it replaces. The switch from truncate(0) to truncate(params_len) is behaviorally identical because every caller (match_dynamic, the pattern_match unit test) enters with an empty list, and params_len is the semantically correct invariant anyway. match_inner is private so no public surface changes.

Other factors

The PR description includes a clear root-cause trace (which failure paths leaked, why the crash follows) and before/after test output showing all four new tests fail on the unfixed build. Tests follow the file's existing conventions (tempDir, bunExe/bunEnv, concurrent pipe drain, exit-code asserted last) and live in the module's own test file as CLAUDE.md prescribes for non-regressions. No CODEOWNERS entry covers src/router/. The bug-hunting system found nothing.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed both, and added their route trees as test fixtures.

Running each issue's reported structure against stock 1.4.0 and against this branch:

                              stock 1.4.0                                with this PR
#12206   businessId: ["6679fbe17b41431a977163fd",       businessId: "6679fbe17b41431a977163fd"
                      "6679fbe17b41431a977163fd"]
#15554   a: ["1", "1"],  b: "2"                         a: "1",  b: "2"

Both are the duplicate-name form of the leak. In #12206, /admin/[businessId]/providers/[providerId]/edit is probed first, consumes 6679fbe17b41431a977163fd as businessId, then fails because the path has no segment left for [providerId]. Its parameter stays in the scratch list, /admin/[businessId]/providers/create matches and pushes its own businessId, and the two entries with the same name collapse into an array. #15554 is the same shape with [a]/test/[b].

That also answers the API question in #12206: .params is Record<string, string>, and the string[] was always the leak showing through. Both of the file structures listed in #15554 are covered.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — surgical fix that restores the params scratch list on every failure path by construction, with thorough tests covering the reported route trees.

Extended reasoning...

Overview

Two files touched: src/router/lib.rs (~20 net lines) and test/js/bun/util/filesystem_router.test.ts (+110 lines of tests). The Rust change extracts the body of Pattern::match_ into a private match_inner and wraps it with a save-length / truncate-on-false guard, then deletes the two now-redundant truncate(0) calls from the inner body. The old code truncated on only two of six failure exits; the wrapper covers all of them by construction rather than enumeration. Since every call site enters with an empty params list, truncate(params_len) is behaviorally identical to the old truncate(0) on the paths that already had it — and strictly more correct if a future caller ever passes a non-empty list.

Security risks

None. This is filesystem-route pattern matching against a URL path; no auth, crypto, permissions, or untrusted-length arithmetic is touched. The bug produced wrong .params and a null-deref crash; the fix restores the invariant that a failed candidate route leaves the shared scratch list unchanged.

Level of scrutiny

Low-to-moderate. The Rust change is a mechanical refactor into a single-exit wrapper — the kind of shape that is easy to verify by reading (record length → call inner → on false, truncate back). match_inner is private, so no API surface changes. The fix sits at the layer that owns the invariant (the function that pushes into the shared list is responsible for cleaning up on failure). No CODEOWNERS on these paths.

Other factors

Tests are well-structured: an it.each over six fixtures (three failure-path shapes plus the exact repros from #12206 and #15554) asserting the full { name, params } object, and a subprocess test for the two crash-inducing trees so a segfault surfaces as an exit code. The subprocess test follows the file's existing conventions (tempDir, bunExe()/bunEnv, concurrent pipe drain, stdout asserted before exit code). PR description shows before/after test output confirming all new tests fail on the unfixed build. No prior human review comments to address.

robobun added 2 commits July 6, 2026 11:50
…match

Pattern::match_ pushes parameters into a scratch list that every candidate
route shares, but it only cleared that list on two of its six failure paths.
A route that matched part of the path, pushed a parameter and then failed
(pattern exhausted with segments remaining, or a catch-all reached with
nothing left to consume) left the parameter behind for the next candidate,
so the route that eventually matched reported parameters belonging to a
route that did not.

When the leaked parameter name does not appear in the winning route's name,
string_pointer_from_strings resolves it to a zero-length slice, toStringCopy
turns that into a null WTF::String, and Identifier::fromString dereferences
it: reading .params segfaults.

Give match_ a single exit point that truncates the list back to the length it
had on entry whenever the match fails.
Both report the same leak: a candidate route is probed, pushes its parameter
and fails, and the route that then matches reports that parameter alongside
its own. Because the two routes name the parameter the same thing, the two
values collapse into an array and .params hands back string[] where the docs
promise string.
@robobun
robobun force-pushed the farm/d3b53d96/router-param-leak branch from 144e9a9 to 72decbd Compare July 6, 2026 11:52

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — clean structural fix that covers every failure path by construction, with thorough test coverage of both linked issues and the crash case.

Extended reasoning...

Overview

This PR fixes Bun.FileSystemRouter leaking route params from failed candidate matches into the winning match, which manifests as spurious extra params, string[] values where string is expected (#12206, #15554), and a segfault when a leaked param name isn't a substring of the winning route's name. The Rust change in src/router/lib.rs is ~20 lines: Pattern::match_ is refactored into a thin wrapper that records params.len() on entry, delegates to a new match_inner, and truncates back to the entry length on any false return. The two scattered truncate(0) calls in the old body are removed as now-redundant. The test file gains an it.each over six fixtures (three synthetic leak paths plus the exact route trees from #12206 and both variants from #15554) and one subprocess test for the crash case.

Security risks

None. This is pure in-process routing logic over already-validated route names; no auth, crypto, network, or filesystem writes are touched. The fix strictly reduces what reaches the caller (stale params are dropped), so it cannot introduce new exposure.

Level of scrutiny

Low-to-moderate. The change is small, mechanical, and correct by construction: instead of enumerating which of six failure paths need to reset the scratch list, the wrapper resets on every failure. I verified Pattern::match_ has exactly one production caller (Routes::match_dynamic, which always enters with an empty list) plus the in-crate unit test (which truncates before each call), so replacing truncate(0) with truncate(entry_len) is behaviorally identical for existing callers while being more robust. match_inner only ever pushes to params, so truncating back to the entry length exactly restores entry state.

Other factors

  • No CODEOWNERS for src/router/.
  • The bug hunter found no issues.
  • Test coverage is strong: fixtures assert the full { name, params } shape via toEqual (so extra keys fail), and the crash repro runs in a subprocess so a segfault surfaces as an exit code. The new subprocess test's expect(stderr).toBe("") matches the pattern already used by several other tests in the same file.
  • The one CI failure reported by robobun is test/js/bun/cookie/cookie-map.test.ts, which is entirely unrelated to this change (that file was touched by a separate recent commit on main and this PR doesn't go near cookies).
  • No outstanding human review comments.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — clean single-exit wrapper that restores the params invariant on every failure path, with tests covering both linked issues and the crash.

Extended reasoning...

Overview

Two files changed. In src/router/lib.rs, Pattern::match_ is refactored into a thin wrapper that records params.len() on entry, delegates to a new private match_inner, and truncates params back to the entry length whenever the inner matcher returns false. The two ad-hoc params.truncate(0) calls (which covered only 2 of 6 failure paths) are removed. In test/js/bun/util/filesystem_router.test.ts, an it.each over six route-tree fixtures (three synthetic leak paths plus the exact trees from #12206 and #15554) asserts the full { name, params } shape, and a subprocess test covers the two trees that previously segfaulted so a crash surfaces as an exit code.

Security risks

None. This is pure route-matching bookkeeping over filesystem-derived route names and a request path — no auth, crypto, permissions, or untrusted deserialization is touched. The change strictly narrows what ends up in params (removing leaked entries), and Vec::truncate to a saved length is a no-op when nothing was pushed.

Level of scrutiny

Low-to-medium. The Rust diff is ~15 net lines and mechanical: save length → call original body → restore on false. The old truncate(0) and the new truncate(params_len) are equivalent for the sole production caller (Routes::match_dynamic, which always enters with an empty list), and truncate(params_len) is the semantically correct contract for any future caller. Every existing return path in match_inner is unchanged; only the two now-redundant truncate calls were dropped.

Other factors

The fix lives at the layer that owns the invariant (the function that mutates params), covers the whole bug class by construction rather than by enumerating exit sites, and the PR description demonstrates the new tests fail on the unfixed build and pass on the fixed one. No CODEOWNERS entry covers src/router/, no human reviewer has left outstanding comments, and the bug-hunting system found nothing. The subprocess test's expect(stderr).toBe("") matches the pattern already used by several neighboring tests in the same file.

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status for a reviewer: the diff is green, and each red build had a different unrelated cause.

This PR's test passes on CI. From the latest build on 821f9b1e, debian 13 aarch64:

--- [3/240] test/js/bun/util/filesystem_router.test.ts
bun test v1.4.0-canary.1 (821f9b1eb)
..................................
 34 pass
 0 fail
 1526 expect() calls
Ran 34 tests across 1 file. [473.00ms]

That build finished 267 passed, 3 failed. All three failures are on darwin and none of them executed a router test:

  • darwin 26 aarch64 x2: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. The agent never got the binary, so no test ran at all.
  • darwin 14 x64: Test "creates subprocess with terminal attached" timed out after 90000ms. A PTY/subprocess test.

The two earlier red builds:

  1. cookie-map.test.ts, every lane. My branch was cut before cookie: update remaining cookie-map Expires assertions to IMF-fixdate #33425, which updated those Expires assertions to IMF-fixdate. The runtime already emitted Thu, 01 Jan 1970 00:00:00 GMT while my stale copy of the test still expected Fri, 1 Jan 1970 00:00:00 -0000. Fixed by rebasing onto current main; the file is untouched by this PR.
  2. bun-install-security-provider.test.ts on windows 11 aarch64. "Large payload via ipc pipe" timed out at 120s. The same test flakes on unrelated branches (it also appears on repl-sigint-and-event-loop, and on realpath-native-empty-path where the build still passed after a retry). It fail-fast cancelled 210 queued jobs, which is why that board looked worse than it was.

filesystem_router does not appear in any annotation or job log across any of the three builds, and the remaining annotations are soft failures that were retried (hot.test.ts, install hoisting, an hmr case).

I have used my one re-trigger, so I am not going to keep pushing empty commits at this. The change itself is 21 lines in src/router/lib.rs plus tests, and every new test is verified to fail on the unfixed build and pass on the fixed one. Happy to rebase again or adjust anything on request.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

#36680 applies the same truncate-on-false wrapper and additionally fixes the is_end() / Pattern::init off-by-one reported in the second half of #12206 ([test]/a with a single-character trailing static segment). The tests from this PR are carried over.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated into #36680, which now also carries the route-tree cases from this PR (including the index-file layout of the #12206 tree). Closing.

@robobun robobun closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.FileSystemRouter router match unknown behaviour Array instead of string in MatchedRoute when specific file structure

1 participant