Skip to content

Propagate exceptions from boxed primitive unwrapping in YAML/TOML/JSON5 stringify - #37040

Closed
robobun wants to merge 1 commit into
mainfrom
farm/fa170e6e/fix-yaml-unwrap-boxed-exception
Closed

Propagate exceptions from boxed primitive unwrapping in YAML/TOML/JSON5 stringify#37040
robobun wants to merge 1 commit into
mainfrom
farm/fa170e6e/fix-yaml-unwrap-boxed-exception

Conversation

@robobun

@robobun robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes a Fuzzilli-found abort (fingerprint daec39a4e7f75e61): Bun.YAML.stringify(new String()) called with a nearly exhausted stack tripped ExceptionScope::releaseAssertNoException with a pending "Maximum call stack size exceeded" exception.

Root cause

JSC__JSValue__unwrapBoxedPrimitive in bindings.cpp (used by the YAML, TOML, and JSON5 stringifiers through JSValue::unwrap_boxed_primitive) calls object->toNumber() / object->toString() on Number and String wrapper objects. Both can run user JavaScript (valueOf / toString / Symbol.toPrimitive) and can throw, including a stack overflow when the stack is nearly gone. The function ignored the exception and returned a non-empty encoded value, violating the zero-is-throw convention the Rust from_js_host_call wrapper validates. Debug builds abort on the exception scope assertion; release builds kept stringifying with an exception pending.

The WebKit helper this was copied from (unwrapBoxedPrimitive in JSONObject.cpp) relies on its callers doing RETURN_IF_EXCEPTION right after; the FFI version has to do that itself.

A stack overflow isn't needed to hit this. The deterministic repro used for the regression test:

const s = new String("x");
s.toString = () => { throw new Error("boom"); };
Bun.YAML.stringify(s); // aborted debug builds

Fix

Add a throw scope and return the empty value when toNumber() / toString() throws, so the Rust side propagates the error normally.

Testing

  • Original fuzzer script and the minimized repros now exit cleanly or throw the JS error on both the debug and fuzzilli builds.
  • New test in test/js/bun/yaml/yaml.test.ts covering throwing toString/valueOf on boxed primitives at the root and nested. The assertion only exists in debug builds (release already surfaced the error because the pending exception was noticed once the host function returned), so the test guards the regression by aborting the debug test runner if it comes back.
  • test/js/bun/yaml/, test/js/bun/toml/, test/js/bun/json5/ all pass with the debug build.

JSC__JSValue__unwrapBoxedPrimitive called toNumber()/toString() on
Number and String wrapper objects without checking for exceptions.
When the conversion threw (a user-defined valueOf/toString, or a stack
overflow near the stack limit), the function returned a non-empty value
with the exception still pending, violating the zero-is-throw FFI
convention and tripping the exception scope assertion in debug builds.

Found by Fuzzilli via Bun.YAML.stringify(new String()) with a nearly
exhausted stack.
@github-actions github-actions Bot added the claude label Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 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: f0d6016d-af65-478a-bf34-d102338d7464

📥 Commits

Reviewing files that changed from the base of the PR and between a32adeb and 08fbd30.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/yaml/yaml.test.ts

Walkthrough

JSC__JSValue__unwrapBoxedPrimitive now propagates exceptions from boxed Number and String conversions. YAML tests cover these exceptions for root values and nested object or array values.

Changes

YAML boxed primitive exception propagation

Layer / File(s) Summary
Propagate boxed conversion exceptions
src/jsc/bindings/bindings.cpp, test/js/bun/yaml/yaml.test.ts
JSC__JSValue__unwrapBoxedPrimitive uses a throw scope and returns an empty encoded value when Number or String conversion fails. YAML.stringify tests verify rethrown errors for root, object, and array values.

Possibly related PRs

  • oven-sh/bun#36857: Both changes address JavaScriptCore throw-scope exception propagation in different code paths.
  • oven-sh/bun#37025: This PR directly matches the boxed primitive unwrapping and regression test changes.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix and the affected YAML, TOML, and JSON5 stringifiers.
Description check ✅ Passed The description explains the root cause, fix, regression coverage, and verification results, although it does not use the template headings.
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.

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

@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #37025, which fixes the same missing exception check in JSC__JSValue__unwrapBoxedPrimitive with an identical code change and was opened first. That PR's repro reaches the bug through Symbol.toPrimitive, this one through overridden toString/valueOf and a near-exhausted stack, but both paths go through the same two conversion calls, so one fix and one test cover it.

@robobun robobun closed this Aug 6, 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.

LGTM — textbook exception-scope fix for a fuzzer-found abort.

What was reviewed:

  • Verified the Rust side wraps this in from_js_host_call, so returning {} on exception correctly propagates as JsError::Thrown; all call sites (YAML/TOML/JSON5 stringifiers, Space::init) already use ?.
  • Checked the remaining branches: BooleanObject/BigIntObject read internalValue() directly (no user JS, cannot throw) and the fallthrough just returns the input object — no missing exception checks.
  • Test asserts specific error messages and covers both wrapper types at root and nested positions.
Extended reasoning...

Overview

This PR fixes JSC__JSValue__unwrapBoxedPrimitive in src/jsc/bindings/bindings.cpp to declare a throw scope and RETURN_IF_EXCEPTION after object->toNumber() and object->toString(), which can invoke user-defined valueOf/toString/Symbol.toPrimitive and throw. Previously the function returned a non-empty encoded value with an exception pending, tripping releaseAssertNoException in debug builds (Fuzzilli fingerprint daec39a4e7f75e61). A regression test is added to test/js/bun/yaml/yaml.test.ts.

Security risks

None. This is purely error-propagation hygiene in a stringifier helper; no new inputs are accepted and no security-relevant surface is touched.

Level of scrutiny

Low-to-medium. The change is 8 lines of C++ following the exact idiom REVIEW.md mandates ("Every call that can throw or run user code needs RETURN_IF_EXCEPTION under a ThrowScope before its result is used"). I confirmed via grep that the Rust wrapper (JSValue::unwrap_boxed_primitivehost_fn::from_js_host_call) already interprets the empty return value as Err, and every one of the ~10 call sites in YAMLObject.rs, TOMLObject.rs, and JSON5Object.rs propagates with ?, so no caller needs updating. The unchanged BooleanObject/BigIntObject branch reads internalValue() on a JSWrapperObject — that's a direct field read, cannot enter user JS, so no scope check is needed there.

Other factors

The test is well-constructed: it exercises both throwing paths (String wrapper via toString, Number wrapper via valueOf), at both the root and inside a container, and asserts the specific thrown message rather than a bare toThrow(). It's placed alongside the existing boxed-primitive tests in the YAML suite. The PR description accurately explains why the WebKit original doesn't need this (its callers do the check) but the FFI export does. This is a small, self-contained, obviously-correct fix.

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