CVE-2026-14083
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html |
modified |
Files Changed
third_party/blink/renderer/core/html/html_stream.ccthird_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html
Patch
From 9859a31c6c9707b09e0a6e22d49a41301e21e4ee Mon Sep 17 00:00:00 2001 From: Noam Rosenthal <[email protected]> Date: Wed, 27 May 2026 02:06:08 -0700 Subject: [PATCH] Disallow script mutation with safe streamHTML* Bug: 513128322 Change-Id: Ic7fcb986f65a994c3948afe9912de231a0f35293 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7849074 Reviewed-by: Philip Jägenstedt <[email protected]> Commit-Queue: Noam Rosenthal <[email protected]> Reviewed-by: Daniel Vogelheim <[email protected]> Cr-Commit-Position: refs/heads/main@{#1636775} --- diff --git a/third_party/blink/renderer/core/html/html_stream.cc b/third_party/blink/renderer/core/html/html_stream.cc index 0296bfe..678190f 100644 --- a/third_party/blink/renderer/core/html/html_stream.cc +++ b/third_party/blink/renderer/core/html/html_stream.cc @@ -157,6 +157,13 @@ return nullptr; } + if (!SanitizerAPI::AllowMutatingRootElement(sanitizer_mode, target)) { + exception_state.ThrowDOMException( + DOMExceptionCode::kNoModificationAllowedError, + "Cannot stream safely into a script element"); + return nullptr; + } + std::optional<FragmentParserOptions> trusted_options = sanitizer_mode == Sanitizer::Mode::kSafe ? std::make_optional(options) diff --git a/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html new file mode 100644 index 0000000..de1f08d9 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html @@ -0,0 +1,49 @@ +<!doctype html> +<meta charset="utf-8" /> +<title>Sanitizer API safe streaming setters should not execute scripts when targeting script elements</title> +<link rel="help" href="https://github.com/WICG/declarative-partial-updates" /> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<body> +<script> + async function test_safe_setter_on_script(method, setup_target) { + promise_test(async (t) => { + const script = document.createElement("script"); + document.body.appendChild(script); + t.add_cleanup(() => script.remove()); + + const target = setup_target ? setup_target(script) : script; + const var_name = `executed_${method}`; + window[var_name] = false; + + let writable; + try { + writable = target[method](); + } catch (e) { + // If the safe setter throws an exception when targeting a script element, + // that is a valid safe-mode behavior. + assert_false(window[var_name], "Script should not execute"); + return; + } + + assert_true(writable instanceof WritableStream); + const writer = writable.getWriter(); + await writer.write(`window.${var_name} = true;`); + await writer.close(); + + assert_false(window[var_name], `Streaming content into a script element using safe ${method} should not execute the script`); + }, `Safe streaming setter ${method} targeting a script element should not allow script execution`); + } + + for (const method of ["streamHTML", "streamAppendHTML", "streamPrependHTML"]) { + test_safe_setter_on_script(method); + } + + for (const method of ["streamBeforeHTML", "streamAfterHTML", "streamReplaceWithHTML"]) { + test_safe_setter_on_script(method, (s) => { + s.appendChild(document.createTextNode("/* setup */")); + return s.firstChild; + }); + } +</script> +</body>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html
new file mode 100644
index 0000000..de1f08d9
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-script-safe.html
@@ -0,0 +1,49 @@
+<!doctype html>
+<meta charset="utf-8" />
+<title>Sanitizer API safe streaming setters should not execute scripts when targeting script elements</title>
+<link rel="help" href="https://github.com/WICG/declarative-partial-updates" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body>
+<script>
+ async function test_safe_setter_on_script(method, setup_target) {
+ promise_test(async (t) => {
+ const script = document.createElement("script");
+ document.body.appendChild(script);
+ t.add_cleanup(() => script.remove());
+
+ const target = setup_target ? setup_target(script) : script;
+ const var_name = `executed_${method}`;
+ window[var_name] = false;
+
+ let writable;
+ try {
+ writable = target[method]();
+ } catch (e) {
+ // If the safe setter throws an exception when targeting a script element,
+ // that is a valid safe-mode behavior.
+ assert_false(window[var_name], "Script should not execute");
+ return;
+ }
+
+ assert_true(writable instanceof WritableStream);
+ const writer = writable.getWriter();
+ await writer.write(`window.${var_name} = true;`);
+ await writer.close();
+
+ assert_false(window[var_name], `Streaming content into a script element using safe ${method} should not execute the script`);
+ }, `Safe streaming setter ${method} targeting a script element should not allow script execution`);
+ }
+
+ for (const method of ["streamHTML", "streamAppendHTML", "streamPrependHTML"]) {
+ test_safe_setter_on_script(method);
+ }
+
+ for (const method of ["streamBeforeHTML", "streamAfterHTML", "streamReplaceWithHTML"]) {
+ test_safe_setter_on_script(method, (s) => {
+ s.appendChild(document.createTextNode("/* setup */"));
+ return s.firstChild;
+ });
+ }
+</script>
+</body>
Original Bug Report
Sanitizer API Safe-Mode Bypass in Streaming HTML Setters
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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The Sanitizer API’s safe-mode implementation potentially fails to prevent the mutation of script elements when using streaming setters like streamHTML. This allows attacker-controlled text to be inserted into a live <script> element and executed as JavaScript, leading to an XSS vulnerability. The issue arises from a missing validation check in the HTMLStream::Create function.
Affected files:
third_party/blink/renderer/core/html/html_stream.ccthird_party/blink/renderer/core/dom/container_node.ccthird_party/blink/renderer/core/sanitizer/sanitizer_api.ccthird_party/blink/renderer/core/html/parser/html_construction_site.cc
Estimated timestamp from git blame: 2026-03-19
Summary
A potential security vulnerability has been identified in the experimental Sanitizer API’s safe-mode implementation. While non-streaming HTML setters (such as setHTML and appendHTML) correctly validate and block operations targeting <script> or <svg:script> elements in safe-mode, the streaming counterparts (streamHTML, streamAppendHTML, and streamPrependHTML) omit this critical check. This oversight allows an attacker to achieve Cross-Site Scripting (XSS) by streaming content into a live script element.
Root Cause Analysis
The Sanitizer API’s safe-mode contract is designed to ensure that calling a safe setter on a <script> target is a no-op. This is enforced via SanitizerAPI::AllowMutatingRootElement in third_party/blink/renderer/core/sanitizer/sanitizer_api.cc.
Standard safe setters correctly funnel through ParseHTMLFragment in third_party/blink/renderer/core/html/parser/fragment_parser.cc, which applies this guard. However, the WritableStream safe setters (implemented in third_party/blink/renderer/core/dom/container_node.cc) instead use HTMLStream::Create, which currently fails to call AllowMutatingRootElement or perform an equivalent validation on the target element.
In third_party/blink/renderer/core/html/html_stream.cc at HTMLStream::Create (line 134):
if (!target->IsElementNode() && !target->IsShadowRoot()) {
exception_state.ThrowDOMException(DOMExceptionCode::kHierarchyRequestError,
"Cannot stream before/after a node that "
"is not an element or shadow root");
return nullptr;
}
// Missing SanitizerAPI::AllowMutatingRootElement(sanitizer_mode, target) check here.
Potential Attack Scenario
- An application uses the Sanitizer API with streaming setters (e.g.,
element.streamAppendHTML()). Note: These features are currently experimental and require--enable-experimental-web-platform-features. - An attacker influences the application logic or uses DOM clobbering to ensure the target for the stream is a
<script>element. - The application opens a stream to the script element using the safe-mode setter.
- The attacker writes a payload to the stream (e.g.,
alert(document.domain)). - Because the target is a script tag, the HTML parser enters
kPLAINTEXTState, treating all input as literal text. - The
StreamingSanitizerallows text nodes in all modes. - The payload is appended directly into the live
<script>element in the DOM. - This child insertion triggers
HTMLScriptElement::ChildrenChanged, which executes the appended content as JavaScript.
Impact
This leads to Universal Cross-Site Scripting (UXSS) in any application utilizing the experimental Sanitizer API’s streaming methods in a way that an attacker can control the destination element. This bypasses the API’s fundamental security guarantee that safe-mode setters will not execute scripts.
Suggested Fix
In third_party/blink/renderer/core/html/html_stream.cc, update HTMLStream::Create to call SanitizerAPI::AllowMutatingRootElement(sanitizer_mode, target). If the check returns false, the function should return nullptr (and optionally throw a DOMException), mirroring the behavior of the non-streaming ParseHTMLFragment implementation.
Note: This analysis was performed using static code review; an active proof-of-concept has not been executed.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
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.