Skip to content

Copy the message string when constructing Error/AggregateError from a ZigString - #31451

Open
Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/error-message-copy
Open

Copy the message string when constructing Error/AggregateError from a ZigString#31451
Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/error-message-copy

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Zig::getErrorInstance builds the Error message with Zig::toString, which for an untagged ZigString wraps the caller's bytes via StringImpl::createWithoutCopying — no copy, no ownership, no liveness link. Several callers pass stack-local buffers (AsyncModule's resolve_error/download_error format the message into a local Vec<u8> that drops at function return), so the JSString backing error.message aliases freed memory by the time user code reads it — a long-standing latent use-after-free read on those error paths (it predates the Rust port; the Zig code freed the buffer with a defer the same way).

The TypeError/SyntaxError/RangeError helpers right next to it already use toStringCopy. This switches getErrorInstance and JSC__JSGlobalObject__createAggregateError (same lifetime requirement for the AggregateError message) to copy as well. Error paths only, so the extra copy is free in practice.

Found while auditing string ownership at the FFI boundary for the Windows cross-compile work; verified the module-resolution error path still produces intact messages with a debug build.

… ZigString

Zig::getErrorInstance built the Error message with Zig::toString, which
for an untagged ZigString wraps the caller's bytes via
StringImpl::createWithoutCopying — no copy, no ownership, no liveness
link. Several callers pass stack-local buffers (AsyncModule's
resolve_error/download_error format their message into a local Vec), so
the JSString backing error.message aliases freed memory by the time user
code reads it. The TypeError/SyntaxError/RangeError siblings already use
toStringCopy; do the same here and in
JSC__JSGlobalObject__createAggregateError, whose message has the same
lifetime requirement.
@robobun

robobun commented May 27, 2026

Copy link
Copy Markdown
Collaborator
Updated 11:07 PM PT - May 27th, 2026

@Jarred-Sumner, your commit ae94dddce664e8d2adda2892ddd53d522512cd7a passed in Build #58573! 🎉


🧪   To try this PR locally:

bunx bun-pr 31451

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

bun-31451 --bun

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9960597d-bfe6-4712-af87-fad9f60680a0

📥 Commits

Reviewing files that changed from the base of the PR and between 1872fb4 and ae94ddd.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/helpers.h

Walkthrough

Both error-message construction sites now choose between adopting an external Zig string or copying non-external Zig buffers into a new JS string, avoiding JS strings that alias short-lived caller-owned memory.

Changes

Error Message String Lifetime Fixes

Layer / File(s) Summary
Conditional adopt vs copy for error messages
src/jsc/bindings/helpers.h, src/jsc/bindings/bindings.cpp
getErrorInstance and JSC__JSGlobalObject__createAggregateError now call Zig::toString for externally-tagged Zig strings and Zig::toStringCopy for non-external inputs, with comments explaining ownership and lifetime differences.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: switching Error/AggregateError message construction to copy ZigStrings instead of aliasing caller's bytes.
Description check ✅ Passed The description covers the problem (use-after-free from stack-local buffers), solution (switch to toStringCopy for untagged strings), and validation approach, addressing both required template sections.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Comment thread src/jsc/bindings/bindings.cpp Outdated
Review feedback: switching getErrorInstance/createAggregateError to an
unconditional toStringCopy dropped the external-tag (mark_global)
ownership transfer, so callers that heap-allocate the message and rely
on the JS string adopting and freeing it (VirtualMachine.rs's joined
build-failure message for AggregateError) would leak it on every call.
Guard like toIdentifier does: external-tagged messages keep toString
(adopted as an ExternalStringImpl, freed via free_global_string);
everything else — including the untagged stack-local buffers that
motivated the change — is copied.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Good catch — fixed in the follow-up commit: both getErrorInstance and createAggregateError now guard like toIdentifier does (isTaggedExternalPtr(ptr) ? toString : toStringCopy), so external-tagged messages keep the ownership-transfer/adoption path (no leak for VirtualMachine.rs's marked-global AggregateError message) while untagged/UTF-8 messages — the ones that could alias freed stack buffers — are copied.

@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 — the follow-up commit applies the isTaggedExternalPtr ? toString : toStringCopy guard at both sites (same pattern as toIdentifier), so the external-tag ownership transfer is preserved and the original UAF on untagged stack buffers is fixed.

Extended reasoning...

Overview

Two one-line changes at the Rust↔C++ FFI boundary: getErrorInstance (helpers.h) and JSC__JSGlobalObject__createAggregateError (bindings.cpp) now build their message string via isTaggedExternalPtr(ptr) ? toString(...) : toStringCopy(...) instead of unconditionally calling toString. This copies untagged ZigString bytes (which previously produced a createWithoutCopying JSString aliasing caller-owned stack memory) while preserving the bit-62 external-tag adoption path that frees caller-transferred heap buffers via free_global_string.

Security risks

None introduced. The change removes a latent use-after-free read on error-message paths where callers pass stack-local buffers (e.g. AsyncModule's resolve/download error formatting). The external-tag branch is unchanged behavior versus the pre-PR baseline, so no new ownership hazard there.

Level of scrutiny

Moderate — this is memory-ownership code at an FFI boundary, but the final shape is a direct copy of the existing toIdentifier idiom three functions down in the same header, applied to error-path-only code. The first revision of this PR unconditionally used toStringCopy, which would have leaked the mark_global()'d AggregateError message from VirtualMachine.rs; I flagged that in the prior review and the author's follow-up commit (ae94ddd) applied the suggested guard verbatim. The current bug-hunting pass found no issues on the revised diff.

Other factors

  • The sibling getTypeErrorInstance/getSyntaxErrorInstance/getRangeErrorInstance helpers already use toStringCopy, so this brings getErrorInstance in line with established neighbors.
  • The added comments accurately document the tag semantics and reference the concrete callers that motivate each branch.
  • Error-path only; the extra copy has no meaningful cost.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Looked at the AsyncModule sites this mentions while triaging a report of the same pattern, two notes that may help when merging:

  • Confirmed both resolve_error and download_error in src/jsc/AsyncModule.rs still reach Zig::getErrorInstance through ZigString__toErrorInstance with an untagged ZigString over a local Vec<u8> (the with_encoding() call only tags non-ASCII text), so this change is what makes them correct.
  • They are not reachable right now: nothing populates ParseResult.pending_imports (the runtime linker skips import resolution and enqueue_dependency_to_root waits synchronously in sleep_until), so VirtualMachine.modules.enqueue never runs. That is why there is no user-visible repro for them. The same hazard is reachable from bun:ffi cc() (wrapper compile diagnostics), fixed at the call site in bun:ffi: copy the TinyCC diagnostic into the error cc() throws #38062; this PR covers every remaining caller of to_error_instance on a borrowed buffer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants