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.
Behind the NewHTMLSettingMethods runtime flag (status:
experimental). Requires
--enable-experimental-web-platform-features today, so it is not
shipped to stable users.
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.
out/Default/chrome \
--enable-experimental-web-platform-features \
https://static.januschka.com/i-513128322/
Both appendHTML and streamAppendHTML are no-ops on a
<script>. The streaming setter either rejects synchronously
or leaves executed false. Result: NOT VULNERABLE.
The streamed text appears inside the live <script> and runs.
Result: VULNERABLE.
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)
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;
}
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
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.