Skip to content

dev server: check the route bundle index of a client script URL before Index::init - #43329

Open
robobun wants to merge 2 commits into
mainfrom
robobun/91ec7d91/dev-server-client-script-index-range
Open

robobun wants to merge 2 commits into
mainfrom
robobun/91ec7d91/dev-server-client-script-index-range

Conversation

@robobun

@robobun robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • GET /_bun/client/x-ffffffff00000000.js aborts a dev server that has assertions on: panic: GenericIndex::init: maxInt is reserved for Optional::none. A release build answers 404. The request needs no credentials.
  • The cause is on_js_request (src/runtime/bake/DevServer.rs:1740). It built route_bundle::Index::init(..) from the URL first and checked route_bundles.len() second. ffffffff decodes to u32::MAX, and GenericIndex::init (src/bun_core/util.rs:3262) asserts against that value.
  • src/ has 64 GenericIndex::init call sites. Only this one takes bytes from the network.

Fix

  • Compare the raw u32 with dev.route_bundles.len() first. Build the Index only for a number in range. The two u32::try_from(..).expect("int cast") calls become as u32: both inputs always fit.
  • Correct because only get_or_put_route_bundle (DevServer.rs:5222) grows route_bundles, and it builds Index::init(len) before the push. So a number below len is never u32::MAX.
  • Verified: new test in test/bake/dev/html.test.ts. With src/ at main, bun bd test fails with DevServer crashed. Also ran esm, bundle, sourcemap, hot and dev-and-prod under test/bake/.
  • Self-reviewed: three corrections to this description, all made. Open PRs own the other inputs that abort an assertion build: see "Not covered" in the Notes.

Background

  • The client bundle of a route is at /_bun/client/{name}-{index}{generation}.js. index and generation are 8 hex characters each: the four bytes of a u32 in memory order.
  • GenericIndex<u32, M> is a typed index. It reserves u32::MAX as the none value of its optional form, and init has a debug_assert! for it.
  • debug_assert! is active in debug, release-asan and release-assertions builds. The builds that ship compile it out.
Notes

Not covered. Each input below still aborts the debug build of this branch. Each has an open PR with a test, so this PR leaves them alone.

Inputs that this branch answers without an abort (debug build, server alive after each):

  • /_bun/client/: x-ffffffff00000000.js, x-feffffff00000000.js, x-ffffffffffffffff.js, x-FFFFFFFF00000000.js, index 0 before the first page load, x-ffffffffffffffff.js.map, x-0000000000000001.js.map, .js.map, .js. All 404. A valid index with a wrong generation answers with the reload stub, as before.
  • /_bun/asset/ffffffffffffffff.css, /_bun/asset/zz, a 4000 character asset name: 404.
  • /_bun/unref and /_bun/report_error with malformed bodies: 400 or 200.
  • HMR frames: malformed i, u, l, s frames, unknown ids and an empty frame. The server closes the socket or ignores the frame.

Other facts

  • The audit: 12 type aliases of GenericIndex in src/, 64 ::init( call sites. The other 63 take a container length, a loop counter, a constant, or a number that the server packed itself (serialized_failure::Packed masks to 30 bits). new_route_params (DevServer.rs:6658) takes a number from the framework's own server-side JS, not from the network.
  • Assertions: scripts/build/config.ts sets assertions to debug || asan by default, and scripts/build/rust.ts turns on debug-assertions for those builds.
  • Release builds are not affected. The assert is compiled out, u32::MAX as usize >= len is true, and the answer is 404. So USE_SYSTEM_BUN=1 bun test test/bake/dev/html.test.ts passes before and after. The fail-before proof needs bun bd.
  • parse_hex_to_int::<u64> reads all 16 hex characters as the bytes of a u64 in memory order. On a little-endian host the first group (the index) is the low half and the second group (the generation) is the high half.
  • The Zig original had the same order: .init(@intCast(id & 0xFFFFFFFF)) on a GenericIndex(u30, RouteBundle), then the length check. This was never correct, so the test is in the module's test file and not under test/regression/.
  • dev server: make a client script request wait for the bundle in flight #42733 and dev server: tell a page that loaded an outdated client bundle to reload #42041 edit on_js_request below this line and keep the old order. Whichever lands second needs a small rebase.
  • Self-review detail. Corrections made to this description: a wrong function name (get_or_put_route_bundle is the function that grows route_bundles), the audit count, and the "Not covered" list. The review also weighed a wider PR that folds in the fixes of Stop HMR websocket frames from aborting or crashing the dev server #33196 and bake: normalize the HTTP request-target before matching framework routes #33232, and rejected it because those PRs already carry them with tests. It suggested a tracking issue for the "Not covered" inputs. That issue is not filed, because each input already has an open PR that records it.

…e Index::init

GET /_bun/client/x-ffffffff00000000.js decodes to u32::MAX, the value
GenericIndex reserves for Optional::none. on_js_request built the index
first and checked the range second, so an assertion build aborted with
'GenericIndex::init: maxInt is reserved for Optional::none'. Check the
raw number against route_bundles.len() first.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 2 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 2 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 31 seconds for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 32fec187-491b-4780-ac37-713488e71a3f

📥 Commits

Reviewing files that changed from the base of the PR and between 367d939 and ff2e214.

📒 Files selected for processing (2)
  • src/runtime/bake/DevServer.rs
  • test/bake/dev/html.test.ts

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

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:10 PM PT - Sep 18th, 2026

✅ @robobun, your commit ff2e2149b3ee4e1864a026171e0e3f37fc257223 passed in Build #117864! 🎉


🧪   To try this PR locally:

bunx bun-pr 43329

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

bun-43329 --bun

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction, on a build with assertions (bun bd):

  1. Start a dev server: Bun.serve({ port: 0, development: { hmr: true }, routes: { "/": html } }), where html is an imported index.html.
  2. Run curl http://127.0.0.1:$PORT/_bun/client/x-ffffffff00000000.js.

Before the fix, the process aborts with panic: GenericIndex::init: maxInt is reserved for Optional::none at src/runtime/bake/DevServer.rs:1741. With the fix, the answer is 404 and the server continues to serve requests. A release build answers 404 before and after, because the assert is compiled out.

Test: bun bd test test/bake/dev/html.test.ts -t "client script route". With src/ at main it fails with DevServer crashed. With this branch it passes.

@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 — a four-line reorder that moves the route_bundles.len() range check ahead of route_bundle::Index::init, so a client-supplied ffffffff index hits the 404 path instead of the debug assert.

What was reviewed:

  • id as u32 / (id >> 32) as u32 are exact replacements for the old & 0xFFFFFFFF + try_from().expect() pair (both always fit), and index < len now guarantees index != u32::MAX because get_or_put_route_bundle builds Index::init(len) before every push.
  • The sibling .js.map branch in on_js_request was checked for the same pattern: source_map_store::Key::init is a plain u64 wrapper and SourceId::kind() is a bit test feeding a hashmap lookup, so no sentinel assert is reachable there.
  • The new test only fails on assertion builds (release already answered 404), which is inherent to a debug-assert fix; it exercises the real script URL first, then both the u32::MAX sentinel and a near-miss out-of-range index, and confirms the server survives.
Extended reasoning...

Overview

The PR touches src/runtime/bake/DevServer.rs::on_js_request (the /_bun/client/{name}-{index}{generation}.js handler) and adds one devTest to test/bake/dev/html.test.ts. The Rust change reorders two statements: the bounds check against dev.route_bundles.len() now runs on the raw u32 before route_bundle::Index::init is called, and the two u32::try_from(..).expect("int cast") calls become plain as casts. GenericIndex::init (src/bun_core/util.rs:3262) carries a debug_assert!(int != NULL_VALUE), so on debug/ASAN/assertion builds a URL encoding index ffffffff previously aborted the dev server before the range check could reject it.

Security risks

This is a network-reachable unauthenticated path on the dev server, so the change is in scope for a DoS review, and it is a strict improvement: untrusted bytes are now range-checked before being wrapped in the typed index. I confirmed the invariant the new guard relies on — get_or_put_route_bundle (DevServer.rs:5257) constructs Index::init(len) before each push, so len can never exceed u32::MAX and any index < len is a valid, non-sentinel value. I also checked the sibling .js.map branch in the same function for the same class of bug: source_map_store::Key::init is a transparent u64 wrapper with no assert, SourceId::kind() is self.0 & 1, and the result is only used as a hashmap key, so crafted map URLs cannot reach an assert. on_asset_request similarly does a plain hashmap lookup. No auth, crypto, or data-exposure surface is involved; release builds were already returning 404.

Level of scrutiny

Low-to-moderate. The diff is four lines of Rust with a clear mechanical equivalence: id as u32 is bit-identical to u32::try_from(id & 0xFFFFFFFF) on a u64 (the mask guaranteed success), and (id >> 32) as u32 always fits so try_from could never fail there either. The one-line comment explains the ordering constraint that would otherwise be non-obvious to the next reader. No CODEOWNERS entry covers src/runtime/bake/ or test/bake/, and the bug hunt ran to a dry streak without findings.

Other factors

The test cannot fail under USE_SYSTEM_BUN=1 because the system Bun is a release build with debug_assert! compiled out; that is a property of the bug rather than a weakness in the test, and the PR description states this plainly. The test structure is sound: it extracts the real script URL from the served page (so expect(script).toBeString() fails loudly if the URL format ever changes rather than silently sending undefined), verifies the real script serves, probes both the exact sentinel and a near-miss out-of-range value, and re-fetches / to prove the server is still alive. The ?? [] fallback only affects which assertion reports the failure, not whether one does. Two other open PRs reportedly edit lines below this in on_js_request; whichever lands second will need a trivial rebase, which is not a correctness concern for this change.

This branch has not been deployed

No deployments
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.

1 participant