← Chrome — 2026-W05

6914696683f3f2b348bb343046047241f317c14b  [scoped-registry] Implement keep custom element registry null in shadow root

severity low class LogicError CVSS 3.1 confidence 0.3 signal score 6.8 third_party/blink/renderer/core/ AI review CONFIRMED
Primitive: Shadow root with a deliberately-null custom element registry could wrongly acquire the global registry on document adoption.
Triage note: Implements the spec 'keep custom element registry null' variable so a declarative shadow root created with shadowrootcustomelementregistry stays null-registry after adoption instead of being handed the effective global registry (fixes a failing WPT). Registry-confusion has a mild isolation angle, but it is gated behind the experimental ScopedCustomElementRegistryEnabled flag and is primarily spec-correctness. Lead, not exploitable-grade.
Structural signals: security_assert (+3.0), area:third_party/blink/renderer/core/ (+2.5), surgical (+1.3)

The bug at a glance

This is a low-severity correctness/spec-conformance fix with a mild isolation angle. The bug: a declarative shadow root that opted into a deliberately-null scoped custom element registry (via the shadowrootcustomelementregistry attribute, with no registry actually initialized) could be wrongly assigned the document’s effective global custom element registry when the tree was adopted into another document. That defeats the author’s intent to keep the shadow root’s registry null, i.e. it collapses an intended scoping/isolation boundary between a scoped-registry subtree and the global registry. The practical impact is limited: it only affects the experimental Scoped Custom Element Registry feature (RuntimeEnabledFeatures::ScopedCustomElementRegistryEnabled), it changes which registry upgrades/defines custom elements within a shadow tree rather than crossing an origin or process boundary, and there is no memory-safety component. The worst realistic outcome is that custom elements in a subtree the author intended to keep unmanaged get upgraded by the global registry after adoption, which is a same-origin behavioral/isolation correctness issue, not a disclosure or corruption primitive. Hence low, CVSS left null; kept as watch (not noise) because it strengthens a scoping invariant and touches DOM adoption, a historically bug-prone area.

The DOM spec defines a shadow root variable ‘keep custom element registry null’. Before this CL, Blink approximated that variable with a heuristic in tree_scope_adopter: ‘if the shadow root has no registry AND it is not a declarative shadow root, adopt the global registry.’ A TODO (crbug.com/399124619) explicitly flagged that using IsDeclarativeShadowRoot() as a proxy was inaccurate. The gap: a declarative shadow root created WITH shadowrootcustomelementregistry but WITHOUT an initialized registry is supposed to stay null forever (the author asked for a scoped, currently-empty registry). The old heuristic could not distinguish ‘intentionally null scoped registry’ from ‘default null’, so on adoption it handed the subtree the global registry, silently violating the intended scoping. The fix implements the real spec variable as a bit on ShadowRoot and consults it during adoption and cloning.

Root cause

ShadowRoot gains a 1-bit flag keep_custom_element_registry_null_ with SetKeepCustomElementRegistryNull(bool) / ShouldKeepCustomElementRegistryNull(). The setter DCHECKs that when set true the registry is currently null (an invariant: you only keep-null a root that has no registry). Element parsing of a declarative shadow root now sets this flag to true when the shadowrootcustomelementregistry attribute is present (waiting_for_scoped_registry) and the feature is enabled — step 10.8.8 of the declarative shadow attachment. Node cloning copies the flag from source to clone (step 6.6) so cloned scoped-null shadow roots preserve the intent. Most importantly, TreeScopeAdopter::MoveTreeToNewDocument, which on adoption assigns a shadow root the new document’s effective global custom element registry, replaces the inaccurate ‘!IsDeclarativeShadowRoot()’ proxy with ‘!ShouldKeepCustomElementRegistryNull()’. Now a shadow root that explicitly wants a null registry is skipped by the global-registry assignment and stays null after adoption, matching the WPT expectation that was previously failing.

Key code

Adoption now consults the real ‘keep registry null’ variable instead of the IsDeclarativeShadowRoot() proxy.

// tree_scope_adopter.cc (MoveTreeToNewDocument)
auto* shadow_root_registry = shadow_root.customElementRegistry();
if (RuntimeEnabledFeatures::ScopedCustomElementRegistryEnabled() &&
    ((!shadow_root_registry &&
      !shadow_root.ShouldKeepCustomElementRegistryNull()) ||   // was: !IsDeclarativeShadowRoot()
     (shadow_root_registry && shadow_root_registry->IsGlobalRegistry()))) {
  shadow_root_registry = new_document.EffectiveGlobalCustomElementRegistry();
  shadow_root.SetCustomElementRegistry(shadow_root_registry);
}

// shadow_root.h
void SetKeepCustomElementRegistryNull(bool flag) {
  DCHECK(!flag || !customElementRegistry());  // only keep-null a null root
  keep_custom_element_registry_null_ = flag;
}
bool ShouldKeepCustomElementRegistryNull() const {
  return keep_custom_element_registry_null_;
}

Patch walkthrough

  • third_party/blink/renderer/core/dom/shadow_root.h — Adds the keep_custom_element_registry_null_ : 1 bitfield plus its setter/getter implementing the DOM spec ‘keep custom element registry null’ variable. The setter’s DCHECK encodes the invariant that the flag is only set on a root whose registry is currently null.
  • third_party/blink/renderer/core/dom/element.cc — Two spec steps: (1) cloning step 6.6 copies the flag from the source shadow root to the cloned shadow root so the null-registry intent survives node cloning; (2) declarative shadow attachment step 10.8.8 sets the flag to true when the shadowrootcustomelementregistry attribute is present (waiting_for_scoped_registry) and the ScopedCustomElementRegistry feature is enabled, recording author intent at parse time.
  • third_party/blink/renderer/core/dom/tree_scope_adopter.cc — The core fix. Removes the inaccurate IsDeclarativeShadowRoot() proxy (and its TODO referencing crbug.com/399124619) and gates the global-registry assignment on !ShouldKeepCustomElementRegistryNull(). A shadow root that explicitly wants a null registry is now left null on adoption instead of inheriting the new document’s global registry.
  • third_party/blink/web_tests/virtual/scoped-custom-element-registry/external/wpt/custom-elements/registries/element-mutation-expected.txt — Baseline update: the previously-failing subtest ‘Declarative shadow DOM with shadowrootcustomelementregistry attribute without registry initialized should remain null registry after adoption’ now passes, so the expectation file changes from a recorded FAIL (expected [object CustomElementRegistry] but got null) to all-subtests-passing.

Background

Scoped custom element registry — An experimental feature (CustomElementRegistry instances attached per shadow root/document) letting a subtree resolve custom elements against its own registry instead of the global window.customElements. A null registry means the subtree has no custom-element upgrading of its own.

shadowrootcustomelementregistry attribute — A declarative shadow DOM attribute on the that opts the resulting shadow root into scoped-registry semantics. Present-but-uninitialized means the root should hold a null (scoped) registry, distinct from the default global registry.

keep custom element registry null (spec variable) — A per-shadow-root boolean in the DOM spec ensuring that a shadow root deliberately created with a null registry is not later assigned the effective global registry (e.g. during adoptNode/document adoption). This CL implements that variable.

Node adoption (TreeScopeAdopter) — When a node moves between documents (adoptNode, or moving into a connected tree), Blink’s TreeScopeAdopter rebinds document-scoped state, including which custom element registry a shadow root uses. This is where the incorrect global-registry assignment happened.

Effective global custom element registry — A document’s default CustomElementRegistry used when a subtree has no scoped registry. Assigning it to an intentionally-null scoped shadow root is precisely the isolation-collapsing behavior the fix prevents.

Vulnerability window

  1. known-imperfect — Blink implemented registry-on-adoption with an IsDeclarativeShadowRoot() heuristic and a TODO (crbug.com/399124619) noting it did not correctly model the spec’s ‘keep registry null’ variable.
  2. reported — Bug 448853877 and a failing WPT subtest: a declarative shadow root with shadowrootcustomelementregistry but no initialized registry wrongly acquired a registry after adoption.
  3. fix — 2026-01-26 (Jayson Chen, r1574985): add the ShadowRoot bit, set it at parse and clone time, consult it in adoption, and rebaseline the now-passing WPT.

Proof of concept

No exploit PoC is appropriate; this is a same-origin DOM scoping-correctness fix behind an experimental flag, with no memory-safety or cross-origin element. The behavior can be observed (not weaponized) with the WPT scenario: create a declarative shadow root using the shadowrootcustomelementregistry attribute without initializing a registry, adopt the containing node into another document, and check that the shadow root’s customElementRegistry remains null post-patch (pre-patch it became the global registry). The commit’s own rebaselined WPT subtest already encodes this observation, so no separate exploit code exists or should be invented.

Exploitation

  1. setup — Requires the experimental Scoped Custom Element Registry feature enabled. Author creates a declarative shadow root with the shadowrootcustomelementregistry attribute but no initialized registry, intending it to stay unmanaged (null).
  2. trigger — The subtree is adopted into another document (e.g. adoptNode or moving into a connected document). Pre-patch, adoption assigned the new document’s effective global registry to that shadow root.
  3. impact — Custom elements within the intended-null subtree could then be defined/upgraded by the global registry, collapsing the author’s scoping boundary. Impact is a same-origin behavioral/isolation correctness deviation, not disclosure or corruption; no privilege or origin boundary is crossed.

Detection & hunting

For defenders and detection engineers:

  • Unexpected non-null registry after adoption —
  • WPT registries suite —

Audit directions

  • Other paths that assign registries —
  • Flag propagation completeness —
  • DCHECK invariant robustness —

Before / after

Loading diff…