Skip to content

Fix missing exception check on Bun.plugin target coercion#31956

Closed
robobun wants to merge 1 commit into
mainfrom
farm/1a3e0125/bun-plugin-target-exception
Closed

Fix missing exception check on Bun.plugin target coercion#31956
robobun wants to merge 1 commit into
mainfrom
farm/1a3e0125/bun-plugin-target-exception

Conversation

@robobun

@robobun robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Fuzzing found a debug assertion failure (ExceptionScope::assertNoException, fingerprint 351df89ce3773a75) in Bun.plugin():

const v0 = {};
function f1() { return v0; }
f1.toString = f1;
Bun.plugin({ target: f1, setup: ArrayBuffer });

Root cause

setupBunPlugin in src/jsc/bindings/BunPlugin.cpp coerces the target option with toStringOrNull(), which can run user JS (toString/valueOf). When that coercion throws (here, OrdinaryToPrimitive fails with "No default value"), toStringOrNull() returns null, the validation block was skipped, and execution continued with the exception still pending: the builder object was constructed and setup() was invoked. Debug builds abort on the assertion when re-entering the VM; release builds incorrectly call setup() before the error finally surfaces.

Fix

Add the two missing RETURN_IF_EXCEPTION checks after toStringOrNull() and JSString::value() so the exception propagates to the caller as a normal catchable error. The other string coercions in this file already check exceptions correctly.

Test

Added handles a 'target' whose toString throws to test/js/bun/plugin/plugins.test.ts, next to the existing invalid-target test. It asserts the thrown error propagates and that setup is never invoked. On the unfixed build it fails with setup called once (release) or the assertion abort (debug); it passes with this fix.

setupBunPlugin converted the target option to a string without checking
for a pending exception. A target object whose toString throws left the
exception pending while setup() was still invoked, tripping
ExceptionScope::assertNoException in debug builds.
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:03 AM PT - Jun 7th, 2026

@robobun, your commit 0984a55 has 1 failures in Build #61271 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31956

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

bun-31956 --bun

@github-actions github-actions Bot added the claude label Jun 7, 2026
@coderabbitai

coderabbitai Bot commented Jun 7, 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: e72524ff-4766-4cb9-aa75-c0f173559837

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and 0984a55.

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

Walkthrough

This PR improves error handling in BunPlugin's target property parsing by adding explicit exception checks when converting the target value to a string, and adds a test case to verify that conversion errors are properly propagated.

Changes

Target toString() Error Handling

Layer / File(s) Summary
Target toString() error handling implementation and tests
src/jsc/bindings/BunPlugin.cpp, test/js/bun/plugin/plugins.test.ts
setupBunPlugin refactored to add explicit RETURN_IF_EXCEPTION checks after converting targetValue to a JS string. Test import updated to include jest, and a new test case added to verify that plugin() throws when target's toString() throws and does not invoke the setup callback.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: adding missing exception checks for Bun.plugin target coercion, which is the primary change across both modified files.
Description check ✅ Passed The description follows the required template with both 'What does this PR do?' and 'How did you verify your code works?' sections clearly addressed, including root cause, fix details, and test verification.
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.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Infer (1.2.0)
src/jsc/bindings/BunPlugin.cpp

In file included from src/jsc/bindings/BunPlugin.cpp:1:
In file included from src/jsc/bindings/BunPlugin.h:3:
src/jsc/bindings/root.h:42:10: fatal error: 'cmakeconfig.h' file not found
42 | #include "cmakeconfig.h"
| ^~~~~~~~~~~~~~~
1 error generated.
Aborting translation of method 'Zig::jsFunctionAppendOnLoadPluginBody' in file 'src/jsc/bindings/BunPlugin.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, characters 12-69
Called from Base__List.rev_filter_map.loop in file "src/list.ml", line 944, characters 13-17
Called f

... [truncated 2200 characters] ...

"src/clang/cFrontend_decl.ml" (inlined), line 54, characters 4-52
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.function_decl in file "src/clang/cFrontend_decl.ml", line 90, characters 12-151
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.translate_one_declaration in file "src/clang/cFrontend_decl.ml", line 453, characters 10-56
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.translate_one_declaration.translate in file "src/clang/cFrontend_decl.ml" (inlined), line 448, characters 24-96
Called from Stdlib__List.iter in file "list.ml", line 110, characters 12-15
Called from Stdlib__List.iter in file "list.ml", line 110, characters 12-15
Called from Stdlib__List.iter in file "list.ml" (inlined), line 110, characters 17-25
Called from Base__List


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

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(plugin): propagate exception from Bun.plugin target string coercion #31286 - Also fixes missing RETURN_IF_EXCEPTION checks in setupBunPlugin() for target string coercion in BunPlugin.cpp

🤖 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 — straightforward addition of missing RETURN_IF_EXCEPTION checks following the established JSC pattern, with a regression test.

Extended reasoning...

Overview

This PR adds two RETURN_IF_EXCEPTION checks in setupBunPlugin (src/jsc/bindings/BunPlugin.cpp) after toStringOrNull() and JSString::value(), plus a regression test in test/js/bun/plugin/plugins.test.ts. It fixes a fuzzer-found assertion failure where a throwing target coercion left a pending exception while execution continued into setup().

Security risks

None. This is a hardening change that makes exception propagation correct; it adds no new code paths, parsing, or external input handling.

Level of scrutiny

Low. The change is mechanical and matches the ubiquitous JSC exception-checking idiom already used throughout this file (and the rest of the bindings). The diff is tiny and the intent is unambiguous.

Other factors

  • The added test directly exercises the failing case (throwing toString on target) and asserts both that the error propagates and that setup is not invoked.
  • The one finding from review is a nit: the if (targetJSString) guard is now dead (always true) after the new RETURN_IF_EXCEPTION. This has zero runtime impact and the belt-and-suspenders shape already appears elsewhere in the codebase, so it doesn't block approval.
  • No prior reviewer comments to address.

Comment on lines +309 to +311
auto* targetJSString = targetValue.toStringOrNull(globalObject);
RETURN_IF_EXCEPTION(throwScope, {});
if (targetJSString) {

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: now that RETURN_IF_EXCEPTION follows toStringOrNull(), targetJSString can never be null on line 311 — toStringOrNull returns null iff an exception is pending (see the [[ZIG_EXPORT(null_is_throw)]] annotation at bindings.cpp:4697). Per CLAUDE.md ("guards a new validator makes redundant — a leftover null-check misleads readers into thinking the value can be absent"), drop the if (targetJSString) wrapper and unindent the body.

Extended reasoning...

What & why

JSC's JSValue::toStringOrNull(globalObject) is implemented as toString() followed by an internal RETURN_IF_EXCEPTION(scope, nullptr). toString() itself never returns null (it returns an empty JSString on failure), so the only way toStringOrNull yields nullptr is when an exception is pending. Bun's own FFI export documents this contract explicitly:

// src/jsc/bindings/bindings.cpp:4697
[[ZIG_EXPORT(null_is_throw)]] JSC::JSString* JSC__JSValue__toStringOrNull(...)

This PR adds RETURN_IF_EXCEPTION(throwScope, {}) immediately after the toStringOrNull call (line 310). Once that macro returns on a pending exception, control reaching line 311 implies no exception is pending, which in turn implies targetJSString != nullptr. The if (targetJSString) guard is therefore unreachable-false — dead code introduced by this very change.

Step-by-step proof

Consider the two possible outcomes of line 309:

  1. toString/@@toPrimitive throws (the fuzzer case this PR fixes). toStringOrNull returns nullptr and sets a pending exception. Line 310's RETURN_IF_EXCEPTION observes the exception and returns {}. Line 311 is never reached.
  2. Coercion succeeds. toString() returns a non-null JSString*; the internal scope sees no exception, so toStringOrNull forwards that non-null pointer. Line 310 sees no exception and falls through. Line 311 evaluates if (non-null) — always true.

There is no third path where line 311 is reached with targetJSString == nullptr.

Why existing code doesn't justify keeping it

One reviewer might note that NodeModuleModule.cpp:220-224 has the same belt-and-suspenders shape (toStringOrNullRETURN_IF_EXCEPTIONif (!code)). However, the codebase is not uniform here — JSBuffer.cpp:279-281 and ErrorCode.cpp:376-378 both dereference the result directly after RETURN_IF_EXCEPTION with no null guard:

auto arg_ = arg.toStringOrNull(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
const auto& view = arg_->view(lexicalGlobalObject);  // no null check

So "prevailing convention" cuts both ways. The tiebreaker is the project's own contributor guidance — CLAUDE.md:323 is unambiguous and calls out this exact scenario:

Delete dead code in the same PR that makes it dead (these deletions are required scope, not drive-bys) … guards a new validator makes redundant (a leftover null-check misleads readers into thinking the value can be absent)

This PR added the validator (RETURN_IF_EXCEPTION); per the documented rule, removing the now-redundant guard is in-scope for the same PR rather than a drive-by style preference.

Impact

None at runtime — the branch is always taken. The cost is purely readability: a future reader seeing if (targetJSString) after the exception check will reasonably (but wrongly) infer there's a non-exception path where toStringOrNull returns null, and may cargo-cult that defensive shape elsewhere.

Fix

auto* targetJSString = targetValue.toStringOrNull(globalObject);
RETURN_IF_EXCEPTION(throwScope, {});
String targetString = targetJSString->value(globalObject);
RETURN_IF_EXCEPTION(throwScope, {});
if (!(targetString == "node"_s || targetString == "bun"_s || targetString == "browser"_s)) {
    JSC::throwTypeError(globalObject, throwScope, "plugin target must be one of 'node', 'bun' or 'browser'"_s);
    return {};
}

(Equivalently, switch to targetValue.toString(globalObject) — which never returns null — and keep the RETURN_IF_EXCEPTION; either form removes the dead branch.)

@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #31286, which fixes the same missing exception check in setupBunPlugin and already went through review there (Jarred's suggestion to use toString() is applied, which also avoids the dead null check flagged above). I ported the stronger test assertion from this PR to #31286 (asserting setup is never invoked), so nothing from here is lost.

@robobun robobun closed this Jun 7, 2026

@ZhengSJCode ZhengSJCode left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good

Commented in CodeRabbit Change Stack

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