Skip to content

FileSystemRouter: deep-copy extensions and asset_prefix_path on reload()#30070

Open
robobun wants to merge 1 commit into
mainfrom
farm/a43364ca/fsr-reload-deep-copy
Open

FileSystemRouter: deep-copy extensions and asset_prefix_path on reload()#30070
robobun wants to merge 1 commit into
mainfrom
farm/a43364ca/fsr-reload-deep-copy

Conversation

@robobun

@robobun robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator

What

FileSystemRouter.reload() creates a new arena and carries the existing config forward into it before freeing the old arena:

var router = Router.init(vm.transpiler.fs, allocator, .{
    .dir = allocator.dupe(u8, this.router.config.dir) catch unreachable,
    .extensions = allocator.dupe(string, this.router.config.extensions) catch unreachable,
    .asset_prefix_path = this.router.config.asset_prefix_path,
}) catch unreachable;
...
this.arena.deinit();

allocator.dupe(string, ...) (string = []const u8) only duplicates the outer []string slice — each inner []const u8 still points into the previous arena. asset_prefix_path isn't copied at all.

The first reload() appears to work because loadRoutes runs while the old arena is still live. But the config it leaves behind holds dangling inner slices, so the second reload() reads freed bytes at router.zig:443 (strings.eql(extname[1..], _extname)) for every scanned file — tripping ASAN in debug and silently dropping routes in release.

Repro

const r = new Bun.FileSystemRouter({
  dir, style: 'nextjs', fileExtensions: ['.tsx'], assetPrefix: '/_next/static/',
});
r.reload();
r.reload();  // ASAN use-after-poison in strings.eql → router.zig:443
==4118==ERROR: AddressSanitizer: use-after-poison
    #1 string.immutable.eql        src/string/immutable.zig:864
    #2 router.RouteLoader.load     src/router.zig:443
    #4 router.loadRoutes           src/router.zig:484
    #5 FileSystemRouter.reload     src/bun.js/api/filesystem_router.zig:262

Fix

Deep-copy each inner extension string into the new arena with a loop over allocator.dupe(u8, ext), and dupe(u8, ...) the asset_prefix_path as well, so the config is fully self-contained once the previous arena is freed.

Verification

  • git stash -- src/ && bun bd test filesystem_router.test.ts -t 'preserves custom fileExtensions'fail (ASAN use-after-poison at router.zig:443)
  • git stash pop && bun bd test filesystem_router.test.ts -t 'preserves custom fileExtensions'pass
  • All 21 tests in filesystem_router.test.ts pass.

Follow-up to #29971.

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@Jarred-Sumner has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 19 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: 6e306b89-7ec3-4b79-9b98-133cc5c66b8b

📥 Commits

Reviewing files that changed from the base of the PR and between 0a7bed5 and 56f721d.

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

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

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

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:25 AM PT - May 4th, 2026

@robobun, your commit 56f721d is building: #51003

@github-actions github-actions Bot added the claude label May 1, 2026
@github-actions

github-actions Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fs: reuse DirEntry in bustEntriesCache instead of orphaning it #29919 - Also deep-copies individual extension strings in FileSystemRouter.reload() to fix the same use-after-free bug (bundled as a secondary fix alongside a DirEntry cache change)

🤖 Generated with Claude Code

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #29919. That PR is about the bustEntriesCache DirEntry leak and only touches reload()'s extensions as a drive-by (it surfaced under ASAN while testing 1000 reloads). It's currently conflicting with main and does not fix asset_prefix_path.

This PR is the focused fix for the reload() shallow-copy UAF: deep-copies both the inner extension strings and asset_prefix_path, with a dedicated regression test. Once this lands, the filesystem_router.zig hunk in #29919 becomes a no-op and can be dropped on rebase.

@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 arena-lifetime fix that deep-copies the config strings into the new arena before freeing the old one, with a regression test that exercises multiple reloads.

Extended reasoning...

Overview

This PR touches src/bun.js/api/filesystem_router.zig (the reload() method only) and adds one regression test to test/js/bun/util/filesystem_router.test.ts. The change replaces a shallow allocator.dupe(string, extensions) with an explicit loop that dupe(u8, ...)s each inner extension string, and likewise dupes asset_prefix_path, so the new router config is fully self-contained in the new arena before the old arena is freed.

Security risks

None. This is a pure memory-lifetime correction in a local-filesystem routing utility. There is no change to input parsing, auth, permissions, or any externally-reachable surface — it strictly tightens ownership of already-held byte slices.

Level of scrutiny

Low-to-moderate. The diff is ~15 lines of Zig in a single function and follows the exact pattern already used for config.dir one line below. The only new behavior is allocating a few extra small strings into an arena that is freed wholesale on the next reload()/finalize, so there is no leak risk. The catch unreachable and labeled-block style match the surrounding code. The default-extensions case (static string literals) and the empty asset_prefix_path case both degrade to harmless zero/short copies.

Other factors

  • The PR description includes an ASAN trace pinpointing the UAF and shows the new test failing before / passing after the fix.
  • It is a direct follow-up to #29971 and #29973, which touched the same arena-lifetime area in this file.
  • The added test loops reload() five times and asserts both routes and match().src (which exercises asset_prefix_path), giving good coverage of the fix.
  • No bugs were flagged by the bug-hunting system, and there are no outstanding human reviewer comments.

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Branch was rebased onto the src/ restructure (file is now src/runtime/api/filesystem_router.zig). Diff vs main is unchanged — same 16-line deep-copy fix + 35-line test.

Build #51003: 25/37 BuildKite jobs Expired waiting for agents across every platform (linux, darwin, windows, freebsd, android). Zero test failures — no tests ran. The 12 jobs that did get agents (build-zig/build-cpp) all passed. Fleet-wide agent shortage, not this change.


Previous build (#49815) — 72/75 passed; 3 darwin failures unrelated to this change
lane status failing test note
darwin-14-aarch64-test-bun exit 2 test/cli/run/no-orphans.test.ts bun run --no-orphans supervision; also failing on unrelated #30052 — pre-existing darwin flake from #29930
darwin-14-x64-test-bun exit 3 test/cli/run/no-orphans.test.ts same as above
darwin-26-aarch64-test-bun exit -1 test/cli/run/no-orphans.test.ts + test/js/bun/s3/s3-storage-class.test.ts same + S3 network flake

filesystem_router.test.ts passed on every platform including debian-13-x64-asan-test-bun.

@dylan-conway dylan-conway left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@robobun do we have a method or abstraction for deep cloning lists of strings? If so let's use it

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

@dylan-conway Searched — there isn't one. bun.clone (src/bun.zig:456) shallow-dupes [][]const u8 (falls through to allocator.dupe(Child, item) since []const u8 has no .clone()), and bun.strings.* has nothing that returns a deep-duped [][]const u8. The pattern is inlined everywhere else it's needed:

  • src/cli/test_command.zig:1591-1596 — alloc outer + loop dupe(u8, ...)
  • src/bun.js/api/server/HTMLBundle.zig:280-289 — loop dupe(u8, from)
  • src/bun.zig:1605-1611 StringSet.clone — loop dupe(u8, key)
  • src/bunfig.zig ~8 sites

Kept the inline loop here to match. Happy to add a bun.strings.dupeList(allocator, []const []const u8) helper if you'd rather consolidate, but that felt out of scope for this fix.

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.
@Jarred-Sumner Jarred-Sumner force-pushed the farm/a43364ca/fsr-reload-deep-copy branch from 6a4d448 to 56f721d Compare May 4, 2026 10:25

@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 — focused UAF fix that deep-copies arena-backed config slices, with a regression test; ASAN lane is green.

Extended reasoning...

Overview

This PR fixes a use-after-free in FileSystemRouter.reload() (src/runtime/api/filesystem_router.zig). Previously, reload() created a new arena and copied the config forward with allocator.dupe(string, extensions), which only duplicates the outer []string — each inner []const u8 still pointed into the previous arena, which is freed at the end of reload(). asset_prefix_path wasn't copied at all. The second reload() would then read freed bytes when comparing extensions in loadRoutes. The fix replaces the shallow dupe with an explicit alloc + per-element dupe(u8, ext) loop and adds dupe(u8, asset_prefix_path). A regression test in filesystem_router.test.ts calls reload() five times with custom fileExtensions and assetPrefix, asserting routes and match().src remain correct.

Security risks

None. This is internal arena bookkeeping for a developer-facing routing utility. No auth, crypto, network, or untrusted-input parsing is touched. The change strictly removes a memory-safety hazard (UAF) rather than introducing one.

Level of scrutiny

Low-to-medium. The diff is ~12 lines of Zig that follow an established idiom already used across the codebase (test_command.zig, HTMLBundle.zig, StringSet.clone, etc., as the author enumerated in-thread). The new allocations land in the new arena, so they're freed on the next reload()/finalize() with no leak. Edge cases check out: when extensions is the static default_extensions, deep-copying string literals into the arena is harmless; dupe(u8, "") for an empty asset_prefix_path returns an empty slice. The labeled-block + multi-arg for is idiomatic Zig.

Other factors

  • Bug-hunting system found no issues.
  • CI: 72 lanes passed including debian-13-x64-asan-test-bun (the lane that would catch this class of bug); the three darwin failures are documented pre-existing flakes unrelated to this change.
  • The only reviewer question (whether a deep-dupe helper exists) was answered with concrete codebase references; keeping the inline loop matches existing convention and avoids scope creep.
  • No CODEOWNERS entry covers this file.
  • Overlap with #29919 is acknowledged and non-conflicting — this PR is the more complete fix (also covers asset_prefix_path) and ships a dedicated regression test.

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