0ca06b10ef21e6760caeb7f06e8844e40c502266 [scoped-registry] Fix clone without children default registry
Triage note: Wrong-registry assignment is type-confusion-adjacent for custom element upgrades, but it is gated behind the experimental ScopedCustomElementRegistry flag and framed as pre-ship polish. Not reachable in default config.
The bug at a glance
This is a latent registry-confusion defect in a feature that had not yet shipped to stable (Scoped Custom Element Registry, gated behind –enable-blink-features=ScopedCustomElementRegistry). Cloning an element without children failed to propagate the document’s default scoped custom element registry to the clone, so the clone could later resolve custom-element definitions against a null/unexpected registry. In principle, resolving an element against the wrong set of definitions is a type-confusion class problem: an element could be upgraded (or fail to upgrade) using a definition that does not match the author’s intent, producing a JS object whose class/prototype disagrees with the element it is attached to. In practice the severity is low because (a) the feature is pre-stable and reachable only with the flag, (b) the observed symptom was a web-platform-test correctness failure (clone-contenteditable.html) rather than a crash or corruption, and (c) the mismatch is between author-registered JS definitions within the same document, not a memory-type mismatch in C++ that would corrupt the heap. It is honest watch-tier: a correctness bug with a plausible type-confusion latent-safety motive, patched specifically to de-risk shipping the feature (’to make sure we’re ready to ship scoped custom element registry’).
The angle is registry provenance on the clone path. Scoped custom element registries mean ‘what class does resolve to’ is answered by a per-scope registry rather than one global one. Any code path that constructs an element without threading through the correct registry becomes a place where the element’s future upgrade resolves against the wrong definition set. CloneWithoutChildren() was such a hole: it passed nullptr for the registry, so the cheap-clone path silently dropped the document’s default registry that the normal clone path would have supplied.
Root cause
Element::CloneWithoutChildren() is the shallow-clone entry point (used by contenteditable operations and other internal clone-without-subtree cases). It constructed a NodeCloningData and delegated to the two-argument CloneWithoutChildren(data, registry) with registry explicitly nullptr. When ScopedCustomElementRegistry is enabled, an element carries an associated CustomElementRegistry that determines how its tag name resolves to a definition. Passing nullptr meant the clone was created with no registry association, so it did not inherit the document’s default registry the way a full clone does. The clone-contenteditable.html web test exercised cloning an element without children and caught that the clone resolved against an unexpected registry/definition set. The fix looks up the document’s default registry via CustomElementRegistry::DefaultRegistry(GetDocument()) and passes that instead of nullptr, so the shallow-clone path now matches the registry-propagation behavior of the normal clone path. The companion VirtualTestSuites change adds the internal custom-elements/ test base (alongside external/wpt/custom-elements/) to the ScopedCustomElementRegistry virtual suite so the regression is covered under the flag.
Key code
element.cc: propagate the document’s default registry into the shallow-clone path instead of nullptr
Element& Element::CloneWithoutChildren() const {
NodeCloningData data;
- return CloneWithoutChildren(data, /*registry*/ nullptr);
+ auto* registry = CustomElementRegistry::DefaultRegistry(GetDocument());
+ return CloneWithoutChildren(data, registry);
}
Patch walkthrough
third_party/blink/renderer/core/dom/element.cc— In the no-argument Element::CloneWithoutChildren(), replaces the hard-coded nullptr registry argument with CustomElementRegistry::DefaultRegistry(GetDocument()). This ensures a shallow clone is associated with the same default scoped registry the source document uses, so any later custom-element resolution/upgrade of the clone consults the intended definition set rather than falling back to a null/unexpected registry.third_party/blink/web_tests/VirtualTestSuites— Adds “custom-elements/” to the bases of the virtual test suite that runs with –enable-blink-features=ScopedCustomElementRegistry, so Blink’s internal custom-elements tests (not just the imported WPTs) execute under the flag. This is the regression-coverage half of the fix, ensuring clone-without-children registry behavior stays tested as the feature moves toward stable.
Background
Scoped Custom Element Registry — A web platform feature letting a document (or shadow root) use its own CustomElementRegistry rather than the single global one, so the same tag name can map to different definitions in different scopes. Still behind a flag at the time of this commit.
Default registry / CustomElementRegistry::DefaultRegistry — The registry a document uses when no explicit scoped registry is supplied. New elements in that document should be associated with it so their upgrade resolves correctly.
CloneWithoutChildren (shallow clone) — A shallow copy of an element that omits its subtree, used internally (e.g. contenteditable splitting/wrapping). Being a fast path, it is exactly where per-element metadata like the associated registry is easy to forget to propagate.
Type confusion via registry mismatch — If an element upgrades using a definition from the wrong registry, the resulting JS wrapper’s class/prototype can disagree with what the element actually is, which is the web-facing analogue of type confusion; the concrete risk depends on what the two definitions expose.
Vulnerability window
- feature development — Scoped Custom Element Registry under development behind a Blink feature flag (crbug 40826514 tracks the effort); not shipped to stable.
- detected — The clone-contenteditable.html web test surfaced that a cloned-without-children element did not receive the correct default registry.
- fixed — Committed 2026-01-30 by Jayson Chen (Microsoft), r1577378, explicitly to de-risk shipping the feature; reviewed by Mason Freed.
Proof of concept
No exploit shipped and none is appropriate; the shipped test artifact is a virtual-suite base addition, not a standalone PoC. A correctness repro (flag-enabled build) would: define distinct custom-element behavior in the document’s default scoped registry, obtain an element that goes through the shallow CloneWithoutChildren path (e.g. via a contenteditable operation that clones an element without its children), and observe whether the clone upgrades/resolves against the intended default registry. Pre-fix the clone lacked the default registry association and could resolve against an unexpected (null/global) definition set; post-fix it resolves against the document default. This demonstrates the confusion condition, not a memory-safety exploit; no such exploit is known and the class ceiling here is web-visible behavior mismatch, not heap corruption.
Exploitation
- precondition — Requires the ScopedCustomElementRegistry feature enabled (not default), so real-world attack surface at commit time was effectively zero; relevant only prospectively as the feature ships.
- primitive — Best case for an attacker is inducing a clone to resolve against a definition the author did not intend, i.e. an element behaving as the wrong custom-element class within the same document.
- ceiling — Because both registries are author-supplied JS definitions in the same origin/document, this is a same-origin logic/type confusion at the web layer; it does not by itself grant cross-origin access or native type confusion. Any escalation would depend on a second bug in how a mismatched definition interacts with element internals.
Detection & hunting
For defenders and detection engineers:
- flag exposure —
- test regressions —
Audit directions
- all element-construction paths under scoped registries —
- DefaultRegistry fallback semantics —
- upgrade-time resolution —