Sanitizer API Safe-Mode Bypass in Streaming HTML Setters

#513128322 Filed 2026-05-14 Status: Accepted (P3 / S4) Assignee: nrosenthal@chromium.org

Summary

Blink's experimental Sanitizer API has a safe-mode contract: calling a safe HTML setter on a <script> (or svg:script) element must be a no-op so attacker text can never become live script source. The non-streaming setters (setHTML, appendHTML) enforce this through ParseHTMLFragment, which calls SanitizerAPI::AllowMutatingRootElement.

The streaming setters (streamHTML, streamAppendHTML, streamPrependHTML, ...) go through HTMLStream::Create in third_party/blink/renderer/core/html/html_stream.cc, which skipped the same check. The result: a safe-mode stream into a live <script> tokenizes as PLAINTEXT and the appended text node triggers ScriptLoader::ChildrenChanged and executes.

Affected

Behind the NewHTMLSettingMethods runtime flag (status: experimental). Requires --enable-experimental-web-platform-features today, so it is not shipped to stable users.

Reproducer

Run Chrome with --enable-experimental-web-platform-features then click the button below. The control (appendHTML) must be a no-op, the bypass (streamAppendHTML) is what we want to block.

How to launch a vulnerable build

out/Default/chrome \
  --enable-experimental-web-platform-features \
  https://static.januschka.com/i-513128322/

Output

(not run yet)

Expected vs Actual

Expected (fixed build)

Both appendHTML and streamAppendHTML are no-ops on a <script>. The streaming setter either rejects synchronously or leaves executed false. Result: NOT VULNERABLE.

Actual (current main, unfixed)

The streamed text appears inside the live <script> and runs. Result: VULNERABLE.

Root Cause

Two parallel parsing entry points, only one guarded:

// fragment_parser.cc (non-streaming) -- has the guard
if (!SanitizerAPI::AllowMutatingRootElement(config.sanitizer_mode,
                                            context_element)) {
  return;  // no-op on <script>
}

// html_stream.cc HTMLStream::Create (streaming) -- guard missing
if (!target->IsElementNode() && !target->IsShadowRoot()) {
  ...throw HierarchyRequestError...
}
// (no AllowMutatingRootElement check here)

Fix

Mirror the non-streaming guard in HTMLStream::Create. When the target is a script element in safe mode, throw NoModificationAllowedError and return early.

if (!SanitizerAPI::AllowMutatingRootElement(sanitizer_mode, target)) {
  exception_state.ThrowDOMException(
      DOMExceptionCode::kNoModificationAllowedError,
      "Cannot stream safely into a script element");
  return nullptr;
}

Red / Green

Red WPT (failing on unfixed main)

external/wpt/domparsing/tentative/stream-html-script-safe.html - streams window.executed = true into a live <script> via each of streamHTML, streamAppendHTML, streamPrependHTML, streamBeforeHTML, streamAfterHTML, streamReplaceWithHTML and asserts the global stays false.

[FAIL] streamHTML        ... expected false got true
[FAIL] streamAppendHTML  ... expected false got true
[FAIL] streamPrependHTML ... expected false got true

Green after fix in html_stream.cc

$ third_party/blink/tools/run_web_tests.py -t Default --no-retry-failures \
    external/wpt/domparsing/tentative/stream-html-script-safe.html
All 1 test ran as expected.

$ third_party/blink/tools/run_web_tests.py -t Default \
    external/wpt/domparsing/tentative/
All 23 tests ran as expected.

Links

Issue 513128322 CL 7849074 (assignee)