Skip to content

error: read name/message via full [[Get]] for the .stack header; drop inspect.js workaround - #34868

Open
robobun wants to merge 9 commits into
mainfrom
farm/cd55536b/remove-formatError-stack-replace
Open

error: read name/message via full [[Get]] for the .stack header; drop inspect.js workaround#34868
robobun wants to merge 9 commits into
mainfrom
farm/cd55536b/remove-formatError-stack-replace

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Repro

const util = require("util");

// (1) the inspect.js workaround corrupts a materialized stack when message is later cleared
const e = new Error("msg");
void e.stack;
e.message = "";
util.inspect(e).split("\n")[0];   // bun: "Errormsg",  node: "Error: msg"

// (2) sanitized name lookup misses a name on an intermediate prototype
class Bar extends Error {}
class Foo extends Bar {}
Bar.prototype.name = "Bar";
new Foo("x").stack.split("\n")[0];   // bun: "Error: x",  node: "Bar: x"

// (3) ...and an accessor-defined name
class G extends Error { get name() { return "G"; } }
new G("m").stack.split("\n")[0];     // bun: "Error: m",  node: "G: m"

// (4) the Error.prepareStackTrace default string hardcodes "Error"
Error.prepareStackTrace = (e, s) => e.stack;
class Foo2 extends Error { name = "Foo" }
new Foo2("x").stack.split("\n")[0];  // bun: "Error: x",  node: "Foo: x"

Cause

formatError in src/js/internal/util/inspect.js carried a 2023 workaround that rewrote ^Error: in the stack string to ${err.name}${err.message ? ": " : ""}. It compensated for native bugs in the .stack header: sanitizedNameString only reads own + one prototype level with VMInquiry, skips accessors, and rejects non-primitives; sanitizedMessageString reads own only; and formatStackTraceToJSValue (the prepareStackTrace default string) hardcoded "Error".

For the common cases (own data name, empty message) the native header is correct, so the workaround now only fires when the current name/message disagree with the materialized stack, where it drops ^Error: but leaves the old message text and produces Errormsg. Deleting it alone regresses cases (2)-(4).

Node's formatError has no such rewrite. V8 composes the header with ErrorUtils::ToString: ordinary [[Get]] on name and message, undefined defaulting to "Error" / empty, ToString otherwise.

Fix

Both header-composing paths (computeErrorInfoWithoutPrepareStackTrace for the direct .stack read, formatStackTraceToJSValue for the prepareStackTrace default string) now go through one computeErrorHeader that follows ErrorUtils::ToString. The GC-finalizer path reaches the former with errorInstance == nullptr, so the [[Get]] only runs from a mutator.

Running user code there opens two hazards this PR closes:

  • Re-entry. A name/message getter that calls Error.captureStackTrace(this) or reads this.stack would re-enter unboundedly. computeErrorHeader guards with isComputingErrorStackHeader on Zig::GlobalObject; the inner call falls back to the side-effect-free sanitized accessors so the cycle terminates after one level, matching Node's single outer getter invocation.
  • Use-after-free. A name getter that read this.stack after Error.captureStackTrace had installed the lazy CustomAccessor reached errorInstanceLazyStackCustomGetter while the outer materializeErrorInfoIfNeeded still held a Vector<StackFrame>& into *m_stackTrace; the inner move + setStackFrames({}) destructed that Vector and the outer formatStackTrace read freed memory (ASAN heap-use-after-free under Malloc=1). Under the same guard the inner call now formats from the existing Vector without moving out of it or reassigning m_stackTrace.

errorConstructorFuncCaptureStackTrace now roots the captured frames' cells in a MarkedArgumentBuffer before computing (both eager paths), since the header [[Get]] can run a user getter before the frames are formatted; the existing rooting in errorInstanceLazyStackCustomGetter and this site share a protectStackFrameCells helper.

As a side effect of dropping the dynamicDowncast<ErrorInstance> gate, Error.captureStackTrace({name:"X",message:"Y"}) now labels its stack "X: Y" (previously "Error"), which is the V8 behavior.

With the native header correct, the stack.replace line in formatError is removed, the relaxed upstream assertions in util-inspect.test.js / util-format.test.js are restored verbatim, and the two previously-disabled subclass-header assertions in util-inspect.test.js (BazError accessor-name and the [WOW] tag variant) are re-enabled.

Verification

New tests:

  • util-inspect.test.js: error inspect preserves stack header when name/message change after materialization (the cases the workaround corrupted plus the subclass/empty-message cases it papered over) and error stack header reads name/message via full [[Get]] (deep-prototype name, accessor name, non-primitive name, accessor/deep-prototype message, undefined/null name, throwing name getter, the prepareStackTrace default string, a re-entrant message getter called once, and captureStackTrace on a {name, message} literal). Every expected value was taken from Node v26.3.0.
  • capture-stack-trace.test.js: spawned Malloc=1 regression for the lazy-getter UAF.
USE_SYSTEM_BUN=1 bun test util-inspect.test.js -t "stack header"              # 2 fail
USE_SYSTEM_BUN=1 bun test capture-stack-trace.test.js -t "does not free"      # 1 fail
bun bd test util-inspect.test.js -t "stack header"                            # 2 pass
bun bd test capture-stack-trace.test.js                                       # 44 pass

test/js/node/v8/capture-stack-trace.test.js (44 pass incl. the #34095 test, with BUN_JSC_validateExceptionChecks=1), test/js/node/util/node-inspect-tests/, test/js/node/v8/, test/js/bun/sourcemap/, test/js/bun/util/fuzzy-wuzzy.test.ts, test/js/bun/util/inspect.test.js, test/js/node/assert/ all pass. A handful of pre-existing debug+ASAN timeouts (util-inspect.test.js "no assertion failures 2", error-gc-test.test.js, inspect-error-leak.test.js) and the two inspect-error.test.js snapshot mismatches reproduce identically on unmodified main.


no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

…eaders

The native .stack getter already emits the correct first line (name
followed by ': message' only when message is non-empty) for subclassed
errors and empty-message errors, so the 2023 stack.replace workaround in
formatError is no longer needed. Worse, it now corrupts output: when an
Error's message is cleared after .stack was read, util.inspect rendered
'Errormsg' instead of 'Error: msg', and a user-assigned stack starting
with 'Error: ' on an empty-message Error lost its ': '. Node never
rewrites the stack header in util.inspect.

Remove the workaround and restore the upstream Node assertion in
util-inspect.test.js that it replaced. Add a test covering the cases the
workaround mangled plus the subclass/empty-message cases it originally
papered over.
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:39 PM PT - Jul 20th, 2026

@robobun, your commit 1a36d38cb6ea6a77dc1e344080bc0cf176dc43e4 passed in Build #76605! 🎉


🧪   To try this PR locally:

bunx bun-pr 34868

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

bun-34868 --bun

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Error stack formatting now preserves materialized headers, reads name and message through property access, roots captured stack frames before materialization, and adds regression coverage for formatting, inheritance, coercion, mutation, and getter behavior.

Changes

Error stack formatting

Layer / File(s) Summary
Stack header and property semantics
src/jsc/bindings/FormatStackTraceForJS.cpp, src/jsc/bindings/ZigGlobalObject.h, src/js/internal/util/inspect.js, test/js/node/util/node-inspect-tests/parallel/*
Stack headers use centralized name and message property access with re-entrancy protection, while inspection and formatting tests require exact stack output and cover header preservation and getter behavior.
Captured stack frame rooting
src/jsc/bindings/FormatStackTraceForJS.cpp, test/js/node/v8/capture-stack-trace.test.js
Captured callees and code blocks are rooted before eager or lazy stack materialization, with overflow handling and a subprocess regression test for recursive lazy stack access.

Possibly related PRs

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: stack-header lookup via full [[Get]] and removal of the inspect.js workaround.
Description check ✅ Passed The description covers what changed and how it was verified, though it uses different section headings than the template.

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

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with USE_SYSTEM_BUN=1 bun test util-inspect.test.js -t "stack header" and capture-stack-trace.test.js -t "does not free". Passes with bun bd.

Self-review surfaced the sanitized-accessor gaps (deep-proto / accessor / non-primitive name), the prepareStackTrace hardcoded-name path, a re-entry cycle, and a heap-use-after-free when a name getter reads this.stack during materialization; all addressed through 4cfb8ae with a Malloc=1 ASAN regression test.

CI: util-inspect.test.js, util-format.test.js, and capture-stack-trace.test.js pass on every lane in builds 76596 and 76605. Remaining lane failures are unrelated flakes (bun-server.test.ts websocket idle CPU threshold on darwin, complex-workspace.test.ts install, es-module-lexer/grpc/solc timeouts on Windows, http2/undici on darwin); none touch error stack formatting or util.inspect. Ready for review.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Unsupported proper logging of AggregateError, Error.cause, modified/accessed Error.stack #1352 - Partially addresses the sub-problem where util.inspect/logging mangles a modified Error.stack header; the removed regex workaround was rewriting the first line even when the user had manually set .stack

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #1352

🤖 Generated with Claude Code

@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 — deletes a stale workaround that now only fires when it corrupts output, and restores the upstream Node assertion it had relaxed. The inline nit about the sibling //! temp bug workaround in util-format.test.js is cosmetic (that .replace() is now a no-op) and doesn't block.

Extended reasoning...

Overview

Two files touched: src/js/internal/util/inspect.js deletes a single stack.replace(/^Error: /, ...) line and its //! temp fix comment from formatError; test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js restores the upstream Node assertion util.inspect(err) === err.stack (previously relaxed with a matching workaround) and adds a new test block covering the corruption cases plus the two scenarios the workaround originally papered over.

Security risks

None. This is purely a change to how util.inspect renders an error's already-materialized .stack string — no parsing of untrusted input, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-medium. The runtime change is a two-line deletion that removes a Bun-specific divergence from Node's formatError, which per REVIEW.md is the reference implementation for node:* compat. I traced the remaining path: getStackStringremoveDuplicateErrorKeysimproveStack is now byte-for-byte the Node flow. I walked each new test case through improveStack (e.g. the err.name = "Renamed" case: name doesn't end with "Error" and constructor isn't null, so the stack passes through untouched and firstLine sees "Error: x") and they hold. The restored upstream loop assertion is strictly stronger than what it replaces.

Other factors

The PR description documents USE_SYSTEM_BUN=1 fails / bun bd passes for the new test, and that the surrounding inspect suites still pass. The one finding — a sibling //! temp bug workaround at util-format.test.js:436 — is a dead no-op (/^Error/ can't match a stack that now starts with CustomError), so it's stale-comment cleanup, not a behavioral gap. Worth sweeping in a follow-up commit but not a reason to hold the fix.

Comment thread test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js
robobun added 2 commits July 21, 2026 00:51
The sibling '//! temp bug workaround' in util-format.test.js papered over
the same native stack-header bug. With the header now correct,
customError.stack already begins with 'CustomError: bar', so the
anchored /^Error/ replace was a dead no-op.
sanitizedNameString walks only self+prototype with VMInquiry and rejects
non-primitive values, so a name set on an intermediate prototype, defined
as an accessor, or requiring ToString fell back to 'Error'. The removed
formatError workaround had been masking this in util.inspect by re-reading
err.name. computeErrorInfoWithoutPrepareStackTrace now reads name and
message via an ordinary [[Get]] with ToString, defaulting to 'Error' and
the empty string for undefined, matching V8's ErrorUtils::ToString.

The GC-finalizer path reaches this function with errorInstance == nullptr,
so the [[Get]] only runs from a mutator. Root the captured frames' cells
in errorConstructorFuncCaptureStackTrace's eager path before computing,
since reading name/message can now run a user getter.
@robobun robobun changed the title util: remove stack.replace workaround in formatError that now corrupts headers error: read name/message via full [[Get]] for the .stack header; drop inspect.js workaround Jul 21, 2026

@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 util-format.test.js sibling from my earlier comment is now addressed. Beyond the inline finding, I checked the other three paths that reach the new full-[[Get]] header read: errorInstanceLazyStackCustomGetter already has the same protectedFrameCells rooting; computeErrorInfoWrapperToJSValue operates on ErrorInstance::m_stackTrace (visited by ErrorInstance); and the GC-finalizer hook computeErrorInfoWrapperToString passes a null errorInstance so the new branch never runs there.

Extended reasoning...

This run's inline finding covers the one remaining unrooted eager-compute path in errorConstructorFuncCaptureStackTrace (the non-ErrorInstance else-branch). I audited every other caller that now reaches errorInstance->get(name/message) via computeErrorInfoWithoutPrepareStackTrace and confirmed each either already roots the frame cells or holds them via a GC-visited owner, so hoisting the MarkedArgumentBuffer above the dynamicDowncast is the only remaining gap. The earlier util-format.test.js cleanup I flagged has been applied. Deferring rather than approving because this is native JSC/GC-adjacent code with a still-open memory-safety asymmetry.

Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
robobun added 2 commits July 21, 2026 01:51
…branch

The non-ErrorInstance else-branch now also reads name/message via
[[Get]] before formatting, so it needs the same rooting. Hoist the
MarkedArgumentBuffer so both eager-compute paths share it.
The native .stack header now reads name via full [[Get]], so the BazError
get-name case and the Foo-extends-TypeError cases produce the upstream
Node output and the disabled assertion passes.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/FormatStackTraceForJS.cpp (1)

795-846: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Consider a memory-safety regression test for this rooting fix.

The rooting added here protects stackTrace's callee()/codeBlock() cells from GC while computeErrorInfoToJSValue runs user name/message getters (this is most reachable via the hasMaterializedErrorInfo() branch at lines 812-824, where stackTrace stays local and isn't otherwise reachable). The added tests cover header-preservation and [[Get]] value semantics, but none appear to force a GC from inside a name/message getter during Error.captureStackTrace on an already-materialized error to actually exercise the UAF this rooting prevents.
As per path instructions, **/*.{test,spec}.{js,jsx,ts,tsx}: "crash, UAF, leak, and issue reproductions require the corresponding spawned fixture or memory-safety regression test." Would you like me to draft a Bun.gc(true)-based regression test that triggers this path?

🤖 Prompt for 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.

In `@src/jsc/bindings/FormatStackTraceForJS.cpp` around lines 795 - 846, Add a
memory-safety regression test for the already-materialized ErrorInstance path in
FormatStackTraceForJS, using Error.captureStackTrace and a name/message getter
that forces GC via Bun.gc(true) while computeErrorInfoToJSValue is processing
stackTrace. Ensure the fixture exercises the protected callee/codeBlock cells
and verifies execution completes without a crash or UAF.

Source: Path instructions

🤖 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/jsc/bindings/FormatStackTraceForJS.cpp`:
- Around line 795-811: Extract the duplicated frame-cell rooting sequence into a
shared helper near the existing stack-trace formatting utilities, including
capacity reservation, callee/codeBlock iteration, overflow detection, and OOM
handling. Replace the inline logic in both errorInstanceLazyStackCustomGetter
and the shown eager-compute path with calls to that helper, preserving each
caller’s existing failure behavior and rooted buffer lifetime.

---

Outside diff comments:
In `@src/jsc/bindings/FormatStackTraceForJS.cpp`:
- Around line 795-846: Add a memory-safety regression test for the
already-materialized ErrorInstance path in FormatStackTraceForJS, using
Error.captureStackTrace and a name/message getter that forces GC via
Bun.gc(true) while computeErrorInfoToJSValue is processing stackTrace. Ensure
the fixture exercises the protected callee/codeBlock cells and verifies
execution completes without a crash or UAF.
🪄 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: bafc4826-1090-43f7-8b58-299548dd21fb

📥 Commits

Reviewing files that changed from the base of the PR and between 04708f7 and 498737e.

📒 Files selected for processing (4)
  • src/js/internal/util/inspect.js
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • test/js/node/util/node-inspect-tests/parallel/util-format.test.js
  • test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js
💤 Files with no reviewable changes (1)
  • src/js/internal/util/inspect.js

Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
robobun added 2 commits July 21, 2026 02:00
Both call sites (errorInstanceLazyStackCustomGetter and
errorConstructorFuncCaptureStackTrace) now go through one helper.
formatStackTraceToJSValue (the Error.prepareStackTrace default string)
still hardcoded 'Error' as the name, so removing the inspect.js workaround
regressed util.inspect when prepareStackTrace was set. Both paths now go
through one computeErrorHeader that does the full [[Get]].

A name/message getter that called Error.captureStackTrace(this) recursed
unboundedly because the already-materialized branch re-ran the [[Get]].
Guard with isComputingErrorStackHeader on Zig::GlobalObject; the inner
call falls back to the side-effect-free sanitized accessors so the cycle
terminates after one level, matching Node's single getter invocation.

Tests cover the prepareStackTrace path, the re-entry case, and
Error.captureStackTrace on a {name, message} literal.

@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/jsc/bindings/FormatStackTraceForJS.cpp:423-434 — The new errorInstance->get(name/message) can run a user getter that reads this.stack; when Error.captureStackTrace(e) previously installed the lazy CustomAccessor on an unmaterialized ErrorInstance, that reentrant read reaches errorInstanceLazyStackCustomGetter, whose setStackFrames(vm, {}) reassigns m_stackTrace and destructs the Vector<StackFrame> that the outer materializeErrorInfoIfNeeded call's stackTrace& still binds to — formatStackTrace at :442 then walks freed memory. Before this PR the header used sanitizedNameString/sanitizedMessageString (VMInquiry, no accessors), so no user JS ran between binding stackTrace& and formatting; DeferGCForAWhile and the MarkedArgumentBuffer rooting don't help because this is a C++ unique_ptr reassignment, not GC. One fix: have materializeErrorInfoIfNeeded move m_stackTrace into a local before invoking the callback (so the reentrant getter sees stackTrace() == nullptr), or copy stackTrace into a local Vector here before the [[Get]] calls.

    Extended reasoning...

    What the bug is

    computeErrorInfoWithoutPrepareStackTrace now composes the .stack header via full [[Get]] on name/message (FormatStackTraceForJS.cpp:423, :430). When this function is reached from ErrorInstance::materializeErrorInfoIfNeeded, its Vector<StackFrame>& stackTrace parameter is bound to *m_stackTrace.get() — the Vector owned by the ErrorInstance's unique_ptr<Vector<StackFrame>> m_stackTrace. A user name/message getter that reads this.stack can reenter errorInstanceLazyStackCustomGetter, which calls errorObject->setStackFrames(vm, {}). setStackFrames reassigns m_stackTrace = WTF::move(newUniquePtr), destructing the previous Vector<StackFrame> object. The outer stackTrace& now dangles, and Bun::formatStackTrace(..., stackTrace, errorInstance) at :442 reads .size() / .at(i) on freed memory.

    Step-by-step proof

    let n = 0;
    class T extends Error { get name() { if (n++ === 0) void this.stack; return "T"; } }
    const e = new T("m");
    Error.captureStackTrace(e);   // (0)
    e.stack;                      // (1) — UAF

    (0) errorConstructorFuncCaptureStackTrace: e is an ErrorInstance with hasMaterializedErrorInfo() == false, so it takes the lazy branch — instance->setStackFrames(vm, WTF::move(stackTrace)) populates m_stackTrace, then JSObject::deleteProperty(instance, ...) (a direct static call, so ErrorInstance::deleteProperty's materializeErrorInfoIfNeeded is not invoked and m_errorInfoMaterialized stays false), then putDirectCustomAccessor(stack, m_lazyStackCustomGetterSetter) installs the lazy getter as an own property.

    (1) e.stackErrorInstance::getOwnPropertySlot calls materializeErrorInfoIfNeeded(vm, "stack") first. That function (oven-sh/WebKit ErrorInstance.cpp, BUN_JSC_ADDITIONS branch) sees m_errorInfoMaterialized == false and m_stackTrace non-null/non-empty, so it sets m_errorInfoMaterialized = true before invoking the callback, then under DeferGCForAWhile calls fn(vm, *m_stackTrace.get(), line, column, sourceURL, this, m_bunErrorData). m_stackTrace is only nulled after fn returns.

    (2) fncomputeErrorInfoWrapperToJSValuecomputeErrorInfoToJSValuecomputeErrorInfoToJSValueWithoutSkippingcomputeErrorInfoWithoutPrepareStackTrace, all threading the same Vector<StackFrame>& bound to *m_stackTrace. At :423, errorInstance->get(lexicalGlobalObject, vm.propertyNames->name) walks to T.prototype and invokes the user getter with n == 0.

    (3) The getter reads this.stackErrorInstance::getOwnPropertySlotmaterializeErrorInfoIfNeeded now sees m_errorInfoMaterialized == true and returns false immediately → falls through to Base::getOwnPropertySlot, which finds the own CustomAccessor installed in step (0) (the outer putDirect(stack, ...) hasn't run yet — we're still inside fn) → invokes errorInstanceLazyStackCustomGetter.

    (4) errorInstanceLazyStackCustomGetter: errorObject->stackTrace() returns m_stackTrace.get(), which is still non-null. It does auto ownedStackTrace = makeUnique<Vector<StackFrame>>(WTF::move(*stackTrace)) (empties the outer Vector's contents), roots the frame cells, calls computeErrorInfoToJSValue (with n == 1 the getter returns "T" immediately, no further recursion), then errorObject->setStackFrames(vm, {}). ErrorInstance::setStackFrames does m_stackTrace = WTF::move(makeUnique<Vector<StackFrame>>({})) — the previous unique_ptr's Vector<StackFrame> object is destructed. The outer stackTrace& from step (2) now points to freed heap memory.

    (5) The getter returns "T"; back in the outer computeErrorInfoWithoutPrepareStackTrace, :430 reads message (own data property, no reentry), then :442 calls Bun::formatStackTrace(..., stackTrace, errorInstance). formatStackTrace immediately reads stackTrace.size() and iterates stackTrace.at(i) — use-after-free.

    Why nothing existing prevents it

    • DeferGCForAWhile in materializeErrorInfoIfNeeded guards against JSC GC; this is a plain C++ unique_ptr reassignment on the WTF heap.
    • The PR's MarkedArgumentBuffer rooting in errorConstructorFuncCaptureStackTrace (a) is on a stack frame that has already returned before e.stack is read, and (b) protects the frames' JSCell* against GC, not the WTF::Vector container against unique_ptr reassignment.
    • m_errorInfoMaterialized correctly gates a reentrant Error.captureStackTrace(this) (that would take the eager branch, which never touches m_stackTrace), but it does not gate the CustomAccessor that captureStackTrace's lazy branch installed before materialization — Base::getOwnPropertySlot finds it and invokes it regardless.
    • errorInstanceLazyStackCustomGetter's stackTrace == nullptr check doesn't help: m_stackTrace is only nulled after fn returns.

    Why this is new to the PR

    Before, this branch called instance->sanitizedNameString(lexicalGlobalObject) / sanitizedMessageString(...), which use a PropertySlot in VMInquiry mode — accessors are never invoked, so no user JS could run between binding stackTrace& and passing it to formatStackTrace. This PR replaces those with full [[Get]], opening the reentrancy window.

    Impact and fix

    Memory-safety UAF reachable from plain user JS (name accessor on an Error subclass + Error.captureStackTrace + .stack). Per REVIEW.md this is the most-blocked category: "Anything that can run user JS can synchronously free your state — re-validate liveness guards after every callback" / "Never let a pointer or slice outlive the memory it points into."

    Two viable fixes:

    1. WebKit side (preferred): in materializeErrorInfoIfNeeded, auto owned = WTF::move(m_stackTrace) before calling fn, and pass *owned. The reentrant errorInstanceLazyStackCustomGetter then sees stackTrace() == nullptr and takes the empty-trace branch; the outer reference stays valid for the callback's duration.
    2. Bun side: in computeErrorInfoWithoutPrepareStackTrace, when errorInstance is an ErrorInstance whose stackTrace() aliases the incoming stackTrace&, move-copy the frames into a local Vector<StackFrame> (with a local MarkedArgumentBuffer rooting them) before the [[Get]] calls, and pass the local to formatStackTrace.

    Either way, add an ASAN regression test exercising the repro above (per REVIEW.md: "UAF/leak fixes need an ASan repro on the unfixed build").

Comment thread test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/FormatStackTraceForJS.cpp`:
- Around line 49-52: Compress the comment immediately above the stack-formatting
logic to three lines or fewer while preserving its description of V8
name/message coercion, default values, and the re-entrant getter fallback that
terminates cycles after one level.
🪄 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: 5aa5320d-ce6b-4295-b58e-8e82cb42380a

📥 Commits

Reviewing files that changed from the base of the PR and between 498737e and ceb575b.

📒 Files selected for processing (3)
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
… header [[Get]]

A name/message getter that read this.stack after Error.captureStackTrace
had installed the lazy CustomAccessor reached
errorInstanceLazyStackCustomGetter while the outer materialize still held
a reference into *m_stackTrace; the inner move + setStackFrames({})
destructed that Vector and the outer formatStackTrace read freed memory
(ASAN heap-use-after-free under Malloc=1). Under isComputingErrorStackHeader
the inner call now formats from the existing Vector without moving or
reassigning it; computeErrorHeader already falls back to sanitized reads
under the same guard so no user code runs there.

Add a spawned Malloc=1 regression test in capture-stack-trace.test.js,
re-enable the second upstream [WOW] subclass-header assertion in
util-inspect.test.js, and trim two comments to three lines.
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

The UAF from a name getter that reads this.stack after Error.captureStackTrace installed the lazy accessor is addressed in 4cfb8ae: errorInstanceLazyStackCustomGetter now checks isComputingErrorStackHeader and, when re-entering from a header [[Get]], formats from the existing m_stackTrace without moving out of it or calling setStackFrames({}), so the outer reference stays valid. Reproduced the ASAN heap-use-after-free under Malloc=1 before the fix and it's clean after; a spawned Malloc=1 regression test is in capture-stack-trace.test.js.

Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp

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

No new issues found on 4cfb8ae; the four earlier inline findings are all addressed. Deferring to a human because this changes when arbitrary user JS can run during .stack materialization (full [[Get]] on name/message), with GC rooting, a per-global re-entry guard, and a UAF-avoidance branch in the lazy getter — memory-safety-critical enough to want maintainer eyes on the guard design and the materializeErrorInfoIfNeeded re-entry path.

What was reviewed:

  • computeErrorHeader: exception scopes after each get/toWTFString, undefined-defaulting matches V8 ErrorUtils::ToString, guard cleared via makeScopeExit on all paths.
  • Frame-cell rooting: protectStackFrameCells now covers both eager captureStackTrace branches and the lazy getter; the re-entry branch relies on ErrorInstance::visitChildren visiting m_stackTrace (not moved/reset there).
  • The lazy-getter re-entry branch does not move out of or reset m_stackTrace, so the outer materializeErrorInfoIfNeeded's Vector& stays valid; Malloc=1 regression test covers it.
  • Swept for remaining //! temp / Bun messes with markers in the inspect test suite — none left.
Extended reasoning...

Overview

The PR replaces the sanitized (own + one prototype level, VMInquiry, no accessors) name/message reads in the .stack header with V8-semantics full [[Get]] + ToString, shared between computeErrorInfoWithoutPrepareStackTrace and formatStackTraceToJSValue via a new computeErrorHeader. Because that now runs arbitrary user JS before the frames are formatted, it adds (a) protectStackFrameCells rooting via MarkedArgumentBuffer at both eager captureStackTrace paths and the existing lazy getter, (b) a per-Zig::GlobalObject isComputingErrorStackHeader re-entry guard that falls back to sanitized reads on the inner level, and (c) a re-entry branch in errorInstanceLazyStackCustomGetter that formats from *m_stackTrace in place without moving/resetting it, avoiding a heap-use-after-free when a name getter reads this.stack while the outer materializeErrorInfoIfNeeded still holds a Vector<StackFrame>& into the same storage. The now-correct native header lets the 2023 stack.replace(/^Error: /, ...) workaround in inspect.js be deleted and three previously-relaxed/disabled upstream Node assertions be restored verbatim.

Security risks

No auth/crypto/permissions surface. The user-facing risk is memory safety: opening a GC window (user getters, toString) between capturing frames and formatting them, and the m_stackTrace re-entry lifetime. Both are addressed with explicit rooting and the guard, and the ASAN Malloc=1 regression test in capture-stack-trace.test.js pins the UAF case. The re-entry branch's computeErrorInfoToJSValue can still reach prepareStackTrace (user code), but the frames it passes are *m_stackTrace, which ErrorInstance::visitChildren visits — so they remain rooted through the error object as long as nothing under the guard moves/resets m_stackTrace; I did not find a path that does.

Level of scrutiny

High. This is C++ in src/jsc/bindings/ that (1) newly runs user JS from a path that previously did not, (2) touches GC rooting and ErrorInstance lifetime, and (3) adds mutable state to ZigGlobalObject. Four earlier review rounds on this PR surfaced a real GC-rooting gap (non-ErrorInstance branch) and the UAF, both now fixed — which is exactly why a maintainer should confirm the final shape, particularly the per-global (not per-object) guard design that was acknowledged as an over-approximation and left for follow-up.

Other factors

Test coverage is thorough: two new targeted test blocks in util-inspect.test.js covering deep-prototype/accessor/non-primitive/undefined/null/throwing name and message plus re-entrant getters, three upstream Node assertions restored, and a spawned Malloc=1 ASAN regression for the UAF. All prior inline review threads (mine and CodeRabbit's) are resolved. The one remaining acknowledged limitation — the per-global guard over-approximates and downgrades an unrelated inner error's header to sanitized reads inside another error's getter — is not a regression (pre-PR every path was sanitized) and mirrors the sibling isInsideErrorPrepareStackTraceCallback design, but is worth a maintainer nod.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/FormatStackTraceForJS.cpp (1)

834-842: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make the lazy-path rooting comment match the actual operation.

The comment says rooting is a no-op for lazy capture, but protectStackFrameCells still allocates, appends roots, and can throw OOM before setStackFrames. Clarify that temporary protection is required until the frames are installed.

As per coding guidelines, comments should document durable, non-obvious safety invariants.

Proposed comment fix
-    // Both eager-compute paths below read name/message via [[Get]] before formatting,
-    // which may allocate or run a user getter. The lazy path moves the frames into the
-    // ErrorInstance, which visits them; rooting is a no-op there.
+    // Formatting may allocate or run a user getter. Keep frame cells rooted until
+    // lazy frames are installed on the ErrorInstance or eager formatting completes.
🤖 Prompt for 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.

In `@src/jsc/bindings/FormatStackTraceForJS.cpp` around lines 834 - 842, Update
the comment immediately above protectedFrameCells and protectStackFrameCells to
accurately state that temporary rooting remains necessary for the lazy path
until the frames are installed in ErrorInstance, and that protection may
allocate or throw OOM. Remove the claim that rooting is a no-op while preserving
the existing protection and error-handling logic.

Source: Coding guidelines

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

Outside diff comments:
In `@src/jsc/bindings/FormatStackTraceForJS.cpp`:
- Around line 834-842: Update the comment immediately above protectedFrameCells
and protectStackFrameCells to accurately state that temporary rooting remains
necessary for the lazy path until the frames are installed in ErrorInstance, and
that protection may allocate or throw OOM. Remove the claim that rooting is a
no-op while preserving the existing protection and error-handling logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 944f0f9a-68bb-48e6-9eb8-3fc89ab4b88e

📥 Commits

Reviewing files that changed from the base of the PR and between ceb575b and 1a36d38.

📒 Files selected for processing (3)
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js
  • test/js/node/v8/capture-stack-trace.test.js

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