Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2558,23 +2558,27 @@
}

_ => {
// Spec caps at 256 (`var errors_stack: [256]JSValue`). PERF(port):
// was inline switch — Zig stack-allocated; we heap-allocate the
// exact `len` since `JSValue` is a thin u64 and 256 * 8 B = 2 KiB
// is fine either way, but `Vec` avoids the uninit-array dance.
let len = log.msgs.len().min(256);
let mut errors: alloc::vec::Vec<JSValue> = alloc::vec::Vec::with_capacity(len);
for msg in log.msgs.drain(..len) {
let v = match msg.metadata {
// Spec caps at 256 (`var errors_stack: [256]JSValue`). The array
// must stay on the stack like the Zig spec: until
// `create_aggregate_error` runs, these freshly created
// BuildMessage/ResolveMessage wrappers have no other GC-visible
// reference, and `create()` can trigger a collection mid-loop.
// JSC's conservative scan only covers the stack/registers, so a
// heap `Vec<JSValue>` would let earlier wrappers be swept (and
// their native payloads freed) before the AggregateError exists.
let mut errors_stack = [JSValue::ZERO; 256];

Check notice on line 2569 in src/jsc/VirtualMachine.rs

View check run for this annotation

Claude / Claude Code Review

Same heap-Vec UAF pattern remains in LogJsc::to_js — fix is incomplete

Heads-up: the same heap-`Vec<JSValue>` UAF pattern this PR fixes still exists in `LogJsc::to_js` at `src/jsc/lib.rs:2146-2151` — `let mut errors_stack: Vec<JSValue> = …` followed by a loop pushing `msg_to_js()` (which calls the same `BuildMessage::create`/`ResolveMessage::create`). That path is live via `Bun.Transpiler` (`scanImports`/`transformSync`), `web_worker`, `TOMLObject`, `JSONCObject`, `css_internals`, and `filesystem_router` for any input with 2+ log messages. This is pre-existing code
Comment on lines +2561 to +2569

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.

🟣 Heads-up: the same heap-Vec<JSValue> UAF pattern this PR fixes still exists in LogJsc::to_js at src/jsc/lib.rs:2146-2151let mut errors_stack: Vec<JSValue> = … followed by a loop pushing msg_to_js() (which calls the same BuildMessage::create/ResolveMessage::create). That path is live via Bun.Transpiler (scanImports/transformSync), web_worker, TOMLObject, JSONCObject, css_internals, and filesystem_router for any input with 2+ log messages. This is pre-existing code the PR doesn't touch, but it's the identical root cause with the identical 3-line fix ([JSValue::ZERO; 256] like src/ast_jsc/lib.rs:50 already does), so it's probably worth applying here too.

Extended reasoning...

What the bug is

This PR fixes a use-after-free in process_fetch_log (src/jsc/VirtualMachine.rs) by replacing a heap Vec<JSValue> with a stack [JSValue; 256], so JSC's conservative stack/register scan keeps freshly-created BuildMessage/ResolveMessage wrappers rooted while later loop iterations call create() (which can trigger GC). The PR's new comment at lines 2561-2568 documents exactly why a heap Vec<JSValue> is unsafe in this pattern.

However, the identical buggy pattern remains in src/jsc/lib.rs:2146-2151, in impl LogJsc for bun_ast::Log → fn to_js:

_ => {
    let mut errors_stack: Vec<JSValue> = Vec::with_capacity(count);
    for msg in &msgs[0..count] {
        errors_stack.push(msg_to_js(msg, global)?);
    }
    let out = bun_core::ZigString::init(message.as_bytes());
    global.create_aggregate_error(&errors_stack, &out)
}

The local is even named errors_stack (after the Zig spec's on-stack [256]JSValue), but it is a heap Vec. msg_to_js (lib.rs:2131-2135) dispatches to BuildMessage::create / ResolveMessage::create — the exact same GC-allocating calls that motivated this PR's fix.

Code path that triggers it

LogJsc::to_js is a live path. Verified callers (via use crate::LogJsc + .to_js(...)):

  • src/runtime/api/JSTranspiler.rs — lines 903, 1029, 1046, 1083, 1398, 1404, 1589, 1595, 1818, 1824 (e.g. transformSync / scanImports on input with multiple parse errors)
  • src/jsc/web_worker.rs:1432, 1680
  • src/runtime/api/TOMLObject.rs:25, 30, 49
  • src/runtime/api/JSONCObject.rs:27
  • src/css_jsc/css_internals.rs:226, 386
  • src/runtime/api/filesystem_router.rs:262, 297, 311, 497, 523

Any of these producing 2+ log messages enters the _ => branch and stages the wrappers in a heap Vec.

Why existing code doesn't prevent it

Quoting this PR's own new comment: "JSC's conservative scan only covers the stack/registers, so a heap Vec<JSValue> would let earlier wrappers be swept (and their native payloads freed) before the AggregateError exists." That reasoning applies verbatim to lib.rs:2146. Until create_aggregate_error runs, the only references to the per-message wrappers live in the Vec's heap buffer; the Vec struct on the stack holds a pointer/len/cap, and conservative scanning does not chase heap pointers.

For contrast, the parallel implementation in src/ast_jsc/lib.rs:49-50 already does this correctly:

// On-stack array: conservative GC stack scan keeps these JSValues alive (see PORTING.md §JSC).
let mut errors_stack: [JSValue; 256] = [JSValue::default(); 256];

and the Zig spec at src/ast_jsc/logger_jsc.zig:44 uses var errors_stack: [256]jsc.JSValue. Only the src/jsc/lib.rs port diverged.

Step-by-step proof

Concrete walk-through with new Bun.Transpiler().transformSync(src) where src has 3 parse errors, under BUN_JSC_collectContinuously=true:

  1. Transpiler hits 3 errors → log.msgs.len() == 3JSTranspiler.rs calls log.to_js(global, "...").
  2. to_js enters the _ => arm, allocates Vec<JSValue> with capacity 3 — the 24-byte buffer lives on the heap.
  3. Iteration 0: msg_to_jsBuildMessage::create allocates a JS cell wrapping a 152-byte Box<BuildMessage> payload; the resulting JSValue is written to vec_buf[0] on the heap. No stack slot or register continues to hold it after push returns.
  4. Iteration 1: msg_to_jsBuildMessage::create allocates again. With collectContinuously, this triggers a full GC. The conservative scan walks the native stack and registers; it sees the Vec's (ptr, len, cap) triple but does not dereference ptr, so the wrapper from iteration 0 has zero visible references. It is swept; BuildMessageClass__finalize runs and frees the 152-byte Box<BuildMessage>.
  5. Iteration 1's wrapper is pushed; iteration 2 likewise (potentially sweeping iteration 1's wrapper).
  6. create_aggregate_error(&errors_stack, …) reads errors_stack[0] — a stale JSValue pointing at a swept cell — and stores it in the AggregateError's errors array.
  7. The caller throws/returns the AggregateError. When user code reads e.errors[0].message / .position, or when the runtime prints the error, it dereferences the freed 152-byte payload → heap-use-after-free, identical fingerprint to the one this PR fixes.

Impact

Same as the bug this PR fixes: heap UAF reading freed BuildMessage/ResolveMessage payloads when constructing/printing an AggregateError from a multi-error log, reachable from several public APIs (Bun.Transpiler, TOML/JSONC parsing, CSS internals, FileSystemRouter, Workers). It requires a GC to land mid-loop, so it's rare in practice but deterministic under BUN_JSC_collectContinuously=true — exactly the conditions in this PR's new regression test.

How to fix

Apply the same change as this PR:

_ => {
    let mut errors_stack = [JSValue::ZERO; 256];
    for (i, msg) in msgs[..count].iter().enumerate() {
        errors_stack[i] = msg_to_js(msg, global)?;
    }
    let out = bun_core::ZigString::init(message.as_bytes());
    global.create_aggregate_error(&errors_stack[..count], &out)
}

This is pre-existing code that the PR does not touch, so I've marked it as such — but since the PR's commit title is "Keep multi-error AggregateError children GC-visible while they are created" and this is a second instance of that exact class with the same 3-line fix, it seems worth folding in here.

let len = log.msgs.len().min(errors_stack.len());
for (i, msg) in log.msgs.drain(..len).enumerate() {
errors_stack[i] = match msg.metadata {
bun_ast::Metadata::Build => take(BuildMessage::create(global_this, msg)),
bun_ast::Metadata::Resolve(_) => take(ResolveMessage::create(
global_this,
&msg,
referrer_utf8.slice(),
)),
};
errors.push(v);
}
let errors = &errors_stack[..len];

// C++ `Zig::toString` does `createWithoutCopying`, so the buffer
// must outlive the AggregateError. Mark it global so JSC adopts it
Expand All @@ -2588,7 +2592,7 @@
message.mark_global();
*ret = ErrorableResolvedSource::err(
err,
take(global_this.create_aggregate_error(&errors, &message)),
take(global_this.create_aggregate_error(errors, &message)),
);
}
}
Expand Down
54 changes: 53 additions & 1 deletion test/js/bun/resolve/build-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { tempDir } from "harness";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "node:path";

test("BuildError is modifiable", async () => {
Expand All @@ -19,6 +19,58 @@
expect(error!.message).not.toBe(message);
});

test("AggregateError from a module with many build errors survives GC during its creation", async () => {
// A module that fails to load with multiple errors produces an
// AggregateError of BuildMessage objects. The wrappers used to be collected
// in a heap Vec the conservative GC scan cannot see, so a collection in the
// middle of creating them freed the native payloads and printing or
// inspecting the AggregateError afterwards was a use-after-free.
using dir = tempDir("aggregate-build-errors", {
"bad.js": Array.from({ length: 250 }, (_, i) => `let dup${i} = 1; let dup${i} = 2;`).join("\n"),
"index.js": `
let aggregate;
try {
await import("./bad.js");
throw new Error("expected import to fail");
} catch (e) {
aggregate = e;
}
Bun.gc(true);
if (aggregate.constructor.name !== "AggregateError") {
throw new Error("expected AggregateError, got " + aggregate.constructor.name);
}
if (aggregate.errors.length < 2) {
throw new Error("expected multiple build errors, got " + aggregate.errors.length);
}
for (const sub of aggregate.errors) {
if (typeof sub.message !== "string") throw new Error("bad message");
void sub.position;
}
console.error(aggregate);
console.log("OK", aggregate.errors.length);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
cwd: String(dir),
env: {
...bunEnv,
// Make a collection land while the per-message error wrappers are being created.
BUN_JSC_collectContinuously: "true",
BUN_JSC_forceRAMSize: "1000000",
},
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).not.toContain("AddressSanitizer");

Check warning on line 69 in test/js/bun/resolve/build-error.test.ts

View check run for this annotation

Claude / Claude Code Review

Test asserts absence of 'AddressSanitizer' in stderr — prohibited pattern

nit: `expect(stderr).not.toContain("AddressSanitizer")` is the kind of assertion CLAUDE.md says to avoid ("NEVER write tests that check for no 'panic' or 'uncaught exception' or similar in the test output. These tests will never fail in CI."). Non-ASAN CI builds will never emit this string, and on ASAN builds a UAF aborts the subprocess so `expect(exitCode).toBe(0)` and `expect(stdout).toContain("OK 250")` already catch it — this line can be dropped.

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.

🟡 nit: expect(stderr).not.toContain("AddressSanitizer") is the kind of assertion CLAUDE.md says to avoid ("NEVER write tests that check for no 'panic' or 'uncaught exception' or similar in the test output. These tests will never fail in CI."). Non-ASAN CI builds will never emit this string, and on ASAN builds a UAF aborts the subprocess so expect(exitCode).toBe(0) and expect(stdout).toContain("OK 250") already catch it — this line can be dropped.

Extended reasoning...

What this is

The new regression test asserts expect(stderr).not.toContain("AddressSanitizer") against the subprocess output. The repo's root CLAUDE.md (line 126) explicitly prohibits this style of assertion:

NEVER write tests that check for no "panic" or "uncaught exception" or similar in the test output. These tests will never fail in CI.

Checking for the absence of an AddressSanitizer diagnostic falls squarely into the "or similar" bucket — it is a crash-diagnostic-absence check, just like checking for the absence of panic: or uncaught exception.

Why it is redundant

Walk through the two CI configurations:

  1. Non-ASAN build (most CI jobs). The subprocess binary was not compiled with -fsanitize=address, so it is physically incapable of writing AddressSanitizer to stderr regardless of whether the bug is present. The assertion is a tautology and can never fail. This is exactly the failure mode the CLAUDE.md rule warns about.

  2. ASAN build with the bug present. ASAN detects the heap-use-after-free, prints its report to stderr, and aborts the process with a non-zero exit code before console.log("OK", aggregate.errors.length) runs. So:

    • expect(exitCode).toBe(0) fails (process aborted), and
    • expect(stdout).toContain("OK 250") fails (the OK line was never printed).

    The regression is already detected by the two assertions that follow; the stderr check adds no coverage.

A grep across test/ confirms no other test file uses .not.toContain("AddressSanitizer"), so this is not following an established local convention.

Impact

None functionally — the test still passes/fails correctly with this line present. It is purely dead weight that contradicts an explicit repo testing convention.

Fix

Delete line 69. The remaining expect(stdout).toContain("OK 250") and expect(exitCode).toBe(0) fully cover the regression. If a more descriptive failure message on ASAN runners is desired, prefer attaching stderr to the exit-code assertion (e.g. expect(exitCode, stderr).toBe(0)) rather than asserting on the absence of a sanitizer string.

expect(stdout).toContain("OK 250");
expect(exitCode).toBe(0);
});

test("BuildMessage finalize frees with the same allocator it was created with", async () => {
// BuildMessage.create() clones the message with the passed allocator
// but finalize() was freeing it with bun.default_allocator and never
Expand Down
Loading