CVE-2026-113187
Overview
Files Changed
third_party/blink/web_tests/fast/events/cross-world-event-dispatch.htmlthird_party/blink/renderer/core/dom/events/event_target.ccthird_party/blink/renderer/core/dom/events/event.h
Patch
From 9d15343544a0a6e56a8bf4686196a002ca2c194b Mon Sep 17 00:00:00 2001
From: Dominic Farolino <dom@chromium.org>
Date: Wed, 23 Sep 2026 14:03:03 -0700
Subject: [PATCH] DOM: Fix Observable::when() cross-world error leak
This CL fixes a small leak whereby event listeners registered via
`Observable::when()` would get run for the subset of events that, when
constructed, get stamped with the ID of the world they were born in,
and ordinarily prevented from being exposed to normal
`addEventListener()` JS listeners. `ErrorEvent` is one of these few
events with this property.
Therefore, after this CL, `window.when('error')` subscription handlers
created in the main world will not get fired for uncaught exceptions
thrown in an isolated world. This matches the behavior of
`window.addEventListener()` handlers also created in the main world.
R=masonf
Bug: 553845833
Change-Id: I2ee7a3a7ab84ebdc7424317c22ab2f7bea359730
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8419940
Reviewed-by: Mason Freed <masonf@chromium.org>
Commit-Queue: Dominic Farolino <dom@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1703755}
---
diff --git a/third_party/blink/renderer/core/dom/events/event.h b/third_party/blink/renderer/core/dom/events/event.h
index 746f5023..f9a663d 100644
--- a/third_party/blink/renderer/core/dom/events/event.h
+++ b/third_party/blink/renderer/core/dom/events/event.h
@@ -286,6 +286,12 @@
// Events that must not leak across isolated world, similar to how
// ErrorEvent behaves, can override this method.
+ //
+ // Every path that surfaces an `Event` to script must consult this. Ordinary
+ // event listeners do so in `JSBasedEventListener::Invoke()`, but
+ // `EventTarget::FireEventListeners()` itself does not, so a
+ // `NativeEventListener` that forwards the raw `Event` on to script (e.g. the
+ // one backing `EventTarget#when()`) is responsible for checking it directly.
virtual bool CanBeDispatchedInWorld(const DOMWrapperWorld&) const {
return true;
}
diff --git a/third_party/blink/renderer/core/dom/events/event_target.cc b/third_party/blink/renderer/core/dom/events/event_target.cc
index 6da24729..d585491 100644
--- a/third_party/blink/renderer/core/dom/events/event_target.cc
+++ b/third_party/blink/renderer/core/dom/events/event_target.cc
@@ -281,6 +281,21 @@
// `EventTarget::FireEventListeners()` early-returns if its `ExecutionContext`
// is detached.
DCHECK(script_state_->ContextIsValid());
+
+ // Some events are bound to the `DOMWrapperWorld` they were created in (see
+ // `Event::CanBeDispatchedInWorld()`), and must not be surfaced to script in
+ // any other world. Ordinary event listeners enforce this in
+ // `JSBasedEventListener::Invoke()`; since `this` is a `NativeEventListener`,
+ // it does not go through that path and must perform the same check itself.
+ //
+ // This must happen before `ScriptValue::From()` below: creating the wrapper
+ // in the subscriber's world is itself the leak, not merely invoking the
+ // subscriber. Note that this filters in both directions, since
+ // `script_state_` is the *subscriber's* script state.
+ if (!event->CanBeDispatchedInWorld(script_state_->World())) {
+ return;
+ }
+
ScriptState::Scope scope(script_state_);
ScriptValue script_value = ScriptValue::From(script_state_, event);
diff --git a/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
new file mode 100644
index 0000000..53a6fdb4
--- /dev/null
+++ b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<title>Per-world event dispatch rules across the MAIN/isolated world boundary</title>
+<script src="../../resources/testharness.js"></script>
+<script src="../../resources/testharnessreport.js"></script>
+<div id="target"></div>
+<script>
+// Blink stamps certain events with the DOMWrapperWorld they were created in
+// (`ErrorEvent`, `PromiseRejectionEvent`, and the device-connection events).
+// `Event::CanBeDispatchedInWorld()` then gates delivery, and the *only* place
+// that gate is consulted on the dispatch path is
+// `JSBasedEventListener::Invoke()`.
+//
+// These tests pin down the resulting matrix, including the `EventTarget.when()`
+// hole tracked by crbug.com/553845833.
+
+const ISOLATED_WORLD_ID = 1;
+
+function inIsolatedWorld(script) {
+ return testRunner.evaluateScriptInIsolatedWorldAndReturnValue(
+ ISOLATED_WORLD_ID, script);
+}
+
+setup(() => {
+ assert_implements(
+ window.testRunner, 'This test requires testRunner (isolated worlds).');
+ assert_implements(
+ window.when, 'This test requires EventTarget.when().');
+
+ // Install the isolated world's listeners once, up front:
+ // - a cancelling "error" listener on #target, used by the first two tests
+ // to detect whether a MAIN-world-dispatched event reached it.
+ // - a helper that dispatches an isolated-world-stamped ErrorEvent on
+ // window, used by the last two tests.
+ inIsolatedWorld(`
+ window.targetListenerRunCount = 0;
+ document.getElementById('target').addEventListener('error', e => {
+ window.targetListenerRunCount++;
+ e.preventDefault();
+ });
+
+ // Constructed in this world, so ErrorEvent::world_ is the isolated world.
+ window.dispatchIsolatedErrorEvent = () => {
+ window.dispatchEvent(new ErrorEvent('error', {
+ message: 'isolated-world-private',
+ filename: 'isolated-world-private.js',
+ lineno: 42,
+ colno: 7,
+ }));
+ };
+ `);
+});
+
+function isolatedTargetListenerRunCount() {
+ return inIsolatedWorld('window.targetListenerRunCount');
+}
+
+const target = document.getElementById('target');
+
+// ---------------------------------------------------------------------------
+// MAIN -> isolated.
+// ---------------------------------------------------------------------------
+
+test(() => {
+ const before = isolatedTargetListenerRunCount();
+ const notCanceled =
+ target.dispatchEvent(new Event('error', {cancelable: true}));
+
+ assert_equals(isolatedTargetListenerRunCount(), before + 1,
+ 'isolated-world listener should have run');
+ assert_false(notCanceled,
+ 'the isolated-world listener\'s preventDefault() is observable ' +
+ 'from the main world');
+}, 'A main-world plain Event reaches an isolated-world listener, because the ' +
+ 'base Event has no world stamp.');
+
+test(() => {
+ const before = isolatedTargetListenerRunCount();
+ const notCanceled =
+ target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+
+ assert_equals(isolatedTargetListenerRunCount(), before,
+ 'isolated-world listener must not run');
+ assert_true(notCanceled, 'nothing should have canceled the event');
+}, 'A main-world ErrorEvent does not reach an isolated-world listener, ' +
+ 'because its constructor stamps it with the main world.');
+
+// ---------------------------------------------------------------------------
+// isolated -> MAIN.
+// ---------------------------------------------------------------------------
+
+test(() => {
+ let listenerRunCount = 0;
+ const listener = () => { listenerRunCount++; };
+ window.addEventListener('error', listener);
+
+ try {
+ inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+ assert_equals(listenerRunCount, 0,
+ 'main-world listener must not observe an isolated-world ' +
+ 'ErrorEvent');
+ } finally {
+ window.removeEventListener('error', listener);
+ }
+}, 'An isolated-world ErrorEvent does not reach a main-world ' +
+ 'addEventListener() listener.');
+
+test(() => {
+ let subscriberRunCount = 0;
+ const controller = new AbortController();
+ window.when('error').subscribe(() => { subscriberRunCount++; },
+ {signal: controller.signal});
+
+ try {
+ inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+ assert_equals(subscriberRunCount, 0,
+ 'when() must apply the same per-world filter that ' +
+ 'addEventListener() applies');
+ } finally {
+ controller.abort();
+ }
+}, 'An isolated-world ErrorEvent does not reach a main-world when() ' +
+ 'subscriber.');
+
+// The filter is symmetric, because `ObservableEventListener::Invoke()` checks
+// the event against the *subscriber's* world: an isolated-world `when()`
+// subscriber must no more observe main-world-stamped events than the reverse.
+test(() => {
+ inIsolatedWorld(`
+ window.targetSubscriberRunCount = 0;
+ window.targetSubscriberController = new AbortController();
+ document.getElementById('target').when('error').subscribe(
+ () => { window.targetSubscriberRunCount++; },
+ {signal: window.targetSubscriberController.signal});
+ `);
+
+ try {
+ target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+ assert_equals(inIsolatedWorld('window.targetSubscriberRunCount'), 0,
+ 'isolated-world when() subscriber must not observe a ' +
+ 'main-world-stamped ErrorEvent');
+ } finally {
+ inIsolatedWorld('window.targetSubscriberController.abort();');
+ }
+}, 'A main-world ErrorEvent does not reach an isolated-world when() ' +
+ 'subscriber.');
+</script>
+
Regression Test / PoC
diff --git a/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
new file mode 100644
index 0000000..53a6fdb4
--- /dev/null
+++ b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<title>Per-world event dispatch rules across the MAIN/isolated world boundary</title>
+<script src="../../resources/testharness.js"></script>
+<script src="../../resources/testharnessreport.js"></script>
+<div id="target"></div>
+<script>
+// Blink stamps certain events with the DOMWrapperWorld they were created in
+// (`ErrorEvent`, `PromiseRejectionEvent`, and the device-connection events).
+// `Event::CanBeDispatchedInWorld()` then gates delivery, and the *only* place
+// that gate is consulted on the dispatch path is
+// `JSBasedEventListener::Invoke()`.
+//
+// These tests pin down the resulting matrix, including the `EventTarget.when()`
+// hole tracked by crbug.com/553845833.
+
+const ISOLATED_WORLD_ID = 1;
+
+function inIsolatedWorld(script) {
+ return testRunner.evaluateScriptInIsolatedWorldAndReturnValue(
+ ISOLATED_WORLD_ID, script);
+}
+
+setup(() => {
+ assert_implements(
+ window.testRunner, 'This test requires testRunner (isolated worlds).');
+ assert_implements(
+ window.when, 'This test requires EventTarget.when().');
+
+ // Install the isolated world's listeners once, up front:
+ // - a cancelling "error" listener on #target, used by the first two tests
+ // to detect whether a MAIN-world-dispatched event reached it.
+ // - a helper that dispatches an isolated-world-stamped ErrorEvent on
+ // window, used by the last two tests.
+ inIsolatedWorld(`
+ window.targetListenerRunCount = 0;
+ document.getElementById('target').addEventListener('error', e => {
+ window.targetListenerRunCount++;
+ e.preventDefault();
+ });
+
+ // Constructed in this world, so ErrorEvent::world_ is the isolated world.
+ window.dispatchIsolatedErrorEvent = () => {
+ window.dispatchEvent(new ErrorEvent('error', {
+ message: 'isolated-world-private',
+ filename: 'isolated-world-private.js',
+ lineno: 42,
+ colno: 7,
+ }));
+ };
+ `);
+});
+
+function isolatedTargetListenerRunCount() {
+ return inIsolatedWorld('window.targetListenerRunCount');
+}
+
+const target = document.getElementById('target');
+
+// ---------------------------------------------------------------------------
+// MAIN -> isolated.
+// ---------------------------------------------------------------------------
+
+test(() => {
+ const before = isolatedTargetListenerRunCount();
+ const notCanceled =
+ target.dispatchEvent(new Event('error', {cancelable: true}));
+
+ assert_equals(isolatedTargetListenerRunCount(), before + 1,
+ 'isolated-world listener should have run');
+ assert_false(notCanceled,
+ 'the isolated-world listener\'s preventDefault() is observable ' +
+ 'from the main world');
+}, 'A main-world plain Event reaches an isolated-world listener, because the ' +
+ 'base Event has no world stamp.');
+
+test(() => {
+ const before = isolatedTargetListenerRunCount();
+ const notCanceled =
+ target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+
+ assert_equals(isolatedTargetListenerRunCount(), before,
+ 'isolated-world listener must not run');
+ assert_true(notCanceled, 'nothing should have canceled the event');
+}, 'A main-world ErrorEvent does not reach an isolated-world listener, ' +
+ 'because its constructor stamps it with the main world.');
+
+// ---------------------------------------------------------------------------
+// isolated -> MAIN.
+// ---------------------------------------------------------------------------
+
+test(() => {
+ let listenerRunCount = 0;
+ const listener = () => { listenerRunCount++; };
+ window.addEventListener('error', listener);
+
+ try {
+ inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+ assert_equals(listenerRunCount, 0,
+ 'main-world listener must not observe an isolated-world ' +
+ 'ErrorEvent');
+ } finally {
+ window.removeEventListener('error', listener);
+ }
+}, 'An isolated-world ErrorEvent does not reach a main-world ' +
+ 'addEventListener() listener.');
+
+test(() => {
+ let subscriberRunCount = 0;
+ const controller = new AbortController();
+ window.when('error').subscribe(() => { subscriberRunCount++; },
+ {signal: controller.signal});
+
+ try {
+ inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+ assert_equals(subscriberRunCount, 0,
+ 'when() must apply the same per-world filter that ' +
+ 'addEventListener() applies');
+ } finally {
+ controller.abort();
+ }
+}, 'An isolated-world ErrorEvent does not reach a main-world when() ' +
+ 'subscriber.');
+
+// The filter is symmetric, because `ObservableEventListener::Invoke()` checks
+// the event against the *subscriber's* world: an isolated-world `when()`
+// subscriber must no more observe main-world-stamped events than the reverse.
+test(() => {
+ inIsolatedWorld(`
+ window.targetSubscriberRunCount = 0;
+ window.targetSubscriberController = new AbortController();
+ document.getElementById('target').when('error').subscribe(
+ () => { window.targetSubscriberRunCount++; },
+ {signal: window.targetSubscriberController.signal});
+ `);
+
+ try {
+ target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+ assert_equals(inIsolatedWorld('window.targetSubscriberRunCount'), 0,
+ 'isolated-world when() subscriber must not observe a ' +
+ 'main-world-stamped ErrorEvent');
+ } finally {
+ inIsolatedWorld('window.targetSubscriberController.abort();');
+ }
+}, 'A main-world ErrorEvent does not reach an isolated-world when() ' +
+ 'subscriber.');
+</script>
+
Original Bug Report
[Blink][EventTarget.when] cross-world Event leak enables page-controlled isolated-world extension API invocation and private storage exfiltration
Report description
[Blink][EventTarget.when] cross-world Event leak enables page-controlled isolated-world extension API invocation and private storage exfiltration
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
The maximum demonstrated impact is cross-world capability transfer enabling page-controlled invocation of pre-existing isolated-world extension JavaScript with extension API authority and recovery of extension-private data.
The PoC proves:
hostile page
-> has no chrome.storage
-> ordinary MAIN error listener is filtered
-> EventTarget.when() receives trusted extension-world ErrorEvent
-> page retains the exact native Event
-> exact MAIN-world clone with identical visible fields cannot reach extension listener
-> page replays the leaked Event on a shared target
-> replay is correctly isTrusted=false
-> pre-existing isolated-world extension callback nevertheless executes
-> page controls the storage-key argument through the shared target
-> callback performs chrome.storage.local.get(pageControlledKey)
-> extension-private value is returned to the page
The page can also choose among multiple pre-existing isolated-world callbacks by choosing the replay target; this was independently confirmed in the extended matrix.
Current EventTarget.when() uses ObservableEventListener, which derives from NativeEventListener. ObservableEventListener::Invoke() forwards the native Event into the subscriber’s ScriptState without checking Event::CanBeDispatchedInWorld() for that subscriber’s DOMWrapperWorld.
Ordinary JS listeners perform that world check before wrapping/invoking the listener.
A robust fix would apply the same world-dispatch authorization before forwarding an Event from ObservableEventListener::Invoke(), e.g. reject when:
!event->CanBeDispatchedInWorld(script_state_->World())
before creating the ScriptValue or calling subscriber_->next().
Impact analysis
The maximum demonstrated impact is cross-world capability transfer enabling page-controlled invocation of pre-existing isolated-world extension JavaScript with extension API authority and recovery of extension-private data.
The PoC proves:
hostile page
-> has no chrome.storage
-> ordinary MAIN error listener is filtered
-> EventTarget.when() receives trusted extension-world ErrorEvent
-> page retains the exact native Event
-> exact MAIN-world clone with identical visible fields cannot reach extension listener
-> page replays the leaked Event on a shared target
-> replay is correctly isTrusted=false
-> pre-existing isolated-world extension callback nevertheless executes
-> page controls the storage-key argument through the shared target
-> callback performs chrome.storage.local.get(pageControlledKey)
-> extension-private value is returned to the page
The page can also choose among multiple pre-existing isolated-world callbacks by choosing the replay target; this was independently confirmed in the extended matrix.
This is not generic AJE: MAIN-world expando/function/getter/prototype mutations did not cross into the isolated wrapper. The demonstrated primitive is stronger and more specific: the leaked native Event acts as a reusable world-qualified capability that lets the page reach extension callbacks it cannot reach with a page-created Event.
Current EventTarget.when() uses ObservableEventListener, which derives from NativeEventListener. ObservableEventListener::Invoke() forwards the native Event into the subscriber’s ScriptState without checking Event::CanBeDispatchedInWorld() for that subscriber’s DOMWrapperWorld.
Ordinary JS listeners perform that world check before wrapping/invoking the listener.
A robust fix would apply the same world-dispatch authorization before forwarding an Event from ObservableEventListener::Invoke(), e.g. reject when:
!event->CanBeDispatchedInWorld(script_state_->World())
before creating the ScriptValue or calling subscriber_->next().
The cause
What version of Chrome have you found the security issue in?
154.0.8027.0+dev, 152.0.7977.64+stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Privilege Escalation
How would you like to be publicly acknowledged for your report?
Zabith Mohammed