Skip to content

FileSystemRouter: build JS error before freeing arena in error paths - #29971

Merged
dylan-conway merged 4 commits into
mainfrom
farm/cf28ae20/fsr-log-uaf
May 1, 2026
Merged

FileSystemRouter: build JS error before freeing arena in error paths#29971
dylan-conway merged 4 commits into
mainfrom
farm/cf28ae20/fsr-log-uaf

Conversation

@robobun

@robobun robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

What

FileSystemRouter's constructor (and reload()) initialize the error log with the arena allocator:

const allocator = arena.allocator();
...
var log = Log.Log.init(allocator);

When route loading produces errors, the error paths did:

arena.deinit();
globalThis.allocator().destroy(arena);
return globalThis.throwValue(try log.toJS(...));  // reads arena-backed msgs.items

log.msgs.items is backed by the arena, so log.toJS() reads freed memory. ASAN reports use-after-poison in logger.Log.toJS.

Repro

// pages/[foo.tsx — missing closing bracket
new Bun.FileSystemRouter({ style: "nextjs", dir: "./pages", fileExtensions: [".tsx"] });

Debug (ASAN) build:

AddressSanitizer: use-after-poison ...
  #1 in logger.Log.toJS (src/logger.zig:733)
  #2 in FileSystemRouter.constructor (src/bun.js/api/filesystem_router.zig:149)

Fix

Build the JS error value first (while the arena is still live — BuildMessage.create / ResolveMessage.create clone the msg into globalThis.allocator()), then free the arena, then throw. Applied to all four log.toJS() call sites across constructor() and reload().

Verification

  • git stash -- src/ && bun bd test filesystem_router.test.ts -t 'invalid route'fail (ASAN crash in subprocess)
  • git stash pop && bun bd test filesystem_router.test.ts -t 'invalid route'pass, error message is Route is missing a closing bracket]
  • All 19 existing filesystem_router.test.ts tests pass.

The constructor's log is initialized with the arena allocator. When route
loading produces errors (e.g. a filename like '[foo.tsx' missing its closing
bracket), the arena was freed before log.toJS() read the messages, causing a
use-after-free that ASAN catches as use-after-poison.

Build the JS error value first, then free the arena. Applies to all four
log.toJS() call sites in constructor() and reload().
@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:54 PM PT - Apr 30th, 2026

@robobun, your commit 833d992 has 1 failures in Build #49546 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29971

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

bun-29971 --bun

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 40 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4d2b0597-16b0-4dc8-a861-a0cd590e7da1

📥 Commits

Reviewing files that changed from the base of the PR and between 4de46b9 and 833d992.

📒 Files selected for processing (1)
  • src/bun.js/api/filesystem_router.zig

Walkthrough

The PR adjusts error-throwing logic in FileSystemRouter to compute JS error messages before freeing memory, and adds a regression test for malformed route filenames.

Changes

Cohort / File(s) Summary
FileSystemRouter Error Handling
src/bun.js/api/filesystem_router.zig
Adjusted error-throwing logic to compute JS error messages before freeing arena/log-backed memory. Now calls log.toJS(...) before origin_str.deinit(), arena.deinit(), and globalThis.allocator().destroy(arena), then throws via globalThis.throwValue(err_value).
FileSystemRouter Tests
test/js/bun/util/filesystem_router.test.ts
Added regression test validating error handling for malformed route filenames during constructor execution, expecting specific bracket-missing error with empty stderr and clean exit.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately describes the main change: reordering error handling to build JS errors before freeing arena memory in FileSystemRouter error paths.
Description check ✅ Passed The description thoroughly covers both required sections: 'What does this PR do?' (detailed problem explanation with code examples and ASAN output) and 'How did you verify your code works?' (verification steps with test results).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 0/5 reviews remaining, refill in 4 minutes and 40 seconds.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/api/filesystem_router.zig`:
- Around line 112-117: The current code calls log.toJS(...) before cleaning up
arena-backed resources so if log.toJS returns error.JSError the subsequent
origin_str.deinit(), arena.deinit(), and globalThis.allocator().destroy(arena)
are skipped and leak; modify the error path in the block containing log.toJS,
origin_str.deinit, arena.deinit, globalThis.allocator().destroy, and
globalThis.throwValue so that cleanup always runs (use defer or capture the
error first, perform origin_str.deinit()/arena.deinit()/destroy(arena), then
convert/throw the JS error), ensuring log.toJS and throwValue still operate but
only after deterministic cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4ed183d6-5f20-45f2-b74d-3e51caf3c468

📥 Commits

Reviewing files that changed from the base of the PR and between 360bbb5 and 4de46b9.

📒 Files selected for processing (2)
  • src/bun.js/api/filesystem_router.zig
  • test/js/bun/util/filesystem_router.test.ts

Comment thread src/bun.js/api/filesystem_router.zig Outdated

@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 — straightforward reordering to build the JS error before freeing the arena that backs the log; left one optional drive-by note about an adjacent pre-existing leak.

Extended reasoning...

Overview

This PR fixes a use-after-free in FileSystemRouter's constructor and reload() error paths. The error log is initialized with the arena allocator, but the previous code did arena.deinit() before calling log.toJS(), which iterates log.msgs.items (arena-backed). The fix hoists log.toJS() above the arena teardown at four call sites and stores the result in a local before throwing. A subprocess-isolated regression test is added that triggers the route-loading error path with an invalid filename ([foo.tsx).

I verified that Log.toJS (src/logger.zig:725) dispatches to BuildMessage.create / ResolveMessage.create, both of which msg.clone(allocator) into globalThis.allocator() (src/bun.js/BuildMessage.zig:54, src/bun.js/ResolveMessage.zig:174), so the returned err_value does not retain any pointers into the arena and is safe to throw after arena.deinit() / destroy(arena).

Security risks

None. This is a memory-safety fix in an error path; no new inputs, parsing, auth, or external surface is introduced.

Level of scrutiny

Low–medium. The change is a mechanical reordering of three statements at four sites, each following the identical pattern. The PR description includes ASAN before/after verification, and a new test asserts both a clean exit and the exact error message. No CODEOWNERS cover this path.

Other factors

The one inline comment is explicitly pre-existing (the readDirInfo catch in reload() leaks the new arena — it never had the UAF because it never freed the arena at all). It's a reasonable drive-by suggestion but orthogonal to this fix and shouldn't block. origin_str is allocated via globalThis.allocator(), not the arena, so its deinit ordering relative to log.toJS is immaterial.

Comment thread src/bun.js/api/filesystem_router.zig Outdated
robobun added 2 commits April 30, 2026 07:42
Capture the error union instead of using try, so arena.deinit() and
destroy(arena) run unconditionally before the result is unwrapped.
Pre-existing leak: the readDirInfo catch block in reload() returned
without freeing the newly-created arena. Apply the same pattern as the
other four log.toJS sites.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/bun.js/api/filesystem_router.zig:255-257 — 🟣 Pre-existing, same arena-lifetime UAF class, three lines above this hunk: allocator.dupe(string, this.router.config.extensions) only copies the outer []([]const u8) — the inner string bytes still point into the old arena, which this.arena.deinit() frees a few lines later on the success path. On the next reload(), loadRoutes dereferences those freed extension bytes (for (this.config.extensions) |_extname| if (strings.eql(extname[1..], _extname)), src/router.zig:442-443). Only fires when fileExtensions was passed (default_extensions is comptime-static), which is why the existing reload() tests miss it. A deep-dupe of each extension string into the new arena before freeing the old one would close it.

    Extended reasoning...

    What the bug is

    reload() shallow-copies this.router.config.extensions into the new arena, then frees the old arena that owns the underlying string bytes. The new router's config.extensions[i].ptr are left dangling, and the next reload() reads them inside loadRoutes.

    This is pre-existing — the PR doesn't touch line 254 or change when the old arena is freed on the success path — but it's the same arena-lifetime UAF class this PR is fixing, sitting three lines above the reload() hunk you just modified, so it's worth flagging while you're here.

    The specific code path

    Allocation. In the constructor, when fileExtensions is passed:

    const allocator = arena.allocator();
    ...
    extensions.appendAssumeCapacity((try val.toUTF8Bytes(globalThis, allocator))[1..]);

    JSValue.toUTF8Bytes is documented "The returned slice is always owned by allocator" (src/bun.js/bindings/JSValue.zig:1259-1264), so each extension's bytes live in the constructor's arena. router.config.extensions = extensions.items is then stored on the router, and that arena becomes this.arena.

    Shallow copy. In reload() (line 254, immediately above this hunk):

    .extensions = allocator.dupe(string, this.router.config.extensions) catch unreachable,

    where string = []const u8. std.mem.Allocator.dupe on a []const []const u8 allocates a new outer array and @memcpys the elements verbatim — i.e., it copies the {ptr, len} slice headers. The inner .ptr values still point into the old arena.

    Free. On the success path a few lines later:

    this.arena.deinit();              // frees the old arena -> extension bytes are gone
    globalThis.allocator().destroy(this.arena);
    this.arena = arena;
    this.router = router;             // stores config.extensions whose [i].ptr now dangle

    Dereference. On the next reload(), router.loadRoutesRouteLoader.load iterates each directory entry and does:

    for (this.config.extensions) |_extname| {
        if (strings.eql(extname[1..], _extname)) {   // src/router.zig:442-443

    _extname is one of the dangling slices; strings.eql reads its bytes — use-after-free.

    Why existing code doesn't prevent it

    Router.init stores config verbatim with no deep copy, and nothing between this.arena.deinit() and the next loadRoutes re-materializes the extension bytes. .dir on the line above is deep-duped (allocator.dupe(u8, ...)), but .extensions is a slice-of-slices and only gets the outer level duped.

    The two existing reload() tests don't pass fileExtensions, so they take the default_extensions branch — those are comptime-static string literals that survive arena.deinit(), masking the bug.

    Impact

    For any router constructed with an explicit fileExtensions option, the second (and every subsequent) reload() reads freed memory while matching file extensions. In ASAN/debug builds this is a use-after-poison crash; in release it can silently mis-match extensions or crash depending on what the freed page now contains.

    Step-by-step proof

    1. new Bun.FileSystemRouter({ dir, style: "nextjs", fileExtensions: [".tsx"] }) → constructor allocates the bytes "tsx" in arena A via toUTF8Bytes(globalThis, arena_A.allocator()); this.router.config.extensions = [ {ptr→A, len=3} ]; this.arena = A.
    2. First router.reload():
      • Creates arena B.
      • .extensions = B.dupe(string, [ {ptr→A, len=3} ]) → new outer array in B, element still {ptr→A, len=3}.
      • loadRoutes runs against {ptr→A} while A is still live → fine.
      • Success path: this.arena.deinit() frees A; this.arena = B; this.router.config.extensions = [ {ptr→A(freed), len=3} ].
    3. Second router.reload():
      • Creates arena C.
      • .extensions = C.dupe(string, [ {ptr→A(freed), len=3} ]) → still {ptr→A(freed)}.
      • loadRoutes → src/router.zig:443 strings.eql(extname[1..], _extname) reads 3 bytes at ptr→AUAF.

    How to fix

    Deep-dupe each inner string into the new arena before freeing the old one, e.g.:

    .extensions = blk: {
        const old = this.router.config.extensions;
        const new = allocator.alloc(string, old.len) catch unreachable;
        for (old, new) |s, *out| out.* = allocator.dupe(u8, s) catch unreachable;
        break :blk new;
    },

    (or skip the copy entirely when old.ptr == default_extensions.ptr).

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures are all pre-existing flakes unrelated to this change. filesystem_router.test.ts passes 19/19 on every lane on both builds.

Build 49359 (6cd9f81, pre-sync) — cross-referenced against builds 49358/49357/49354/49351:

  • fetch-http2-client.test.ts on x64-asan: ASSERTION FAILED: wasRemoved in WebKit AtomStringImpl.cpp
  • bun-create.test.ts: GitHub 403 rate limiting
  • bake/dev/stress.test.ts + bake/dev-and-prod.test.ts on Windows: HMR reload flake
  • aarch64 build-cpp: Azure agent provisioning failure

Build 49546 (833d992, post-sync) — cross-referenced against builds 49540-49545:

  • darwin-14-aarch64 + darwin-26-aarch64 test jobs: Expired (no agent picked them up — infra)
  • fetch-http2-client.test.ts on x64-asan: same AtomStringImpl::remove assertion (also on 49543/49542/49540)
  • bake/dev-and-prod.test.ts, cli/hot/hot.test.ts, bake/dev/html.test.ts: HMR flakes (on all 6 adjacent builds)
  • bun-install-lifecycle-scripts.test.ts, update_interactive_install.test.ts: Windows install flakes (on 49545/49544/49543/49541/49540)
  • serve-response-stream-sink-leak.test.ts: perf threshold (2.6 MB vs 2 MB slack)

Re: the additional finding about allocator.dupe(string, this.router.config.extensions) being a shallow copy in reload() — that pre-existing UAF is already fixed in #29919 ("config.extensions was shallow-duped ... so each extension string still pointed into the previous arena"). Leaving it out of this PR to avoid conflicting.

@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 — straightforward UAF fix (build error before freeing the arena that backs it), applied consistently with a regression test; the inline note below is pre-existing and fine as a follow-up.

Extended reasoning...

Overview

This PR fixes an ASAN-confirmed use-after-free in Bun.FileSystemRouter's error paths. The Log is initialized with the arena allocator, but the old code did arena.deinit() before calling log.toJS(), so log.msgs.items was read after being freed. The fix is mechanical and applied identically at five sites across constructor() and reload(): capture log.toJS(...) into an error-union local first, then run cleanup (origin_str.deinit() / arena.deinit() / destroy(arena)), then return globalThis.throwValue(try err_value). Log.toJS hands the messages to BuildMessage.create / ResolveMessage.create with globalThis.allocator(), so the resulting JSValue does not retain arena-backed memory. A subprocess regression test asserts the expected "Route is missing a closing bracket]" error with empty stderr and exit 0.

Two earlier review rounds were already addressed: CodeRabbit's note that try log.toJS before cleanup would skip cleanup on JSError (fixed in ed3f8bd by capturing the union and deferring try until after cleanup), and my own note that reload()'s readDirInfo catch leaked the freshly-allocated arena (fixed in 6cd9f81). All inline threads are resolved.

Security risks

None. This touches only error-path resource ordering in a dev-time filesystem router; no auth, crypto, network, or untrusted-input parsing semantics change.

Level of scrutiny

Low–medium. The diff is ~30 lines of reorder-and-rename across five near-identical blocks plus comments, with no new control flow beyond what reviewers already requested. The added test follows the file's existing bunExe() + bunEnv subprocess pattern (where bunEnv suppresses ASAN startup noise, so expect(stderr).toBe("") is the established convention here).

Other factors

The one new inline comment is explicitly flagged pre-existing: reload() lacks the post-loadRoutes log.errors + log.warnings > 0 check that the constructor has, so logged-only validation errors are silently swallowed on reload. That asymmetry predates this PR and is a reasonable follow-up, not a blocker. CI showed build-zig failures on the intermediate commit ed3f8bd, but the head commit 6cd9f81 supersedes it and the final source compiles to the same pattern used elsewhere in the file; merge is CI-gated regardless. No CODEOWNERS cover these paths.

Comment thread src/bun.js/api/filesystem_router.zig

@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 — straightforward reordering fix for a real ASAN-detected UAF, with a regression test; the deferred reload() log-check is reasonably out of scope.

Extended reasoning...

Overview

This PR fixes a use-after-free in FileSystemRouter.constructor() and reload() (src/bun.js/api/filesystem_router.zig). The error log is backed by an arena allocator; the old code freed the arena and then called log.toJS(), which reads arena-backed msgs.items. The fix captures log.toJS(...) into an error-union local before arena.deinit() / destroy(arena), then unwraps with try after cleanup — applied uniformly across all five log.toJS sites in the two functions. A subprocess regression test in filesystem_router.test.ts exercises the [foo.tsx missing-bracket path and asserts the correct error message with empty stderr and exit 0.

Security risks

None. This is a pure lifetime/ordering fix in error paths; no new inputs, parsing, auth, or external surface area.

Level of scrutiny

Low-to-moderate. The change is mechanical (reorder three statements at each site), the pattern is identical at every site, and it's confined to error-handling paths that previously crashed under ASAN. The error-union capture ensures cleanup runs even if toJS itself returns error.JSError, addressing the earlier CodeRabbit concern.

Other factors

  • All inline review threads are resolved. CodeRabbit's cleanup-on-toJS-failure concern was addressed in ed3f8bd; my pre-existing reload() readDirInfo arena leak note was addressed in 6cd9f81; my note about the missing log.errors > 0 check in reload() was reasonably deferred as a behavioral change for a follow-up PR.
  • The author triaged CI failures as pre-existing flakes unrelated to this change, and filesystem_router.test.ts is 19/19 green on the ASAN lane.
  • No CODEOWNERS cover the touched paths.
  • The bug-hunting system reported no findings on the current revision.

@dylan-conway
dylan-conway merged commit 5efeaa8 into main May 1, 2026
75 of 77 checks passed
@dylan-conway
dylan-conway deleted the farm/cf28ae20/fsr-log-uaf branch May 1, 2026 17:33
Jarred-Sumner pushed a commit that referenced this pull request May 4, 2026
reload() allocated a new arena and called allocator.dupe(string, extensions)
to carry the config forward, but that only duplicates the outer []string
slice — each inner []const u8 still pointed into the previous arena, which
is deinit()ed at the end of reload(). asset_prefix_path was not copied at
all. The first reload() worked because loadRoutes runs before the old arena
is freed; the second reload() read freed extension bytes in router.zig:443
(strings.eql) for every scanned file, tripping ASAN and silently dropping
routes in release.

Deep-copy each inner extension string and asset_prefix_path into the new
arena so the config is fully self-contained after the old arena is freed.

Follow-up to #29971.
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…ven-sh#29971)

## What

`FileSystemRouter`'s constructor (and `reload()`) initialize the error
log with the arena allocator:

```zig
const allocator = arena.allocator();
...
var log = Log.Log.init(allocator);
```

When route loading produces errors, the error paths did:

```zig
arena.deinit();
globalThis.allocator().destroy(arena);
return globalThis.throwValue(try log.toJS(...));  // reads arena-backed msgs.items
```

`log.msgs.items` is backed by the arena, so `log.toJS()` reads freed
memory. ASAN reports `use-after-poison` in `logger.Log.toJS`.

## Repro

```js
// pages/[foo.tsx — missing closing bracket
new Bun.FileSystemRouter({ style: "nextjs", dir: "./pages", fileExtensions: [".tsx"] });
```

Debug (ASAN) build:
```
AddressSanitizer: use-after-poison ...
  oven-sh#1 in logger.Log.toJS (src/logger.zig:733)
  oven-sh#2 in FileSystemRouter.constructor (src/bun.js/api/filesystem_router.zig:149)
```

## Fix

Build the JS error value first (while the arena is still live —
`BuildMessage.create` / `ResolveMessage.create` clone the msg into
`globalThis.allocator()`), then free the arena, then throw. Applied to
all four `log.toJS()` call sites across `constructor()` and `reload()`.

## Verification

- `git stash -- src/ && bun bd test filesystem_router.test.ts -t
'invalid route'` → **fail** (ASAN crash in subprocess)
- `git stash pop && bun bd test filesystem_router.test.ts -t 'invalid
route'` → **pass**, error message is `Route is missing a closing
bracket]`
- All 19 existing `filesystem_router.test.ts` tests pass.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
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.

2 participants