CVE-2026-17674
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/dom/tree_scope.cc |
modified |
Files Changed
third_party/blink/renderer/core/dom/document_or_shadow_root.idlthird_party/blink/renderer/core/dom/element.ccthird_party/blink/renderer/core/dom/element.hthird_party/blink/renderer/core/dom/element.idlthird_party/blink/renderer/core/dom/tree_scope.ccthird_party/blink/renderer/core/dom/tree_scope.h
Patch
From 40b2818870e8acc4d968e4ea966decc9b5c8c020 Mon Sep 17 00:00:00 2001 From: Jayson Chen <[email protected]> Date: Fri, 05 Jun 2026 17:28:21 -0700 Subject: [PATCH] Fix cross-world isolation bypass via Scoped Custom Element Registry The SCER feature introduced document.customElementRegistry and element.customElementRegistry web-exposed accessors that lack DOMWrapperWorld isolation guards. This allows isolated worlds (e.g., extension content scripts) to obtain custom element registries from other worlds and leak cross-world JavaScript objects (constructors, promises) through methods like get() and whenDefined(), breaking world isolation and enabling arbitrary cross-world code execution. Changes: Add [CallWith=ScriptState] to the customElementRegistry attribute on DocumentOrShadowRoot and Element IDLs, and implement ScriptState-aware overloads in TreeScope and Element that enforce world isolation. Store a world_id_ on each CustomElementRegistry at construction time. Web-exposed accessor paths verify the caller's world matches the registry's world. Internal calls (null ScriptState) bypass the check, preserving behavior for cloning, parsing, and innerHTML. This closes two attack vectors: - Global registry: isolated worlds can no longer obtain it through the new SCER accessors (previously only guarded on window.customElements). - Scoped registries: world-ID comparison prevents cross-world access regardless of how elements are organized in the DOM Bug: 513791232 Change-Id: Iebbec810f29559f68c466a8d888e9422f7bec347 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7855219 Reviewed-by: David Baron <[email protected]> Reviewed-by: Mason Freed <[email protected]> Commit-Queue: Jayson Chen <[email protected]> Reviewed-by: Dominic Farolino <[email protected]> Cr-Commit-Position: refs/heads/main@{#1642691} --- diff --git a/third_party/blink/renderer/core/dom/document_or_shadow_root.idl b/third_party/blink/renderer/core/dom/document_or_shadow_root.idl index 236d632c..54b9a51 100644 --- a/third_party/blink/renderer/core/dom/document_or_shadow_root.idl +++ b/third_party/blink/renderer/core/dom/document_or_shadow_root.idl @@ -27,6 +27,6 @@ // https://w3c.github.io/picture-in-picture/#documentorshadowroot-extension [Measure] readonly attribute Element? pictureInPictureElement; - [RuntimeEnabled=ScopedCustomElementRegistry] + [CallWith=ScriptState, RuntimeEnabled=ScopedCustomElementRegistry] readonly attribute CustomElementRegistry? customElementRegistry; }; diff --git a/third_party/blink/renderer/core/dom/element.cc b/third_party/blink/renderer/core/dom/element.cc index 8e224c7b..5a8eb02 100644 --- a/third_party/blink/renderer/core/dom/element.cc +++ b/third_party/blink/renderer/core/dom/element.cc @@ -285,6 +285,7 @@ #include "third_party/blink/renderer/core/xlink_names.h" #include "third_party/blink/renderer/core/xml_names.h" #include "third_party/blink/renderer/platform/bindings/dom_data_store.h" +#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h" #include "third_party/blink/renderer/platform/bindings/exception_state.h" #include "third_party/blink/renderer/platform/bindings/script_state.h" #include "third_party/blink/renderer/platform/bindings/v8_dom_activity_logger.h" @@ -7370,7 +7371,8 @@ return nullptr; } -CustomElementRegistry* Element::customElementRegistry() const { +CustomElementRegistry* Element::customElementRegistry( + ScriptState* script_state) const { // If scoped registry is not exercised at all in the document, // we can avoid the rare data lookup and just return the tree scope's // registry. @@ -7378,11 +7380,18 @@ GetDocument().ScopedCustomElementRegistryUsed()) { if (const ElementRareDataVector* data = RareData()) { if (data->HasCustomElementRegistrySet()) { - return data->GetCustomElementRegistry(); + CustomElementRegistry* registry = data->GetCustomElementRegistry(); + // A null script_state indicates an internal call that bypasses the + // world check. + if (script_state && registry && + script_state->World().GetWorldId() != registry->GetWorldId()) { + return nullptr; + } + return registry; } } } - return GetTreeScope().customElementRegistry(); + return GetTreeScope().customElementRegistry(script_state); } void Element::SetCustomElementRegistry(CustomElementRegistry* registry, diff --git a/third_party/blink/renderer/core/dom/element.h b/third_party/blink/renderer/core/dom/element.h index 3b36351..d26dc5d 100644 --- a/third_party/blink/renderer/core/dom/element.h +++ b/third_party/blink/renderer/core/dom/element.h @@ -1648,7 +1648,13 @@ CustomElementDefinition* GetCustomElementDefinition() const; // Scoped Custom Elements - CustomElementRegistry* customElementRegistry() const; + // + // Returns the custom element registry associated with this element. + // See TreeScope::customElementRegistry() for the rule about when to + // pass `script_state` (in short: any caller that will hand the registry + // to script must pass it). + CustomElementRegistry* customElementRegistry( + ScriptState* script_state = nullptr) const; // When it comes to storing an element's custom element registry, we have an // optimization where if the registry to be set is the same as element's tree // scope's registry, we don't store it in the element itself and rely on tree diff --git a/third_party/blink/renderer/core/dom/element.idl b/third_party/blink/renderer/core/dom/element.idl index 51f29e1..f1454075 100644 --- a/third_party/blink/renderer/core/dom/element.idl +++ b/third_party/blink/renderer/core/dom/element.idl @@ -196,7 +196,7 @@ [CEReactions, RuntimeEnabled=HeadingOffset] attribute boolean headingReset; // Scoped Custom Element Registries - [RuntimeEnabled=ScopedCustomElementRegistry] readonly attribute CustomElementRegistry? customElementRegistry; + [CallWith=ScriptState, RuntimeEnabled=ScopedCustomElementRegistry] readonly attribute CustomElementRegistry? customElementRegistry; }; Element includes ParentNode; diff --git a/third_party/blink/renderer/core/dom/tree_scope.cc b/third_party/blink/renderer/core/dom/tree_scope.cc index 66fe7b3..811ad9f7 100644 --- a/third_party/blink/renderer/core/dom/tree_scope.cc +++ b/third_party/blink/renderer/core/dom/tree_scope.cc @@ -64,7 +64,9 @@ #include "third_party/blink/renderer/core/probe/core_probes.h" #include "third_party/blink/renderer/core/svg/svg_text_content_element.h" #include "third_party/blink/renderer/core/svg/svg_tree_scope_resources.h" +#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h" #include "third_party/blink/renderer/platform/bindings/script_forbidden_scope.h" +#include "third_party/blink/renderer/platform/bindings/script_state.h" #include "third_party/blink/renderer/platform/instrumentation/use_counter.h" #include "third_party/blink/renderer/platform/wtf/vector.h" #include "ui/gfx/geometry/point_conversions.h" @@ -299,10 +301,17 @@ return element; } -CustomElementRegistry* TreeScope::customElementRegistry() const { +CustomElementRegistry* TreeScope::customElementRegistry( + ScriptState* script_state) const { if (custom_element_registry_) { CHECK(RuntimeEnabledFeatures::ScopedCustomElementRegistryEnabled()); DCHECK(!waiting_for_registry_); + // A null script_state indicates an internal call that bypasses the check. + if (script_state && + script_state->World().GetWorldId() != + custom_element_registry_->GetWorldId()) { + return nullptr; + } return custom_element_registry_; } @@ -311,6 +320,12 @@ return nullptr; } + // The global registry must only be accessible from the main world. + // A null script_state indicates an internal call that bypasses the check. + if (script_state && !script_state->World().IsMainWorld()) { + return nullptr; + } + if (LocalDOMWindow* window = GetDocument().domWindow()) { return window->customElements(); } diff --git a/third_party/blink/renderer/core/dom/tree_scope.h b/third_party/blink/renderer/core/dom/tree_scope.h index 8d02cc0..30aac206 100644 --- a/third_party/blink/renderer/core/dom/tree_scope.h +++ b/third_party/blink/renderer/core/dom/tree_scope.h @@ -188,7 +188,24 @@ void ClearAdoptedStyleSheets(); - CustomElementRegistry* customElementRegistry() const; + // Returns the custom element registry associated with this tree scope. + // + // DOMWrapperWorld rule: any caller that will hand the returned registry + // to script must pass the caller's ScriptState. Without it, an isolated + // world (e.g., an extension content script) can reach a registry created + // in a different world (typically the main world) and leak raw cross- + // world v8 objects -- e.g., custom-element constructors handed out via + // the shared `when_defined_promise_map_`. When ScriptState is supplied, + // this method returns nullptr if the registry's creation world differs + // from the caller's world (or, for the global registry, if the caller is + // not in the main world). + // + // Callers that only use the registry for blink-internal work (creating + // elements, managing element<->registry associations, serialization, + // etc.) may omit `script_state` (or pass nullptr) to bypass the world
Regression Test / PoC
diff --git a/third_party/blink/web_tests/custom-elements/scoped-registry-isolated-worlds.html b/third_party/blink/web_tests/custom-elements/scoped-registry-isolated-worlds.html
new file mode 100644
index 0000000..368a5bf8
--- /dev/null
+++ b/third_party/blink/web_tests/custom-elements/scoped-registry-isolated-worlds.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<script src="../resources/testharness.js"></script>
+<script src="../resources/testharnessreport.js"></script>
+<script src="spec/resources/custom-elements-helpers.js"></script>
+<body>
+<script>
+'use strict';
+(() => {
+
+promise_test((t) => {
+ assert_true(!!window.testRunner, 'Requires testRunner.evaluateScriptInIsolatedWorld');
+ return create_window_in_test(t)
+ .then((w) => {
+ function in_isolated_world() {
+ let f = document.querySelector('iframe');
+ let w = f.contentWindow;
+ let docRegistry = w.document.customElementRegistry;
+ w.postMessage(
+ `document.customElementRegistry=${docRegistry}`,
+ '*');
+ }
+
+ var p = new Promise((resolve) => {
+ w.addEventListener('message', t.step_func((event) => {
+ assert_equals(event.data,
+ 'document.customElementRegistry=null',
+ 'document.customElementRegistry should be null ' +
+ 'in isolated worlds');
+ resolve();
+ }));
+ });
+
+ testRunner.evaluateScriptInIsolatedWorld(
+ 1,
+ `(${in_isolated_world.toString()})();`);
+
+ return p;
+ });
+}, 'No document.customElementRegistry in isolated worlds');
+
+promise_test((t) => {
+ assert_true(!!window.testRunner, 'Requires testRunner.evaluateScriptInIsolatedWorld');
+ return create_window_in_test(t)
+ .then((w) => {
+ function in_isolated_world() {
+ let f = document.querySelector('iframe');
+ let w = f.contentWindow;
+ let el = w.document.createElement('div');
+ w.document.body.appendChild(el);
+ let elRegistry = el.customElementRegistry;
+ w.postMessage(
+ `element.customElementRegistry=${elRegistry}`,
+ '*');
+ }
+
+ var p = new Promise((resolve) => {
+ w.addEventListener('message', t.step_func((event) => {
+ assert_equals(event.data,
+ 'element.customElementRegistry=null',
+ 'element.customElementRegistry should be null ' +
+ 'in isolated worlds');
+ resolve();
+ }));
+ });
+
+ testRunner.evaluateScriptInIsolatedWorld(
+ 1,
+ `(${in_isolated_world.toString()})();`);
+
+ return p;
+ });
+}, 'No element.customElementRegistry in isolated worlds');
+
+promise_test((t) => {
+ return create_window_in_test(t)
+ .then((w) => {
+ // In the main world, document.customElementRegistry should still work.
+ assert_not_equals(w.document.customElementRegistry, null,
+ 'document.customElementRegistry should be accessible in main world');
+ assert_equals(w.document.customElementRegistry,
+ w.customElements,
+ 'document.customElementRegistry should equal ' +
+ 'window.customElements in main world');
+ });
+}, 'document.customElementRegistry accessible in main world');
+
+promise_test((t) => {
+ assert_true(!!window.testRunner, 'Requires testRunner.evaluateScriptInIsolatedWorld');
+ return create_window_in_test(t)
+ .then((w) => {
+ // Create a scoped registry and attach it to a shadow root in main world.
+ let scopedRegistry = new w.CustomElementRegistry();
+ let host = w.document.createElement('div');
+ w.document.body.appendChild(host);
+ let shadow = host.attachShadow({
+ mode: 'open',
+ customElementRegistry: scopedRegistry
+ });
+ let innerEl = w.document.createElement('span', {
+ customElementRegistry: scopedRegistry
+ });
+ shadow.appendChild(innerEl);
+
+ // Sanity check: verify scoped registry is actually attached in main world
+ // before testing isolation.
+ assert_equals(shadow.customElementRegistry, scopedRegistry,
+ 'shadow root should have the scoped registry in main world');
+ assert_equals(innerEl.customElementRegistry, scopedRegistry,
+ 'element created with scoped registry should use it in main world');
+
+ function in_isolated_world() {
+ let f = document.querySelector('iframe');
+ let w = f.contentWindow;
+ // Find the shadow host we created in main world.
+ let host = w.document.body.querySelector('div');
+ let shadow = host.shadowRoot;
+ let innerEl = shadow.querySelector('span');
+ // Try to access scoped registry from isolated world.
+ let shadowRegistry = shadow.customElementRegistry;
+ let elRegistry = innerEl.customElementRegistry;
+ w.postMessage(
+ `shadow.customElementRegistry=${shadowRegistry}` +
+ `|element.customElementRegistry=${elRegistry}`,
+ '*');
+ }
+
+ var p = new Promise((resolve) => {
+ w.addEventListener('message', t.step_func((event) => {
+ assert_equals(event.data,
+ 'shadow.customElementRegistry=null' +
+ '|element.customElementRegistry=null',
+ 'scoped registry should not be accessible ' +
+ 'from isolated worlds');
+ resolve();
+ }));
+ });
+
+ testRunner.evaluateScriptInIsolatedWorld(
+ 1,
+ `(${in_isolated_world.toString()})();`);
+
+ return p;
+ });
+}, 'No scoped customElementRegistry in isolated worlds');
+
+})();
+</script>
diff --git a/third_party/blink/web_tests/virtual/scoped-custom-element-registry-disabled/custom-elements/scoped-registry-isolated-worlds-expected.txt b/third_party/blink/web_tests/virtual/scoped-custom-element-registry-disabled/custom-elements/scoped-registry-isolated-worlds-expected.txt
new file mode 100644
index 0000000..1e4fcf2b
--- /dev/null
+++ b/third_party/blink/web_tests/virtual/scoped-custom-element-registry-disabled/custom-elements/scoped-registry-isolated-worlds-expected.txt
@@ -0,0 +1,11 @@
+This is a testharness.js-based test.
+[FAIL] No document.customElementRegistry in isolated worlds
+ assert_equals: document.customElementRegistry should be null in isolated worlds expected "document.customElementRegistry=null" but got "document.customElementRegistry=undefined"
+[FAIL] No element.customElementRegistry in isolated worlds
+ assert_equals: element.customElementRegistry should be null in isolated worlds expected "element.customElementRegistry=null" but got "element.customElementRegistry=undefined"
+[FAIL] document.customElementRegistry accessible in main world
+ assert_equals: document.customElementRegistry should equal window.customElements in main world expected (object) object "[object CustomElementRegistry]" but got (undefined) undefined
+[FAIL] No scoped customElementRegistry in isolated worlds
+ promise_test: Unhandled rejection with value: object "TypeError: Failed to construct 'CustomElementRegistry': Illegal constructor"
+Harness: the test ran to completion.
+
Original Bug Report
Cross-world isolation bypass via Scoped Custom Element Registry
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A vulnerability in Blink’s Scoped Custom Element Registry (SCER) allows isolated worlds to access the global custom element registry. Combined with a shared promise cache, this enables the leak of JavaScript constructor functions across world boundaries, potentially bypassing world isolation and CSP.
Affected files:
third_party/blink/renderer/core/html/custom/custom_element_registry.ccthird_party/blink/renderer/core/frame/local_dom_window.ccthird_party/blink/renderer/core/dom/tree_scope.ccthird_party/blink/renderer/core/dom/element.ccthird_party/blink/renderer/core/dom/document_or_shadow_root.idlthird_party/blink/renderer/core/dom/element.idl
Estimated timestamp from git blame: 2025-04-14
Summary
A potential vulnerability has been identified in the implementation of the Scoped Custom Element Registry (SCER) feature in Blink. The issue allows for a deterministic Cross-DOMWrapperWorld isolation bypass, enabling arbitrary JavaScript execution between the main world (the page) and isolated worlds (e.g., extension content scripts). This arises from missing world-isolation guards in new SCER accessors and the use of a world-shared promise cache in whenDefined().
Technical Details
Bug A: Missing Accessor Guard
By design, window.customElements is guarded to prevent access from isolated worlds by returning nullptr if the caller is not the main world (see LocalDOMWindow::customElements(ScriptState*) in third_party/blink/renderer/core/frame/local_dom_window.cc).
However, the SCER feature introduced a new customElementRegistry attribute on Document and Element (defined in third_party/blink/renderer/core/dom/document_or_shadow_root.idl). This accessor calls TreeScope::customElementRegistry(), which eventually invokes the no-argument overload of LocalDOMWindow::customElements(). This overload lacks the IsMainWorld() check, allowing an isolated world to obtain the global CustomElementRegistry instance used by the main world.
Bug B: Cross-World Constructor Leak
The CustomElementRegistry maintains a when_defined_promise_map_ to cache promises for custom element names. This map is shared across all DOMWrapperWorlds. When a world (e.g., an extension) calls whenDefined('my-element'), a ScriptPromiseResolver is created in that world and stored in the global registry’s map.
When the element is subsequently defined (e.g., by the main page), the registry resolves the pending resolver using the defining world’s constructor. The resolution process uses ToV8Traits<V8CustomElementConstructor>::ToV8, which in release builds lacks the necessary world-safety checks (the DCHECK in third_party/blink/renderer/bindings/core/v8/to_v8_traits.h is removed). This allows a raw JavaScript function (the constructor) from the main world to be leaked to the isolated world.
Potential Impact
An attacker who obtains a cross-world v8::Function (constructor) can access the target world’s global Function constructor via the prototype chain (constructor.constructor). This enables:
- Page to Extension: A malicious site can execute code in an extension’s context if the extension uses
document.customElementRegistry.whenDefined(). - Extension to Page: A content script can execute code in the main world, bypassing Content Security Policy (CSP).
Potential Steps to Reproduce
- From an extension content script, call
let p = document.customElementRegistry.whenDefined('x-target');. - From the main world page, define the element:
customElements.define('x-target', class extends HTMLElement {});. - In the content script, once
presolves, access the main world’s context via the resolved constructor:p.then(ctor => ctor.constructor('alert(document.domain)')());.
Suggested Fix
- Update
DocumentOrShadowRoot.idlandElement.idlto include[CallWith=ScriptState]for thecustomElementRegistryattribute. - In
TreeScope::customElementRegistry(ScriptState*), enforce anIsMainWorld()check when returning the global registry. - Ensure
CustomElementRegistry::when_defined_promise_map_is world-isolated, or verify world compatibility before resolving promises inCustomElementRegistry::define.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.