Skip to content

Don't leak scope objects through host functions' raw this - #32172

Closed
robobun wants to merge 1 commit into
mainfrom
farm/15503056/fix-scope-this-leak
Closed

Don't leak scope objects through host functions' raw this#32172
robobun wants to merge 1 commit into
mainfrom
farm/15503056/fix-scope-this-leak

Conversation

@robobun

@robobun robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes a fuzzer-found segfault (Fuzzilli fingerprint 07bc0d3cfacec7ff, SIGSEGV in baseline JIT code).

Root cause

When a bare call f() resolves its callee through a closure scope (i.e. f is a captured variable living in the activation), JSC passes the scope object (JSLexicalEnvironment) as the raw this value and relies on the callee to sanitize it. JS functions do that via to_this in their prologue; host functions see the raw value through callFrame->thisValue() and must call JSValue::toThis themselves.

Several Bun host functions returned or stored that raw value, leaking the activation object into user JavaScript:

function outer() {
  const fn = jest.fn();
  fn.mockReturnThis();
  function capture() { return fn; }   // forces `fn` into the activation
  const leaked = fn();                // JSLexicalEnvironment, before this fix
}

The crash chain in the fuzzer input: mockReturnThis returned the activation, the script then read a property of it that corresponded to a not-yet-initialized let (a TDZ slot). JSLexicalEnvironment::getOwnPropertySlot returns the raw slot contents, so the read produced an empty JSValue inside user JS. A JIT-compiled typeof check on that value then dereferenced offset 5 of a null cell (empty passes the is-cell tag check) and segfaulted. In the ASAN build UBSAN reports member call on null pointer of type 'JSC::JSCell'.

Fix

Apply toThis with strict semantics (identity for every value except scope objects, which become undefined, so behavior for all normal calls is unchanged) at every place a raw this escaped:

  • jsMockFunctionCall (the crashing one, line 844 of JSMockFunction.cpp): covers mock.contexts, mockReturnThis, and the this forwarded to mock implementations
  • jest.setSystemTime (C++) and the Rust fake timer methods (useFakeTimers, useRealTimers, advanceTimersByTime, etc. in FakeTimers.rs), via a new JSC__JSValue__toThisStrict binding exposed as JSValue::to_this_strict
  • Bun.plugin builder onLoad/onResolve/module chaining returns
  • StringDecoder called without new: it wrote encoding onto the activation object and returned it; this also fixes a debug-build assert (asObject on a non-cell) when this is undefined

The dead C++ JSMock__jsUseRealTimers that originally returned the raw this was removed on main independently while this PR was open; the live implementation is the Rust one in FakeTimers.rs, which is fixed here.

This same root cause also explains a second Fuzzilli crash, fingerprint 284a2ab00e963a39 (the jsMockFunctionCall hunk covers it), so no separate PR is needed for that one.

Rebased four times while open. First onto the WebKit upgrade (#33133): JSMock__jsSetSystemTime had been rewritten on main (the overridenDateNow "no override" sentinel changed from -1 to NaN), resolved by taking main's logic and applying toThis to its return; plus an EOF test-append in test-timers.test.ts, resolved by keeping both. Second onto jest.resetAllMocks() (#33374) and runtime onResolve (#33409): sources auto-merged, one EOF test-append conflict in plugins.test.ts, resolved by keeping all three tests. Third onto advanceTimersByTime/setSystemTime (#33623): JSMock__jsSetSystemTime reworked to a single exit routing through Bun__FakeTimers__setSystemTime, resolved by taking main's body with toThis on that return; FakeTimers.rs auto-merged with all seven to_this_strict sites intact. Fourth onto the JSC C API removal (#33731), string_decoder lastTotal fix (#33703), and real-clock-under-fake-timers (#33896): sources auto-merged (the JSValue.rs reorg landed around to_this_strict, which survived; all seven FakeTimers.rs sites intact), one EOF test-append conflict in test-timers.test.ts, resolved by keeping both.

Audited the remaining thisValue() uses in src/jsc/bindings and host functions in src/runtime: the rest are type-checked downcasts (a scope object fails the cast and throws) or host_fn(method) shims that validate this before the body runs. The NAPI and V8 shim layers pass the raw this to native addons; that is a separate surface with its own compatibility questions and is not changed here.

Tests

Regression tests in mock-fn.test.js, test-timers.test.ts, plugins.test.ts, and string-decoder.test.js. Each fails on bun without this change (USE_SYSTEM_BUN=1) and passes with it. The original fuzzer input now exits with an ordinary JS error instead of crashing.


[review] gate passed · iteration 11 · 10 files touched

fails on main (without fix)
ASAN without fix: 4 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts test/js/bun/test/mock-fn.test.js test/js/bun/test/test-timers.test.ts test/js/node/string_decoder/string-decoder.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (58767ddf6)

test/js/bun/test/mock-fn.test.js:
(pass) mock() > exists as jest.fn, bunTest.mock, and vi.fn [1.87ms]
(pass) mock() > mock [7.26ms]
(pass) mock() > checks the this value > mock [7.02ms]
(pass) mock() > checks the this value > _protoImpl [0.81ms]
(pass) mock() > checks the this value > getMockImplementation [1.47ms]
(pass) mock() > checks the this value > getMockName [0.88ms]
(pass) mock() > checks the this value > mockClear [0.73ms]
(pass) mock() > checks the this value > mockReset [0.71ms]
(pass) mock() > checks the this value > mockRestore [0.72ms]
(pass) mock() > checks the this value > mockImpleme
... (truncated)

release without fix: 21 failed, 1 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/test/mock-fn.test.js:
(pass) mock() > exists as jest.fn, bunTest.mock, and vi.fn [0.03ms]
(pass) mock() > mock [0.16ms]
(pass) mock() > checks the this value > mock [0.12ms]
(pass) mock() > checks the this value > _protoImpl
(pass) mock() > checks the this value > getMockImplementation [0.04ms]
(pass) mock() > checks the this value > getMockName
(pass) mock() > checks the this value > mockClear
(pass) mock() > checks the this value > mockReset
(pass) mock() > checks the this value > mockRestore
(pass) mock() > checks the this value > mockImplementation
(pass) mock() > checks the this value > mockImplementationOnce [0.01ms]
(pass) mock() > checks the this value > withImplementation
(pass) mock() > checks the this value > mockName
(pass) mock() > checks the this value > mockReturnThis
(pass) mock() > checks the this value > mockReturnValue
(pass) mock() > checks the this value > mockReturnValueOnce
(pass) mock() > checks the this value > mockResolvedValue
(pass) mock() > checks the this value > mockResolvedValueOnce
(pass) mock() > checks the this value > mockRejectedValue
(pass) mock() > checks the this value > mockRe
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts test/js/bun/test/mock-fn.test.js test/js/bun/test/test-timers.test.ts test/js/node/string_decoder/string-decoder.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (58767ddf6)

test/js/bun/test/mock-fn.test.js:
(pass) mock() > exists as jest.fn, bunTest.mock, and vi.fn [1.78ms]
(pass) mock() > mock [6.85ms]
(pass) mock() > checks the this value > mock [6.87ms]
(pass) mock() > checks the this value > _protoImpl [0.80ms]
(pass) mock() > checks the this value > getMockImplementation [1.43ms]
(pass) mock() > checks the this value > getMockName [0.87ms]
(pass) mock() > checks the this value > mockClear [0.69ms]
(pass) mock() > checks the this value > mockReset [0.73ms]
(pass) mock() > checks the this value > mockRestore [0.70ms]
(pass) mock() > checks the this value > mockImpleme
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     58767ddf62
  features     (none)

22 deps, 105 codegen, 1168 objects in 838ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [12.00ms]
[2/1231] gen ErrorCode+*.h
[3/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [12.00ms]
[5/1231] gen bindgenv2
[6/1231] fetch tinycc
[tinycc] up to date
[7/1230] gen .bind.ts → GeneratedBindings.cpp
[8/1230] fetch zlib
[zli
... (truncated)
diff hotspot
src/jsc/JSValue.rs                                 |  9 +++++++++
 src/jsc/bindings/BunPlugin.cpp                     |  6 +++---
 src/jsc/bindings/JSMockFunction.cpp                |  6 ++++--
 src/jsc/bindings/JSStringDecoder.cpp               |  8 ++++----
 src/jsc/bindings/bindings.cpp                      |  5 +++++
 src/runtime/test_runner/timers/FakeTimers.rs       | 14 ++++++-------
 test/js/bun/plugin/plugins.test.ts                 | 23 ++++++++++++++++++++++
 test/js/bun/test/mock-fn.test.js                   | 16 +++++++++++++++
 test/js/bun/test/test-timers.test.ts               | 23 ++++++++++++++++++++++
 test/js/node/string_decoder/string-decoder.test.js | 16 +++++++++++++++
 10 files changed, 110 insertions(+), 16 deletions(-)

gate history · 1 passed · 0 rejected · iteration 11

evidence per changed file
file                                                reads  edits  tests
src/jsc/JSValue.rs                                      1      2      9
src/jsc/bindings/BunPlugin.cpp                          0      0      9
src/jsc/bindings/JSMockFunction.cpp                     4      6      9
src/jsc/bindings/JSStringDecoder.cpp                    1      1      9
src/jsc/bindings/bindings.cpp                           1      2      9
src/runtime/test_runner/timers/FakeTimers.rs            0      0      9
test/js/bun/plugin/plugins.test.ts                      2      6      3
test/js/bun/test/mock-fn.test.js                        1      2      3
test/js/bun/test/test-timers.test.ts                    2      3      6
test/js/node/string_decoder/string-decoder.test.js      1      0      3

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR implements strict-mode this conversion across the Bun runtime to prevent internal scope objects from leaking when host functions are captured and invoked as bare calls. It adds a new to_this_strict Rust wrapper and C++ binding, then systematically applies this conversion to plugin builders, mock functions, timer control functions, and the string decoder constructor. Tests validate that bare calls to these functions return undefined rather than exposing internal chaining or activation objects.

Changes

Strict this Conversion Across Host Functions

Layer / File(s) Summary
Core strict this binding infrastructure
src/jsc/bindings/bindings.cpp, src/jsc/JSValue.rs
Adds JSC__JSValue__toThisStrict C++ binding to convert values using strict ECMAMode, and corresponding JSValue::to_this_strict Rust wrapper method.
Plugin builder method this sanitization
src/jsc/bindings/BunPlugin.cpp, test/js/bun/plugin/plugins.test.ts
Plugin append functions (onLoad, onResolve, module) return strict-mode-converted this values; test verifies bare calls return undefined.
Mock function this conversion and cleanup
src/jsc/bindings/JSMockFunction.cpp, test/js/bun/test/mock-fn.test.js
Mock function calls store and return strict-mode-converted this in both jsMockFunctionCall and jsSetSystemTime; removes unused jsUseRealTimers; test confirms mockReturnThis and this-returning mocks produce undefined on bare calls.
Fake timer control function this sanitization
src/runtime/test_runner/timers/FakeTimers.rs, test/js/bun/test/test-timers.test.ts
All timer control functions return strict-mode-converted this values; test validates captured timer methods return undefined when called as bare functions.
StringDecoder constructor this and newTarget handling
src/jsc/bindings/JSStringDecoder.cpp, test/js/node/string_decoder/string-decoder.test.js
Constructor separates newTarget() into dedicated variable, computes thisValue using strict toThis, and derives hack-branch thisObject from strict thisValue; test validates function-style calls return functional decoder instances.

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 matches the main change: sanitizing raw host-function this values to avoid leaking scope objects.
Description check ✅ Passed The description covers the change and verification, including root cause, fix, and tests, even though it uses different headings than the template.

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

@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 `@test/js/bun/test/mock-fn.test.js`:
- Around line 489-504: The test never exercises the closure-captured "bare call"
path because capture() is not invoked; instead local aliases are called
directly. Fix by calling the functions returned from capture() (e.g., const
[returnsThis, impl] = capture(); or const tuple = capture(); then invoke
tuple[0]() and tuple[1]()) and then assert on their return values and
mock.contexts so the activation-object path for returnsThis and impl is actually
exercised.
🪄 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: 2eeba76e-53cf-44b3-9da4-d706c0c63b8e

📥 Commits

Reviewing files that changed from the base of the PR and between 885c44f and 6c1c440.

📒 Files selected for processing (10)
  • src/jsc/JSValue.rs
  • src/jsc/bindings/BunPlugin.cpp
  • src/jsc/bindings/JSMockFunction.cpp
  • src/jsc/bindings/JSStringDecoder.cpp
  • src/jsc/bindings/bindings.cpp
  • src/runtime/test_runner/timers/FakeTimers.rs
  • test/js/bun/plugin/plugins.test.ts
  • test/js/bun/test/mock-fn.test.js
  • test/js/bun/test/test-timers.test.ts
  • test/js/node/string_decoder/string-decoder.test.js

Comment thread test/js/bun/test/mock-fn.test.js
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Normalize scope-object this in mock function calls to prevent leak/crash #31605 - Also normalizes scope-object this in jsMockFunctionCall via toThis(ECMAMode::strict()) to prevent JSLexicalEnvironment leaking through host functions; Don't leak scope objects through host functions' raw this #32172 is a broader sweep of the same fix across additional call sites

🤖 Generated with Claude Code

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closed #31605 in favor of this PR; it contained only the jsMockFunctionCall hunk, which is included here unchanged.

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

I didn't find any issues, but this touches the JSC bindings layer across C++ and Rust (including a control-flow refactor in JSStringDecoder::construct and a new FFI binding), so it's worth a human look.

Extended reasoning...

Overview

This PR fixes a fuzzer-found segfault by sanitizing raw this values in host functions before they escape to user JS. When a bare call resolves through a closure scope, JSC passes the JSLexicalEnvironment as the raw this; several Bun host functions returned or stored that value verbatim, leaking the activation object and ultimately allowing an empty TDZ slot to reach JIT code as a null cell.

The fix applies JSValue::toThis(..., ECMAMode::strict()) (identity for everything except scope objects, which become undefined) at each escape point: jsMockFunctionCall, the jest fake-timer methods (C++ setSystemTime and seven Rust host fns), the Bun.plugin builder chaining returns, and StringDecoder's construct path. It adds a new JSC__JSValue__toThisStrict FFI binding exposed as JSValue::to_this_strict in Rust, and deletes the dead C++ JSMock__jsUseRealTimers (verified: no remaining references). Four test files gain regression tests.

Security risks

The bug being fixed is itself security-adjacent: leaking a JSLexicalEnvironment into user JS lets scripts read raw TDZ slots (empty JSValues), which the JIT then dereferences as a null cell. The fix narrows the attack surface. The change uses strict-mode toThis, which is identity for all ordinary values, so normal call paths (obj.method(), fn.call(x)) are unchanged. I don't see new risks introduced — but engine-bindings changes are exactly where subtle mistakes have outsized consequences.

Level of scrutiny

This warrants careful human review. While most hunks are a mechanical .toThis(...) suffix, the PR also:

  • Refactors JSStringDecoder::construct: the old code did asObject(callFrame->newTarget()) (asserting on undefined when called without new) and compared JSObject* != JSObject*; the new code keeps newTarget as a JSValue and compares JSValue != JSObject*. The express body-parser hack path is preserved (regular-object this still gets the private decode/encoding properties), but the comparison semantics changed and deserve a second pair of eyes.
  • Adds a new cross-language FFI binding marked [[ZIG_EXPORT(nothrow)]] / safe fn — the toThis strict path is documented as non-throwing, which matches JSC's JSScope::toThis returning jsUndefined(), but it's a new ABI surface.
  • Claims an audit of remaining thisValue() uses in src/jsc/bindings and src/runtime — a substantive completeness claim worth spot-checking.

Other factors

  • No CODEOWNERS cover the touched files.
  • Each fix site has a regression test that the description says fails on current bun and passes with the change.
  • I verified JSMock__jsUseRealTimers has no remaining references, so its removal is safe.
  • The bug-hunting system found nothing; I also found nothing. The change looks correct to me, but it's core runtime/engine-bindings code rather than a trivial config or doc change.

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

I didn't find any issues, but this touches the JSC host-function this handling across several subsystems (mock call path, plugin builder, fake timers, and a non-trivial refactor of the StringDecoder constructor's newTarget/thisValue logic), so it's worth a human pass.

Extended reasoning...

Overview

This PR fixes a fuzzer-found segfault by sanitizing the raw this value that JSC passes to host functions when the callee is resolved through a closure scope (a JSLexicalEnvironment activation object). It adds a new JSC__JSValue__toThisStrict C++ binding plus a Rust JSValue::to_this_strict wrapper, then applies toThis(..., ECMAMode::strict()) at every site where a callframe's raw this was previously returned or stored into user-visible JS: jsMockFunctionCall (mock.contexts / mockReturnThis / forwarded impl this), JSMock__jsSetSystemTime, the seven Rust fake-timer host functions, the three Bun.plugin builder chaining returns, and the StringDecoder constructor's express/body-parser hack branch. It also deletes the dead C++ JSMock__jsUseRealTimers (the live impl is the Rust one, which is patched here). Four regression tests are added, each constructed so the binding lives in the activation and the bare call goes through the scope-resolve path.

Security risks

The bug being fixed is itself the security-relevant part: leaking a JSLexicalEnvironment into user JS exposes raw TDZ slots (empty JSValue) and led to a null-cell deref in JIT code. The fix is strictly a narrowing — strict-mode toThis is identity for every value except scope objects, which become undefined — so normal method calls (jest.useFakeTimers(), builder.onLoad(...)) are unchanged and the new test asserts that. I don't see new injection, auth, or data-exposure surface introduced. The PR explicitly leaves the NAPI / V8-shim raw-this surface unchanged, calling it a separate compatibility question; that scoping decision is reasonable but worth a maintainer's nod.

Level of scrutiny

Medium-high. The per-call-site pattern is mechanical, but this is the core JSC host-function boundary and the changes span C++ and Rust bindings. The JSStringDecoder::construct hunk is the least mechanical: it renames thisValuenewTarget, drops the asObject(newTarget) that asserted in debug when called without new, recomputes thisValue via toThis, and re-derives thisObject from the sanitized value — a small refactor of constructor semantics that deserves a careful look even though it tests green. The dead-code removal of JSMock__jsUseRealTimers also warrants confirmation that nothing references it.

Other factors

The bug hunter found nothing. CodeRabbit's only inline comment (about the capture() test pattern) was withdrawn after the author demonstrated empirically that the test fails on the unfixed build. CI shows two failures (terminal.test.ts on macOS x64, init.test.ts on macOS aarch64) that look unrelated to this diff. None of the touched paths are CODEOWNER-gated. Given the breadth across the bindings layer and the constructor-path refactor, deferring to a human reviewer.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for head 58767ddf (build 71489, final: 270 passed / 16 failed): every one of the 16 red shards failed on the same single test, test/js/web/fetch/fetch-gzip.test.ts:521 (Expected: "InvalidHTTPResponse", Received: "ConnectionRefused"). That test was introduced on main by #33710 (commit eead2f6d41, merged 2026-07-07) and is in my tree only because I rebased onto it. The same failure is on recent builds of at least seven other unrelated branches (71488, 71485, 71478, 71477, 71454, 71453, 71450), and #33916 is already open to fix it. This PR touches no fetch, gzip, or redirect code.

I sampled four failed shards (darwin-26-aarch64, debian-13-x64, alpine-3.23-aarch64, windows-11-aarch64). Each has exactly one ::error file (fetch-gzip.test.ts), and my regression suites pass on every shard that scheduled them, including on darwin-26-aarch64 which ran tests for the first time in four builds:

shard mock-fn plugins string-decoder
darwin-26-aarch64 77 pass, 0 fail 35 pass, 0 fail 96 pass, 0 fail
debian-13-x64 (other shard) (other shard) 96 pass, 0 fail
alpine-3.23-aarch64 (other shard) (other shard) 96 pass, 0 fail

For the previous head fe2f52d9 (build 69780, final): 285 jobs passed, 0 test failures. The only red was the darwin-26-aarch64 agent's artifact-download timeout (now recovered, as above).

Locally against the rebased build the four regression suites are green: mock-fn 77, test-timers 5, string-decoder 96, plugins 35, 0 failing, including every test main added in #33133, #33374, #33409, #33623, #33703, #33731, and #33896. The original Fuzzilli input exits with an ordinary JS error across repeated runs instead of segfaulting.

Earlier heads, each a single unrelated or infra failure: build 68949 (Buildkite agent incident affecting every build in its window plus the autobahn Docker arch mismatch, zero real (fail) lines), 67524 (the same darwin-26 artifact timeout, tree-wide test-net-connect-memleak.js, and a v8-heap-snapshot SIGKILL also seen elsewhere), 62063 (my one retrigger; fetch-abort-slow-connect.test.ts race also on several other PRs), 62055 (terminal.test.ts PTY timeout also on #32110, #32129, #29136).

My single retrigger is spent. Once #33916 lands on main I can rebase again; until then every PR based on current main will see the same red.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Another Fuzzilli crash reduces to the root cause fixed by the jsMockFunctionCall hunk in this PR (fingerprint 284a2ab00e963a39). The fuzzer input called a captured jest.fn() with mockReturnThis() as a bare call, so the raw this was the JSLexicalEnvironment, then read a property of the leaked activation that was still in TDZ. That hands user JS an empty JSValue, and typeof on it is a member call on a null JSC::JSCell (UBSAN report in the ASAN build, segfault in release).

Deterministic repro on current main (00a93bd), no bun test needed:

function outer() {
  const fn = Bun.jest().mock(); // jest.fn()
  fn.mockReturnThis();
  const leaked = fn(); // should be undefined, is the activation object
  const got = leaked.tdz; // empty JSValue read out of the TDZ slot
  console.log(typeof got); // null JSCell deref
  const tdz = 123;
  function capture() {
    return [fn, tdz];
  }
}
outer();

Not opening a separate PR for this fingerprint since the toThis change here covers it. The branch currently conflicts with main in JSMockFunction.cpp and test/js/bun/test/test-timers.test.ts after the WebKit upgrade in #33133 landed, so it needs a rebase before it can merge.

@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 issues found, but this touches JSC bindings across five subsystems (mock, fake timers, plugins, StringDecoder) plus a new Rust↔C++ FFI export and a subtle newTarget/thisValue refactor in JSStringDecoderConstructor::construct, so it's worth a human pass.

Extended reasoning...

Overview

Fixes a fuzzer-found segfault where JSLexicalEnvironment scope objects leaked to user JS through host functions' raw callFrame->thisValue(). Applies toThis(…, ECMAMode::strict()) at every escape point: jsMockFunctionCall, JSMock__jsSetSystemTime, the seven Rust fake-timer host functions (via a new JSC__JSValue__toThisStrict binding + JSValue::to_this_strict wrapper), the three Bun.plugin builder returns, and JSStringDecoderConstructor::construct. Also deletes a dead JSMock__jsUseRealTimers C++ function. 10 files changed across src/jsc/bindings/, src/jsc/JSValue.rs, src/runtime/test_runner/timers/, and four test files.

Security risks

The change reduces risk — it closes an internal-object leak that let user JS read empty TDZ slots and crash via null-cell deref. toThis in strict mode is identity for every value except scope objects (→ undefined), so normal calls are unchanged. The new [[ZIG_EXPORT(nothrow)]] binding is a thin wrapper over JSC's existing toThis. No new attack surface introduced.

Level of scrutiny

High. This is JSC binding code on the crash/memory-safety boundary. The JSStringDecoder hunk is more than a one-liner: it renames thisValuenewTarget, drops an asObject(newTarget) that asserted in debug when called without new, and rebases the express-body-parser "hack" branch condition on the sanitized thisValue instead of the raw one. That's correct as far as I can tell, but it changes control flow in a Node-compat quirk path and deserves a maintainer's eyes.

Other factors

Tests are thorough and empirically verified to fail on the unfixed build (the CodeRabbit thread confirms the capture() pattern is load-bearing). CI is green modulo unrelated darwin flakes per robobun. The PR description explicitly audits remaining thisValue() sites and scopes out NAPI/V8 shims. A second Fuzzilli fingerprint was noted as covered by the same fix. All of this points to a well-executed change — just not one I'd auto-approve given the breadth across critical binding code.

@robobun
robobun force-pushed the farm/15503056/fix-scope-this-leak branch from 8753b85 to 6076ef9 Compare July 6, 2026 12:29

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

I didn't find any bugs, but this is a crash fix that touches JSC bindings across C++ and Rust (including a new FFI export and a reworked JSStringDecoder constructor path), so it's worth a human look before merging.

Extended reasoning...

Overview

This PR fixes a fuzzer-found segfault where JSLexicalEnvironment scope objects leak into user JS through host functions that return/store the raw callFrame->thisValue(). It applies toThis(..., ECMAMode::strict()) at ~12 sites across four C++ bindings files (JSMockFunction.cpp, BunPlugin.cpp, JSStringDecoder.cpp, bindings.cpp), adds a new JSC__JSValue__toThisStrict FFI export with a Rust wrapper in JSValue.rs, and updates seven Rust host functions in FakeTimers.rs. Four test files gain regression coverage.

Security risks

The bug being fixed is itself security-relevant: leaking an activation object lets user JS read TDZ slots as empty JSValues, which the JIT then dereferences as a null cell (segfault; UBSAN "member call on null pointer"). The fix is defensive and strict-mode toThis is identity for everything except scope objects, so behavioral risk to normal callers is low. No new attack surface is introduced, but the correctness of the JSStringDecoder constructor rework (separating newTarget from the sanitized thisValue and re-gating the express body-parser hack path) deserves a second pair of eyes.

Level of scrutiny

Moderate-to-high. While most hunks are mechanical one-liners, the change spans C++/Rust FFI, adds a new exported binding, non-trivially refactors a constructor's newTarget/this handling, and went through a rebase with two hand-resolved conflicts (JSMock__jsSetSystemTime and test-timers.test.ts). Crash fixes in JSC binding code are exactly the category CLAUDE.md flags for careful review.

Other factors

The PR has thorough regression tests that the author verified fail on USE_SYSTEM_BUN=1 and pass on the fixed build, and the CodeRabbit thread on the test shape was resolved with a convincing empirical explanation. CI failures on the latest head are documented as unrelated tree-wide flakes. No prior human review is on the timeline. Given the breadth (10 files, 4 subsystems) and the JSC-internals subject matter, deferring rather than auto-approving.

@robobun
robobun force-pushed the farm/15503056/fix-scope-this-leak branch from 6076ef9 to fe2f52d Compare July 7, 2026 09:07

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

I didn't find any bugs, but this is a systematic sweep across C++ and Rust JSC bindings (plus a new FFI surface) fixing a crash-class issue, so it's worth a human confirming the toThis approach and the completeness of the thisValue() audit.

Extended reasoning...

Overview

This PR fixes a fuzzer-found segfault where JSC's JSLexicalEnvironment (activation object) leaks into user JavaScript when host functions are called as bare functions through closure-captured bindings. The fix applies JSValue::toThis(..., ECMAMode::strict()) at every site where a raw callframe->thisValue() was returned or stored: three plugin builder methods in BunPlugin.cpp, jsMockFunctionCall and jsSetSystemTime in JSMockFunction.cpp, the StringDecoder constructor in JSStringDecoder.cpp, and seven fake-timer host functions in FakeTimers.rs. It also adds a new JSC__JSValue__toThisStrict C++ binding exposed as JSValue::to_this_strict in Rust, and regression tests in four test files.

Security risks

The bug being fixed is itself safety-relevant: leaking an engine-internal scope object to user JS allowed reading TDZ slots as empty JSValues, which the JIT then dereferenced as a null cell (segfault / UBSAN null-pointer member call). The fix closes that surface. I don't see new risks introduced — strict-mode toThis is identity for every value except scope objects, so normal method calls (jest.useFakeTimers(), builder.onLoad(...)) preserve their existing chaining behavior, which the tests confirm.

Level of scrutiny

This warrants human review. While the per-site pattern is mechanical and consistent, the change spans six native source files in the JSC bindings layer (the most correctness-critical part of the codebase), introduces a new FFI entry point, and includes a non-trivial refactor of the JSStringDecoder constructor (the newTarget variable changes from JSObject* to JSValue, and the express body-parser hack branch now derives thisObject from the sanitized thisValue rather than the raw callframe). A maintainer should confirm that strict-mode toThis is the right sanitization primitive here and that the author's audit of remaining thisValue() sites (host_fn shims, NAPI/V8 layers deliberately left unchanged) is complete.

Other factors

The bug-hunting system found nothing. Each fixed site has a targeted regression test that the author verified fails on the unfixed build for the right reason (per the CodeRabbit thread, which was resolved after empirical verification of the closure-capture trigger). CI on the rebased head passed all tests, with only infra-related job failures. The PR description is thorough and traces the crash mechanism precisely. Nothing here looks wrong to me — I'm deferring purely on scope and criticality, not on any specific concern.

When a call like f() resolves the callee through a closure scope, JSC
passes the scope object (JSLexicalEnvironment) as the raw this value
and relies on the callee to run toThis. JS functions do that in their
prologue; host functions must call JSValue::toThis themselves.

Several host functions returned or stored callframe->thisValue() raw,
leaking the activation object to user JS. Reading a TDZ slot through
the leaked object then yields an empty JSValue, which crashes JIT'd
type checks (segfault dereferencing a null cell).

Apply toThis with strict semantics (identity for everything except
scope objects, which become undefined) at each exposure:

- jsMockFunctionCall: mock.contexts, mockReturnThis, and the this
  forwarded to implementations
- jest.setSystemTime (C++) and the Rust fake timer methods, via a new
  JSC__JSValue__toThisStrict binding and JSValue::to_this_strict
- Bun.plugin builder onLoad/onResolve/module chaining returns
- StringDecoder called without new: stop writing encoding onto the
  activation object and returning it; also fixes a debug assert when
  this is undefined (asObject on a non-cell)

Delete the dead C++ JSMock__jsUseRealTimers; the live implementation
is in FakeTimers.rs.
@robobun
robobun force-pushed the farm/15503056/fix-scope-this-leak branch from fe2f52d to 58767dd Compare July 10, 2026 11:03

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

I didn't find any bugs, but this crash fix spans JSC bindings across C++ and Rust (including a new FFI export and reworked newTarget/thisValue handling in JSStringDecoder), so it's worth a human look.

Extended reasoning...

Overview

This PR fixes a fuzzer-found segfault by applying toThis(strict) to raw callFrame->thisValue() at every site where a host function returns or stores it, so a JSLexicalEnvironment passed by a scope-resolved bare call cannot leak into user JS. It touches JSMockFunction.cpp (jsMockFunctionCall, jsSetSystemTime), BunPlugin.cpp (three builder methods), JSStringDecoder.cpp (constructor), FakeTimers.rs (seven timer host functions), and adds a new JSC__JSValue__toThisStrict binding in bindings.cpp exposed as JSValue::to_this_strict in JSValue.rs. Four regression tests cover each surface.

Security risks

The bug itself is security-adjacent: leaking a JSLexicalEnvironment lets user JS read TDZ slots as empty JSValues, which then null-deref in JIT code. The fix is strictly narrowing (scope objects → undefined, everything else identity), so it does not introduce new attack surface. The new FFI export is a thin nothrow wrapper around JSC's existing toThis.

Level of scrutiny

Medium-high. The per-site pattern is mechanical, but the change spans C++ and Rust JSC bindings, adds a cross-FFI export, and the JSStringDecoder hunk reworks control flow: it separates newTarget from thisValue (previously conflated via asObject(callFrame->newTarget()), which asserted on undefined) and re-derives the express-body-parser hack path from the sanitized this. That is a real behavior change worth a maintainer's eyes.

Other factors

The PR has been rebased four times over conflicting changes to the same functions (WebKit upgrade, setSystemTime rework, JSC C API removal), and the description documents each resolution. The bug-hunting system found nothing, CI is green on all shards that ran, and the CodeRabbit thread on the test shape was resolved with an empirical demonstration. Still, given the breadth across critical binding code and the number of merge resolutions, I'm deferring rather than auto-approving.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded. Every hunk in this PR is now in a PR that merges cleanly against main: the mock function call and setSystemTime, the fake timer functions and the Bun.plugin builder are in #39207 (the mock call also in #39509), and the StringDecoder constructor is in #39525. This branch has conflicted with main since July. Reopen if one of those is closed without landing its part.

@robobun robobun closed this Aug 18, 2026
Jarred-Sumner pushed a commit that referenced this pull request Aug 18, 2026
… fs.Stats, Node-API and V8 callbacks (#39525)

### Problem

- When JS calls a function with no receiver through a binding that a
closure captures (or a module binding), the callee's this slot holds the
scope object that the binding was resolved through. This is the normal
shape of `const { x } = require(...)` followed by `x()` inside any
function. JS callees convert that slot in `op_to_this`; host functions
read it raw. The native functions changed in this PR use their receiver
generically and so act on the scope object:
- `NodeError_proto_toString` (`src/jsc/bindings/ErrorCode.cpp:55`) reads
`name`/`code`/`message` through it. A binding in the scope that is still
in its TDZ reads back as the empty `JSValue`, and the process dies with
`panic(main thread): Segmentation fault at address 0x5`. Without a TDZ
binding it returns `"undefined [undefined]: undefined"`. Node throws a
`TypeError`.
- `fs.Stats` / `BigIntStats` `isFile()` and the other mode methods
(`src/jsc/bindings/NodeFSStatBinding.cpp:142`) read `mode` through it:
the same segfault with a TDZ `mode` binding, otherwise a bogus `false`
(or a `TypeError` from `toBigInt64` for the BigInt variant). The
`atime`/`mtime`/`ctime`/`birthtime` accessors in the same file
(`getDateField`, `DatePutter`) have the same shape once their getter is
pulled out of the property descriptor: JSC's `JSCustomGetterFunction`
wrapper also passes the slot through raw, so a bare call segfaults on a
TDZ `atimeMs` binding and otherwise returns an Invalid Date.
- `StringDecoder(...)` called without `new`
(`src/jsc/bindings/JSStringDecoder.cpp:596`) treats it as the
body-parser style receiver: it writes the decoder state onto it and
returns it, so the caller gets `[native code: JSLexicalEnvironment]`
instead of a decoder. The same code also ran `asObject()` on the slot
before checking that it holds an object, which aborts debug builds for
`StringDecoder.call(undefined, enc)`.
- `NAPICallFrame` (`src/jsc/bindings/napi.h:986`) and the V8 shim's
`FunctionTemplate::functionCall` and `invokeAccessor`
(`src/jsc/bindings/v8/shim/FunctionTemplate.cpp:151`,
`shim/TemplateProperty.cpp:64`) emulate a sloppy-mode receiver by hand:
undefined/null become globalThis, everything else goes through
`toObject()`. A scope object is already an object, so the addon receives
it as `this_arg` / `info.This()` / `HolderV2()`. Node gives it
globalThis.
- All of the above reproduce on the current release build (repros in the
details block).
- Root cause: `FunctionCallResolveNode::emitBytecode` (JavaScriptCore
bytecompiler) stores the resolve-scope result in the this register and
leaves the conversion to the callee. Converting `JSScope` receivers
once, where JSC enters a host function, would retire the whole class;
that is a change to the WebKit fork. Until then the fix is the one JSC's
own host functions use: call `JSValue::toThis` before using the receiver
generically. Bun already does this in `JSBuffer.cpp`, `JSDOMOperation.h`
and `JSEventTargetCustom.h`.

### Fix

- `NodeError_proto_toString`, the Stats mode methods and date accessors,
and the `StringDecoder` constructor convert the receiver with
`toThis(globalObject, ECMAMode::strict())` first. Strict `toThis` maps
scope objects to `undefined` and returns every other value unchanged, so
`err.toString()`, `stats.isFile()`, `stats.atime`,
`StringDecoder.call(obj, enc)` and `new StringDecoder()` are unaffected.
The scope receiver then takes the path an undefined receiver already
took: `toString()` throws the `TypeError` Node throws, `isFile()` and
the date getters return `undefined` like they do for `.call(undefined)`,
and `StringDecoder()` returns a fresh decoder. The Stats accessors only
ever live on the two Stats prototypes, never on the global object, so no
legitimate property access can hand them a scope receiver.
- The `StringDecoder` condition compares the converted receiver as a
`JSValue` instead of calling `asObject()` on the slot. On a construct
call the slot holds `new.target`, which is always a constructor object,
so construct calls select the same branch as before.
- `NAPICallFrame`, `FunctionTemplate::functionCall` and `invokeAccessor`
replace the hand-rolled logic with `toThis(globalObject,
ECMAMode::sloppy())`, which is the engine's definition of a sloppy
receiver: undefined, null and scope objects become globalThis,
primitives are boxed, other objects pass through. This is what the old
code computed for every input except scope objects, and it is what Node
hands addons. Every Node-API callable (`napi_create_function`, class
constructors, methods, accessors) is a `NapiClass` that builds its frame
through this one constructor. Sloppy `toThis` has no throwing path, so
the existing `assertNoException()` still holds.
- Verified with the debug build. Each new test fails on the release
build as described:
- `test/js/node/errors/error-code-toString-receiver.test.ts`: bare call
throws (release: returns the garbage string), TDZ variant in a child
exits 0 (release: exit 139).
- `test/js/node/fs/fs-stats-constructor.test.ts`: bare `isFile()` /
BigInt `isDirectory()` return `undefined` (release: `false` /
`TypeError`), the extracted `atime` / BigInt `mtime` getters return
`undefined` (release: Invalid Date), TDZ variant in a child exits 0
(release: exit 139). The `DatePutter` hunk has no test of its own: a
setter called on a scope object only wrote onto that object, which JS
cannot observe.
- `test/js/node/string_decoder/string-decoder.test.js`: captured bare
call returns a decoder (release: `[native code: JSLexicalEnvironment]`),
explicit object receiver still initialized, undefined/primitive
receivers in a child (unfixed debug build: SIGABRT in `asObject`).
- `test/napi/napi.test.ts` (new `return_this` helper in the test addon)
and `test/v8/v8.test.ts` (existing `return_this`): output compared
against node; release prints `false` for the closure line where node
prints `true`, the `call(undefined)` / `call(5)` / `call(receiver)`
lines already matched.
- `test/v8/v8.test.ts` accessor coverage (new
`create_object_with_holder_accessor` fixture): property access compared
against node, plus a Bun-only test that calls the getter and setter
functions with bare, undefined, null, primitive and object receivers.
Release differs only on the two bare-call lines.
- Full `napi.test.ts` (175 pass) and `v8.test.ts` (77 pass, 1
pre-existing skip), the js-native-api suites for functions, callbacks,
constructors, object wrap, properties and new.target, node's
`test-fs-stat*` and `test-string-decoder*`, and the existing
string_decoder, stats and error-code tests all pass.
- Known sites of the same shape that this PR leaves alone:
- `import.meta.resolve` / `resolveSync` (`ImportMetaObject.cpp`): a bare
call already throws "must be bound to an import.meta object"; Node
resolves it, because its function is bound to the module. That needs a
per-module bound function (see #32248), not `toThis`.
- `NodeVMScript` methods and other typed-receiver sites report the bad
receiver as `Received [native code: JSLexicalEnvironment]` in the error
message. Cosmetic, no crash.
- Other custom accessors reached through
`Object.getOwnPropertyDescriptor(...).get` get the raw receiver from
JSC's `JSCustomGetterFunction` wrapper in the same way. The Stats ones
are fixed here because they are in a file this PR already changes; the
general fix is in the wrapper, which lives in the WebKit fork and covers
every custom accessor at once. Note that `toThis` is not safe to add
blindly to a custom accessor that can be installed on the global object,
because a bare identifier read resolves on the global object itself,
which is also a `JSScope`.
- Relation to the other open PRs for this root cause: #39207 fixes the
chaining functions and the `inspect.custom` fallbacks, #39509 the mock
functions and `createInvalidThisError`. This PR shares no file with
#39207 and touches a different function of `ErrorCode.cpp` than #39509,
so the three merge in any order. #32172 was the first attempt; every
hunk in it is now in one of these three, and it no longer merges, so it
is closed in favor of them.

### Background

- **this slot / `op_to_this`**: every call frame has a slot for the
receiver. For `f()` where `f` lives in a scope rather than a local
register, JSC stores the scope object it resolved `f` through in that
slot and JS function prologues run `op_to_this`, which turns it into
`undefined` (strict code) or globalThis (sloppy code). Native functions
read the slot directly via `callFrame->thisValue()`.
- **`JSValue::toThis(globalObject, ECMAMode)`**: the runtime form of
that conversion. Any object inheriting `JSScope` (`JSLexicalEnvironment`
for closures, `JSModuleEnvironment` for ES modules, the global object
itself) becomes `undefined` in strict mode or globalThis in sloppy mode.
Strict mode returns every other value as is. Sloppy mode also turns
undefined/null into globalThis and boxes primitives.
- **Scope objects and the TDZ**: property reads on a scope object go
through its symbol table and return the raw variable slot. A
`let`/`const` that is not initialized yet holds the empty `JSValue`,
which the rest of the engine treats as a cell pointer; touching it is
the segfault at address 5 (the offset of the cell type byte).
- **body-parser hack in StringDecoder**: Bun's `StringDecoder` accepts
being called as a function on an arbitrary object and initializes that
object in place (`RealStringDecoder.apply(this, ...)` from a
`util.inherits` subclass). That is why the constructor looks at its
receiver at all. The fix keeps that path and only stops scope objects
from qualifying.
- **Holder in the V8 shim**: a native data property's callbacks receive
the receiver as `HolderV2()`. Bun installs such properties as JS
accessors, so their getter and setter can also be extracted from the
property descriptor and called like functions; that is the path
`invokeAccessor` converts.

<details>
<summary>Release-build repros (bun 1.4.0-canary)</summary>

```js
// exits 139; node prints "result: TypeError"
let toString;
try { require("node:fs").readFileSync(); } catch (e) { ({ toString } = e); }
let out;
try { out = toString(); } catch (e) { out = e.constructor.name; }
console.log("result:", out);
let name = "after";
function keep() { return [toString, name]; }
keep();
```

```js
// exits 139; node throws a TypeError
const { isFile } = require("node:fs").statSync(__filename);
console.log(isFile());
let mode = 0;
function keep() { return [isFile, mode]; }
keep();
```

```js
const { StringDecoder } = require("node:string_decoder");
function keep() { return StringDecoder; }
const d = StringDecoder("utf8");
console.log(Bun.inspect(d));                               // [native code: JSLexicalEnvironment]
console.log(d instanceof StringDecoder, typeof d.write);   // false undefined
```

```js
// addon.c: napi_create_function("returnThis") returning this_arg from napi_get_cb_info
const { returnThis } = require("./addon.node");
function keep() { return returnThis; }
console.log(returnThis() === globalThis);                  // bun: false, node: true
console.log(returnThis.call(undefined) === globalThis);    // both: true
```

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/napi/napi.test.ts test/v8/v8.test.ts

<!-- robobun:evidence:end -->
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