Skip to content

Fix type confusion assigning onmessage/onerror through a Proxy of globalThis - #37053

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/27c3704b/fix-proxy-global-onmessage
Aug 6, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/27c3704b/fix-proxy-global-onmessage

Conversation

@robobun

@robobun robobun commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

Assigning onmessage or onerror through a Proxy of the global object segfaults the process on 1.4.0:

new Proxy(globalThis, {}).onmessage = null;
// panic: Segmentation fault at address 0x10

The same crash fires for onerror, for the sloppy-mode sandbox idiom with (new Proxy(globalThis, {})) { onmessage = function () {} }, and inside a worker (new Proxy(self, {}).onmessage = ...), where it takes down the whole process.

Cause

onmessage/onerror are installed on the global with putDirectCustomAccessor(..., 0), which makes them CustomValue properties. For a CustomValue slot, JSC's put-on-receiver path (JSObject::definePropertyOnReceiverSlow) invokes the custom setter with the receiver as thisValue. When the receiver is a ProxyObject rather than the global, uncheckedDowncast<Zig::GlobalObject> in setGlobalOnMessage/setGlobalOnError (ZigGlobalObject.cpp) type-confuses the Proxy and dereferences a bogus eventTarget().

Fix

Cast thisValue with dynamicDowncast in the four onmessage/onerror callbacks and fall back to defaultGlobalObject(lexicalGlobalObject) when the receiver is not a global. The assignment then installs the handler on the real global of the running realm, which matches Deno's behavior for this pattern and keeps the with (proxy) sandbox idiom working. In a worker, the handler lands on the worker's own global scope.

Verification

New tests in test/js/web/web-globals.test.js:

  • main thread: install, dispatch, and clear onmessage/onerror through new Proxy(globalThis, {}), plus the with scope variant
  • worker: a worker whose message handler is installed via new Proxy(self, {}) echoes a message back

Both fail with the segfault on the released binary and pass with this change.

The second commit trims the pre-existing crypto.randomUUID fast-path loop in the same file from 45,000 expect() calls to one assertion; it was overrunning the 5s per-test budget on debug builds.


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

robobun added 2 commits August 6, 2026 11:33
…balThis

The global onmessage/onerror accessors are CustomValue properties, so JSC
invokes their callbacks with the property receiver as thisValue. Assigning
through a receiver that is not the global object, e.g.
new Proxy(globalThis, {}).onmessage = null, passed a ProxyObject to an
unchecked downcast and segfaulted. Cast safely and fall back to the lexical
realm's global object, so the handler lands on the real global.
The 9000-iteration loop made 45,000 expect() calls, overrunning the 5s
per-test budget on debug builds. Check the same predicate inline and
assert once.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0d58bffa-9bbe-4040-a661-da215fad8cc7

📥 Commits

Reviewing files that changed from the base of the PR and between 0ffabf6 and bea5f40.

📒 Files selected for processing (2)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/web/web-globals.test.js

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

@github-actions github-actions Bot added the claude label 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 — narrow crash fix replacing uncheckedDowncast with dynamicDowncast + lexical-global fallback.

What was reviewed:

  • Verified dynamicDowncast<T>(JSValue) is an established pattern in bindings and defaultGlobalObject() handles the non-Zig lexical-global case.
  • Confirmed these are the only two putDirectCustomAccessor(..., 0) (CustomValue) slots on the global, so the whole bug class is covered.
  • Tests follow harness conventions (subprocess isolation, test.concurrent, stderr→stdout→exitCode order); the randomUUID loop change preserves the assertion — still checks all 9000 iterations, one expect() at the end.
Extended reasoning...

Overview

Four uncheckedDowncast<Zig::GlobalObject>(JSValue::decode(thisValue)) calls in the onmessage/onerror custom getter/setter callbacks are replaced by a small helper that dynamicDowncasts the receiver and falls back to defaultGlobalObject(lexicalGlobalObject). Because these properties are installed as CustomValue (attributes 0), JSC's definePropertyOnReceiverSlow passes the receiver (e.g. a Proxy) as thisValue, so the unchecked cast type-confused a ProxyObject as a GlobalObject and dereferenced garbage through eventTarget(). Two subprocess tests (main-thread Proxy + with-scope, and worker Proxy of self) are added, and an unrelated slow loop in the same test file is trimmed to one expect().

Security risks

The prior code was a type-confusion segfault reachable from unprivileged JS — the fix strictly removes an unchecked cast in favor of a checked one. defaultGlobalObject already handles a non-Zig lexicalGlobalObject by returning the process default global, so the helper never returns null. No new attack surface.

Level of scrutiny

ZigGlobalObject.cpp is core, but the diff is ~10 lines: a static helper plus four one-line call-site swaps. The pattern (dynamicDowncast<T>(JSValue::decode(thisValue)) with a fallback) is used pervasively across the bindings (e.g. JSCommonJSModule.cpp), and the fallback helper is the existing defaultGlobalObject. I grepped for other putDirectCustomAccessor(..., CustomGetterSetter::create(...), 0) calls on the global — onmessage and onerror are the only two, so no sibling sites share the bug class.

Other factors

The behavioral choice — install the handler on the running realm's global rather than throw a TypeError — is a design decision, but it's documented as matching Deno and keeps the with (new Proxy(globalThis, {})) sandbox idiom working; either way it strictly beats the segfault. Tests use bunEnv/bunExe/tempDir, drain stdout/stderr/exited concurrently, assert stderr before stdout before exitCode, and run as test.concurrent. The crypto.randomUUID loop rewrite still visits all 9000 iterations and calls expect(malformed).toBeUndefined() once, so the assertion can still fail and reports the offending UUID.

@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: 195 of 196 jobs passed. The one red lane (Debian 13 x64-asan) is test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts failing a JSC ExceptionScope assertion (SIGABRT). That failure also occurs on main and is unrelated to this change; it has been flagged for separate triage. The new tests in test/js/web/web-globals.test.js passed on all lanes.

Ready for review.

@Jarred-Sumner
Jarred-Sumner merged commit 45be4ac into main Aug 6, 2026
53 of 55 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/27c3704b/fix-proxy-global-onmessage branch August 6, 2026 20:25
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