Skip to content

Bun.build: report an error for HTML rooted script src paths >= 4096 bytes instead of aborting - #35860

Closed
robobun wants to merge 6 commits into
mainfrom
farm/c12a0373/html-rooted-src-long-path-panic
Closed

robobun wants to merge 6 commits into
mainfrom
farm/c12a0373/html-rooted-src-long-path-panic

Conversation

@robobun

@robobun robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

Reproduction

import fs from "node:fs"; import os from "node:os"; import path from "node:path";
const d = fs.mkdtempSync(path.join(os.tmpdir(), "html-"));
const src = "/" + "a".repeat(5000) + ".js";
fs.writeFileSync(path.join(d, "index.html"), `<html><body><script src="${src}"></script></body></html>`);
const r = await Bun.build({ entrypoints: [path.join(d, "index.html")], throw: false });
console.log("success", r.success, "SURVIVED (no panic)");   // never gets here
panic: range end index 5017 out of range for slice of length 4095

Cause

HTMLScanner::create_import_record re-bases a rooted <script src="/..."> onto the project root via resolve_path::join_abs_string. That primitive writes its normalized result into a fixed 4096-byte threadlocal (PARSER_JOIN_INPUT_BUFFER) with no bound check, so a src attribute of 4096 bytes or more panics on the slice copy and aborts the process. The same primitive backs validate_path for the bundler external option, which aborted on the same input.

Once the path survives the join, the resolver's load_as_file copies it into another PathBuffer-sized threadlocal for extension probing with the same unchecked slice bound.

Fix

join_abs_string / join_abs_string_z now size-check cwd + parts up front and spill the output buffer to a threadlocal Vec<u8> when it exceeds 4096, mirroring the existing join_spill / join_z_spill pattern. The fast path (fits in 4096) is unchanged.

Resolver::load_as_file returns not-found for paths that already exceed the extension-probe buffer instead of panicking on the copy; a path that long cannot name a file the OS would open.

Verification

  • Fails on stock 1.4.0-canary: panic: range end index 5017 out of range for slice of length 4095
  • With fix: Bun.build resolves with success: false and a ResolveMessage ("Could not resolve: ...")
  • external: ["./" + "a".repeat(5000) + ".js"] (same primitive) also survives now
  • bun-build-api.test.ts, bundler_html.test.ts, bundler_edgecase.test.ts, test/js/bun/resolve/resolve.test.ts, test/js/node/path all green

no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-api.test.ts

…ytes instead of aborting

join_abs_string / join_abs_string_z wrote their normalized result into a fixed
4096-byte threadlocal buffer with no bound check. An HTML entrypoint with a
rooted <script src="/..."> is re-based onto the project root through that
primitive, so an attacker-authored src >= 4096 bytes aborted the process with
'range end index ... out of range for slice of length 4095' instead of
producing a resolution error. The same primitive backs bundler 'external'
path normalization.

Spill the output buffer to a threadlocal Vec when cwd + parts exceed 4096,
matching the existing join_spill pattern. Also guard load_as_file's
extension-probing copy so the resolver returns not-found for paths that
already exceed its PathBuffer instead of panicking on the slice bound.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR adds spill-buffer support for oversized absolute path joins, tightens resolver and bundler buffer-boundary checks, and adds a concurrent build regression test for long HTML script paths.

Path Length Safety

Layer / File(s) Summary
Oversized join output handling
src/paths/resolve_path.rs
Absolute path joins now select either the fixed thread-local buffer or a dynamically resized spill buffer based on required output size.
Resolver and bundler capacity guards
src/resolver/resolver.rs, src/bundler/bundle_v2.rs
Resolver paths reject inputs that exceed fixed-buffer limits, and Windows specifier normalization skips conversion when the input is too large.
Long-path build regression coverage
test/bundler/bun-build-api.test.ts
A concurrent build test verifies oversized rooted script paths produce a reported resolution failure without stderr or process abortion.

Possibly related PRs

  • oven-sh/bun#34191: Tightens resolver MAX_PATH_BYTES checks in related standalone and require-resolution flows.
  • oven-sh/bun#35349: Adds related oversized-path guards in Resolver::dir_info_cached_maybe_log.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: HTML rooted script src paths at the 4096-byte boundary now error instead of aborting.
Description check ✅ Passed The description covers what changed and how it was verified, though it uses custom headings instead of the template's exact sections.

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

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:27 PM PT - Jul 25th, 2026

❌ @robobun, your commit 399176e has 1 failures in Build #82121 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.47 MB71.95 MB+528.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+570.0 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+537.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35860

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

bun-35860 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. paths: spill join_abs_string to heap when the result would overflow the thread-local buffer #35861 - Also spills join_abs_string to heap when the result overflows the thread-local buffer in resolve_path.rs, with tests in the same file
  2. resolver: bound load_as_file path before writing into its PathBuffer #35857 - Adds the same load_as_file path bounds check in resolver.rs to prevent panics on oversized paths

🤖 Generated with Claude Code

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap with the two linked PRs, for whoever reviews:

This PR is the only one of the three that tests the HTML-entrypoint panic end to end (rooted <script src="/..."> at 5000 bytes surfaces a ResolveMessage instead of aborting), which needs both layers fixed to reach a resolution error.

Happy to rebase and drop my resolver.rs hunk once #35857 lands, or fold the HTML test into either of the others if that is easier to review.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

This overlaps with #35861 (same join_abs_string spill for the external option face of the same panic). Closing #35861 in favor of this PR since it also guards Resolver::load_as_file.

The branch farm/af4ef906/join-abs-string-spill has two extra tests in bun-build-api.test.ts covering Bun.build({ external: ['/' + 'a'.repeat(5000)] }) and bun build --external /aaa...aaa e.ts, both of which abort on main and exit 0 with the spill in place, if you want to fold them in.

Comment thread src/resolver/resolver.rs
Comment thread test/bundler/bun-build-api.test.ts
…YTES

A path in the [MAX_PATH_BYTES - ext.len(), MAX_PATH_BYTES) window passed the
load_as_file guard but still panicked when load_extension sliced
[0..path.len() + ext.len()], and a path of exactly MAX_PATH_BYTES passed the
existing dir_info_cached check but panicked on the [..len + 1] slice in
dir_info_cached_miss. Bound both, and drop the stale comment claiming
PathBuffer carries a +1 sentinel slot.

The HTML test now sweeps a 16-byte window around MAX_PATH_BYTES plus one
well past it, and asserts stderr is empty rather than checking for a panic
substring.
Comment thread src/paths/resolve_path.rs Outdated
Comment thread src/resolver/resolver.rs
Comment thread src/resolver/resolver.rs
Comment thread src/resolver/resolver.rs 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.

Both earlier findings are addressed — load_extension now guards path.len() + ext.len(), dir_info_cached is tightened to >=, and the test sweeps the boundary with a positive stderr check. I didn't find anything new, but this reworks a path-join primitive used across the resolver/bundler and overlaps with #35857, so a human should coordinate which lands.

What was reviewed:

  • join_abs_needed upper bound vs. both _join_abs_string_buf variants — normalization never grows the concatenation, and no in-tree caller uses platform::Nt (the only case that would add bytes beyond the estimate).
  • parser_join_out_buf spill mirrors the existing join_spill/join_z_spill pattern in the same file; the fixed-buffer fast path is unchanged.
  • dec_ret!(None) matches sibling exit sites in load_as_file; the >= vs > split between load_as_file and load_extension is correct for their respective slice bounds.
Extended reasoning...

Overview

Three files: src/paths/resolve_path.rs adds a threadlocal Vec<u8> spill buffer so join_abs_string/join_abs_string_z heap-allocate when cwd + parts exceeds 4096 instead of panicking on the fixed buffer; src/resolver/resolver.rs adds three length guards (dir_info_cached tightened from > to >= to protect the [..len+1] slice in dir_info_cached_miss, load_as_file bails when the path won't fit the extension-probe buffer, load_extension bails when path + ext won't fit); test/bundler/bun-build-api.test.ts adds a subprocess test that sweeps a 16-byte window around MAX_PATH_BYTES plus one well past it and asserts a ResolveMessage instead of an abort.

Security risks

The bug is a user-input-reachable panic (DoS) in Bun.build via an attacker-authored HTML entrypoint. The fix converts the panic into a resolve error. No new trust boundaries are crossed; the spill buffer is a per-thread Vec that grows monotonically (same retention model as the existing join_spill helpers). No injection/auth/data-exposure surface.

Level of scrutiny

Medium-high. join_abs_string is a shared primitive used throughout the resolver, bundler external validation, and HTML scanner — the upper-bound estimate in join_abs_needed has to hold for POSIX, Loose, and Windows (_join_abs_string_buf_windows) code paths. I traced both branches and confirmed normalization never grows the concatenation and the +1-per-part / +2 slack covers inserted separators and the sentinel NUL; the only path that would exceed the estimate is Platform::Nt (adds a 4-byte \\?\ prefix), and grep confirms no caller invokes join_abs_string/_z with platform::Nt. The resolver guards are simple early-return-None checks.

Other factors

My previous review flagged the missing load_extension headroom and the negative panic-string assertion; both were fixed in a7840b9 (verified by re-reading the diff), and the comment-cop trims landed in b7a4a02. All threads are resolved. The remaining reason to defer is coordination: #35857 (opened first) carries a superset of the resolver.rs guards and #35861 was closed in favor of this PR — a maintainer should decide the merge order rather than me approving one side of an acknowledged overlap. The change to a widely-shared path primitive also merits a human sanity check even though it follows the in-file join_spill pattern closely.

robobun added 2 commits July 26, 2026 03:55
…rror message

On Windows, the 'Could not resolve' error message for an HTML import
re-slashes the specifier via path_to_posix_buf into a pooled PathBuffer
(MAX_PATH_BYTES). A rooted <script src> longer than that panicked on the
slice copy. Fall back to the un-reslashed specifier when it does not fit;
the error text keeps a backslash instead of aborting.
Comment thread src/resolver/resolver.rs
…inks

abs_buf_checked can return a result of exactly buf.len(); appending the
trailing separator then indexed one past the PathBuffer. Reachable via an
absolute HTML <script src> containing an embedded '..' and a trailing '/'
sized so the re-based path lands exactly on MAX_PATH_BYTES. The boundary
test now sweeps this shape too.

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/paths/resolve_path.rs`:
- Around line 1351-1354: Prevent usize overflow in both path-length checks: in
src/paths/resolve_path.rs:1351-1354, update join_abs_needed to use checked
accumulation and return the established oversized-path error on overflow; in
src/resolver/resolver.rs:5875-5878, replace unchecked length addition before
load_as_file slicing with checked_add or an equivalent subtraction-based bound
so oversized inputs cannot bypass validation or panic.
- Around line 1351-1365: Bound the size accepted by parser_join_out_buf before
the spill Vec resize, using the existing path-resolution limit or a dedicated
maximum for user-controlled inputs. Ensure oversized needed values are rejected
through the normal catchable resolution-error path rather than attempting
allocation, while preserving the existing fixed-buffer and valid spill-buffer
behavior.
- Line 16: Update join_abs_string and join_abs_string_z so their returned slices
never reference the thread-local PARSER_JOIN_INPUT_BUFFER or
PARSER_JOIN_SPILL_BUFFER. Return owned or otherwise stable storage, or require
an explicit caller copy before the result can escape, while preserving the
existing path-joining behavior.

In `@test/bundler/bun-build-api.test.ts`:
- Around line 802-809: Update the subprocess assertions around the Promise.all
result to validate the combined { stderr, exitCode } failure signal before
calling JSON.parse(stdout). Keep the existing expected stderr and exitCode
values, then parse and assert the JSON payload only after those diagnostics have
been verified.
🪄 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: fe6d1d5f-1426-4343-859f-a26b7bdca3c6

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 399176e.

📒 Files selected for processing (4)
  • src/bundler/bundle_v2.rs
  • src/paths/resolve_path.rs
  • src/resolver/resolver.rs
  • test/bundler/bun-build-api.test.ts

Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs
Comment thread src/paths/resolve_path.rs
Comment thread test/bundler/bun-build-api.test.ts
@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI at 399176e (build 82121, finished): 195/196 jobs passed; bun-build-api.test.ts (the new test) is green on every lane including Windows x64, Windows aarch64, and all three macOS lanes.

The only hard failure is the binary-size check, which reports +500-576 KB on every target. That check is comparing against canary build #79916; this PR is based on main at 44f6469 (build #81770). Main's own size growth between those builds is what it is reporting, not this ~85-line diff. The baseline has not advanced because recent main builds (#81770, #81444, #81275) all failed or were cancelled before binary-size could run.

The five yellow annotations (bun-install-registry, test-fs-read-stream-pos, fs.watch, no-orphans, 20144) all passed on retry and are unrelated to path resolution or the bundler.

Ready for a maintainer; see the earlier comment for the overlap with #35857 / #35861.

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

The trailing-separator write at resolver.rs:1821 and the Windows path_to_posix_buf site are now guarded — all three of my earlier findings are addressed and this pass found nothing new. Deferring to a human because join_abs_string is a shared primitive under every resolver caller, and this overlaps #35857's resolver.rs hunk.

Checked this pass: join_abs_needed's upper bound holds for both the POSIX and Windows join branches (normalization never grows the concat, absolute-part promotion is covered by summing all inputs); the spill Vec keeps the same single-live-borrow TLS contract as the fixed buffer; the bundle_v2.rs fallback is error-message-only so raw backslashes on Windows are cosmetically fine; the boundary-sweep test now covers the foo../ shape at exactly MAX_PATH_BYTES.

Extended reasoning...

Overview

Four files: src/paths/resolve_path.rs adds a threadlocal Vec<u8> spill for join_abs_string/join_abs_string_z when cwd + parts exceeds 4096, mirroring the existing join_spill pattern. src/resolver/resolver.rs adds four bounds guards (trailing-separator append in resolve_without_remapping, >= on dir_info_cached, load_as_file copy, load_extension slice) plus tightens a stale comment. src/bundler/bundle_v2.rs skips the Windows-only path_to_posix_buf when the specifier already exceeds the pool buffer (error-message rendering only). test/bundler/bun-build-api.test.ts adds a subprocess test that sweeps a 16-byte window around MAX_PATH_BYTES with both plain-rooted and foo../-shaped script src values.

Security risks

This is DoS hardening — user-controlled HTML <script src> and external: values previously reached unchecked slice writes into fixed PathBuffers, panicking and aborting the process. The fix converts those to NotFound/ResolveMessage. No new attack surface is introduced; the spill allocation is bounded by input the caller already holds in memory (no amplification), and the TLS UnsafeCell contract is unchanged from the fixed buffer it supplements.

Level of scrutiny

High. join_abs_string backs every path-join in the HTML scanner, external validator, and several resolver call sites; resolver.rs is the module-resolution hot path. Each of my two prior review passes found an additional unguarded same-class site (the load_extension window, then the resolve_without_remapping trailing-separator write), and CI independently caught the Windows bundle_v2.rs site — the author reproduced and fixed all of them, but the "fix the whole class" history warrants a human confirming the class is closed rather than a bot approving after three iterations.

Other factors

  • The author flagged overlap with #35857 (a superset of the resolver.rs guards) and #35861 (closed in favor of this PR) — a human should decide the merge order.
  • CodeRabbit raised four concerns and withdrew all of them after the author explained the pre-existing TLS-scratch contract, the impracticality of usize overflow on 64-bit, and the stderr-first assertion order.
  • The PR description's evidence block notes the test is platform-gated and deferred to CI on this machine; the Windows hunk was fixed after a CI failure (b1c8c40), not local repro.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Two notes for whoever rebases or reviews this, from a second report of the same abort:

  • The bare-specifier branch of the same function (join_abs_string_z followed by exists_z, now src/bundler/HTMLScanner.rs:60; the rooted branch is line 38) reproduces the identical panic on 1.4.0-canary.1 (eabb96d): <script src="aaa...a.js"> and <link rel="stylesheet" href="aaa...a.css"> with a 5000 byte name both abort with the same panic: range end index 5017 out of range for slice of length 4095 (5018 for the .css one), while the ./-prefixed form prints Could not resolve and exits 1. The spill here changes join_abs_string_z as well, so it should fix that branch too (exists_z fails with ENAMETOOLONG and the raw specifier falls through to the resolver), but the test in this PR only exercises the rooted / form. Worth adding the bare <script src> and <link href> shapes, plus a dev server case if convenient, since Bun.serve with an HTML route runs the same scan.
  • The branch currently conflicts with main in src/paths/resolve_path.rs and src/resolver/resolver.rs (Resolver::load_as_file on main is still unguarded, so the resolver half is still needed; resolver: bound load_as_file path before writing into its PathBuffer #35857 also covers it).

@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

The dev server is another way to reach this abort. Measured on Linux x64 with stock 1.4.3-canary.1+09bb54630:

// index.html: <script type="module" src="/aaa...(5,000 bytes).js"></script>
import page from "./index.html";
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", development: true, routes: { "/": page } });
await fetch(`http://127.0.0.1:${s.port}/`);

The first GET / aborts the whole server process with panic: range end index 5017 out of range for slice of length 4095. The dev server bundles the page on the first request, so the join in HTMLScanner::create_import_record runs at request time. A bare src="aaa...js" aborts the same way, in the second join of that function.

Update: #42806 now bounds both joins in HTMLScanner::create_import_record at the call site, and it adds the same load_as_file and load_extension guards as this PR. The part of this PR that #42806 does not cover is validate_path for the external option (src/bundler/options.rs:57), which still uses the unchecked join_abs_string on main.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #43067. It fixes this trigger with the shared checked path helpers and carries the tests from this pull request.

@robobun robobun closed this Sep 17, 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.

2 participants