CVE-2026-113284
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/xml/parser/xml_document_parser.cc |
modified | |
FinishResponseListenerthird_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc |
modified | |
forthird_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc |
modified | |
ifthird_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc |
modified | |
XMLDocumentParserSimTestthird_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/xml/parser/xml_document_parser.hthird_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.ccthird_party/blink/renderer/core/xml/build.gnithird_party/blink/renderer/core/xml/parser/xml_document_parser.cc
Patch
From 14df19403b0358d99e58b0229df916db6372e56f Mon Sep 17 00:00:00 2001 From: Emmanuele Bassi <[email protected]> Date: Fri, 18 Sep 2026 09:36:37 -0700 Subject: [PATCH] Defer XMLDocumentParser::Finish() while inside ParseChunk XMLDocumentParser buffers data that arrives while ParseChunk() is running into pending_src_, but a Finish() call received during this state—such as when a navigation body load completes synchronously under a running script—previously invoked end() immediately and re-entered the parser before the active chunk finished processing. Treat an in-chunk Finish() call like the paused parser case by recording it in finish_called_ and allowing DoWrite() to process completion after the current chunk and any buffered data are parsed. Additionally, reset finish_called_ in end() so that DoWrite() and ResumeParsing() do not process the same deferred finish twice. Bug: https://issues.chromium.org/issues/553121395#comment8 Change-Id: I8dca1106bf642003351bf56feb15f71c3b0e163e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8419603 Reviewed-by: Dominik Röttsches <[email protected]> Reviewed-by: Joey Arhar <[email protected]> Commit-Queue: Joey Arhar <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700858} --- diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc index f41a90c..ffe3a59 100644 --- a/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc +++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser.cc @@ -492,6 +492,10 @@ if (parser_paused_) return; + // Any deferred Finish() is carried out below; reset the flag so that + // DoWrite() and ResumeParsing() do not call end() a second time. + finish_called_ = false; + // StopParsing() calls InsertErrorMessageBlock() if there was a parsing // error. Avoid showing the error message block twice. // TODO(crbug.com/898775): Rationalize this. @@ -525,10 +529,14 @@ if (IsDetached()) return; - if (parser_paused_) + // If ParseChunk() is on the stack, ending the document now would re-enter + // libxml2 (see DoWrite()). Defer exactly like the paused case; DoWrite() + // honors finish_called_ once the current chunk has been parsed. + if (parser_paused_ || in_parse_chunk_) { finish_called_ = true; - else + } else { end(); + } } void XMLDocumentParser::InsertErrorMessageBlock() { @@ -976,35 +984,59 @@ // Protect the libxml context from deletion during a callback scoped_refptr<XMLParserContext> context = context_; - // libxml2's push parser is not re-entrant: xmlParseEndTag2 holds multiple - // raw pointers inside ctxt, and a nested xmlParseChunk can xmlRealloc() - // those buffers. Crash safely rather than corrupt the heap. (Append() - // routes re-entrant data to pending_src_ so this should be unreachable.) - CHECK(!in_parse_chunk_); - base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true); + { + // libxml2's push parser is not re-entrant: xmlParseEndTag2 holds multiple + // raw pointers inside ctxt, and a nested xmlParseChunk can xmlRealloc() + // those buffers. Crash safely rather than corrupt the heap. (Append() + // routes re-entrant data to pending_src_ and Finish() defers to + // finish_called_, so this should be unreachable.) + CHECK(!in_parse_chunk_); + base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true); - // libXML throws an error if you try to switch the encoding for an empty - // string. - if (parse_string.length()) { - XMLDocumentParserScope scope(GetDocument()); - base::AutoReset<bool> encoding_scope(&is_currently_parsing8_bit_chunk_, - parse_string.Is8Bit()); - ParseChunk(context->Context(), parse_string); + // libXML throws an error if you try to switch the encoding for an empty + // string. + if (parse_string.length()) { + XMLDocumentParserScope scope(GetDocument()); + base::AutoReset<bool> encoding_scope(&is_currently_parsing8_bit_chunk_, + parse_string.Is8Bit()); + ParseChunk(context->Context(), parse_string); - // JavaScript (which may be run under the parseChunk callstack) may - // cause the parser to be stopped or detached. - if (IsStopped()) - return; + // JavaScript (which may be run under the parseChunk callstack) may + // cause the parser to be stopped or detached. + if (IsStopped() && !finish_called_) { + return; + } + } + + // FIXME: Why is this here? And why is it after we process the passed + // source? + if (!IsStopped() && GetDocument()->SawDecodingError()) { + // If the decoder saw an error, report it as fatal (stops parsing) + TextPosition position( + OrdinalNumber::FromOneBasedInt(context->Context()->input->line), + OrdinalNumber::FromOneBasedInt(context->Context()->input->col)); + HandleError(XMLErrors::kErrorTypeFatal, "Encoding error", position); + } } - // FIXME: Why is this here? And why is it after we process the passed - // source? - if (GetDocument()->SawDecodingError()) { - // If the decoder saw an error, report it as fatal (stops parsing) - TextPosition position( - OrdinalNumber::FromOneBasedInt(context->Context()->input->line), - OrdinalNumber::FromOneBasedInt(context->Context()->input->col)); - HandleError(XMLErrors::kErrorTypeFatal, "Encoding error", position); + if (IsDetached() || parser_paused_) { + return; + } + + // Data appended and a Finish() call received while ParseChunk() was on the + // stack above were deferred; take care of them now that the chunk has been + // parsed. + if (!pending_src_.IsEmpty()) { + SegmentedString rest = pending_src_; + pending_src_.Clear(); + Append(rest.ToString()); + if (IsDetached() || parser_paused_) { + return; + } + } + + if (finish_called_ && pending_callbacks_.empty()) { + end(); } } @@ -1811,7 +1843,10 @@ void XMLDocumentParser::DoEnd() { if (!IsStopped()) { if (context_) { - // Tell libxml we're done. + // Tell libxml we're done. The terminating chunk may run SAX callbacks, + // so it needs the same re-entrancy protection as DoWrite(). + CHECK(!in_parse_chunk_); + base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true); { XMLDocumentParserScope scope(GetDocument()); FinishParsing(Context()); diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser.h b/third_party/blink/renderer/core/xml/parser/xml_document_parser.h index d6fa920c..4781ff6 100644 --- a/third_party/blink/renderer/core/xml/parser/xml_document_parser.h +++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser.h @@ -229,9 +229,9 @@ bool saw_first_element_; bool is_xhtml_document_; bool parser_paused_; - // Re-entrancy guard for DoWrite()/xmlParseChunk(). libxml2 push-parser - // contexts are not re-entrant; calling xmlParseChunk while already inside - // a SAX callback corrupts ctxt->pushTab/nsTab. + // Re-entrancy guard for DoWrite()/DoEnd()/xmlParseChunk(). libxml2 + // push-parser contexts are not re-entrant; calling xmlParseChunk while + // already inside a SAX callback corrupts ctxt->pushTab/nsTab. bool in_parse_chunk_ = false; bool requesting_script_; bool finish_called_; diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc new file mode 100644 index 0000000..2f659db --- /dev/null +++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc @@ -0,0 +1,237 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/renderer/core/dom/document.h" +#include "third_party/blink/renderer/core/dom/element.h" +#include "third_party/blink/renderer/core/dom/events/event.h" +#include "third_party/blink/renderer/core/dom/events/native_event_listener.h" +#include "third_party/blink/renderer/core/html/html_element.h" +#include "third_party/blink/renderer/core/testing/sim/sim_request.h" +#include "third_party/blink/renderer/core/testing/sim/sim_test.h" +#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h" +#include "third_party/blink/renderer/platform/wtf/text/strcat.h" +#include "third_party/blink/renderer/platform/wtf/text/string_builder.h" + +namespace blink { + +namespace { + +// Writes the given data (if any) to the response and then finishes it when +// an event is dispatched. Dispatching the event from an inline script lets a +// test deliver the end of the navigation body while XMLDocumentParser is +// still parsing the chunk that contains the script. +class FinishResponseListener final : public NativeEventListener { + public: + FinishResponseListener(SimRequest* response, const String& final_data) + : response_(response), final_data_(final_data) {} + + void Invoke(ExecutionContext*, Event*) override { + EXPECT_FALSE(was_invoked_); + was_invoked_ = true; + if (!final_data_.empty()) { + response_->Write(final_data_); + } + response_->Finish(); + } + + bool WasInvoked() const { return was_invoked_; } + + private: + SimRequest* const response_; + const String final_data_; + bool was_invoked_ = false; +}; + +constexpr int kNestingDepth = 24; + +// An inline script that reports the end of the response and records the +// document's readyState right afterwards, followed by a deeply nested run of +// elements with namespace declarations so that plenty of markup remains to +// be parsed after the script. +String BuildChunkAfterExternalScript(bool close_document) { + StringBuilder markup; + markup.Append( + "<script>" + "document.dispatchEvent(new Event('finish-response'));" + "document.documentElement.setAttribute('data-ready-state'," + " document.readyState);" + "</script>"); + for (int i = 0; i < kNestingDepth; ++i) { + markup.Append("<e"); + markup.AppendNumber(i); + markup.Append(" xmlns:n"); + markup.AppendNumber(i); + markup.Append("=\"http://example.com/ns/"); + markup.AppendNumber(i); + markup.Append("\">"); + } + markup.Append("<p id=\"last\">x</p>"); + for (int i = kNestingDepth - 1; i >= 0; --i) { + markup.Append("</e"); + markup.AppendNumber(i); + markup.Append(">"); + } + if (close_document) { + markup.Append("</body></html>"); + } + return markup.ToString(); +} + +class XMLDocumentParserSimTest : public SimTest {}; + +// The response of an XHTML navigation may finish while the parser is in the +// middle of a chunk, e.g. when an inline script run by the parser causes the +// remainder of the body to be consumed. The parser must defer the completion +// until the chunk has been fully parsed instead of ending the document from +// inside the script. +TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScript) { + SimRequest main_resource("https://example.com/test.xhtml", + "application/xhtml+xml"); + SimSubresourceRequest external_script("https://example.com/ext.js", + "text/javascript"); + + LoadURL("https://example.com/test.xhtml"); + + // The external script pauses parsing until it has loaded. + main_resource.Write( + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>" + "<script src=\"ext.js\"></script>"); + + auto* listener = MakeGarbageCollected<FinishResponseListener>( + &main_resource, /*final_data=*/String()); + GetDocument().addEventListener(AtomicString("finish-response"), listener); + + // Buffered while the parser waits for the external script. + main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/true)); + + // Resumes parsing; the buffered chunk above is parsed in one go and the + // inline script finishes the response from within it. + external_script.Complete(""); + test::RunPendingTasks(); + + EXPECT_TRUE(listener->WasInvoked()); + + // The document must not have been finished while the inline script was + // still running. + EXPECT_EQ(GetDocument().documentElement()->getAttribute( + AtomicString("data-ready-state")), + "loading"); + + // The markup following the inline script must have been parsed normally. + Element* last = GetDocument().getElementById(AtomicString("last")); + ASSERT_TRUE(last); + ASSERT_TRUE(last->parentElement()); + EXPECT_EQ(last->parentElement()->localName(), + StrCat({"e", String::Number(kNestingDepth - 1)})); + + // The deferred completion must still finish the document. + EXPECT_TRUE(GetDocument().LoadEventFinished()); +} + +// Same as above, but the final piece of the body arrives together with the +// completion while the parser is inside the chunk. The data must be parsed +// before the document is finished. +TEST_F(XMLDocumentParserSimTest, FinalDataAndLoadCompletionDuringInlineScript) { + SimRequest main_resource("https://example.com/test.xhtml", + "application/xhtml+xml"); + SimSubresourceRequest external_script("https://example.com/ext.js", + "text/javascript"); + + LoadURL("https://example.com/test.xhtml"); + + main_resource.Write( + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>" + "<script src=\"ext.js\"></script>"); + + auto* listener = MakeGarbageCollected<FinishResponseListener>( + &main_resource, /*final_data=*/"<p id=\"tail\">y</p></body></html>"); + GetDocument().addEventListener(AtomicString("finish-response"), listener); + + main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/false)); + + external_script.Complete(""); + test::RunPendingTasks(); + + EXPECT_TRUE(listener->WasInvoked()); + + EXPECT_EQ(GetDocument().documentElement()->getAttribute( + AtomicString("data-ready-state")), + "loading"); + + Element* last = GetDocument().getElementById(AtomicString("last")); + ASSERT_TRUE(last); + ASSERT_TRUE(last->parentElement()); + EXPECT_EQ(last->parentElement()->localName(), + StrCat({"e", String::Number(kNestingDepth - 1)})); + + // The data received together with the completion must have been parsed. + Element* tail = GetDocument().getElementById(AtomicString("tail")); + ASSERT_TRUE(tail); + EXPECT_EQ(tail->parentElement(), GetDocument().body()); + + EXPECT_TRUE(GetDocument().LoadEventFinished()); +} + +// Like LoadCompletionDuringInlineScript, but the markup after the inline +// script is not well-formed: a stray end tag followed by a deep run of +// unclosed elements with namespace declarations. The response finishing +// during the inline script must not end the document from inside the chunk; +// the parser has to fail the document in an orderly way afterwards. +TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScriptBadMarkup) { + SimRequest main_resource("https://example.com/test.xhtml", + "application/xhtml+xml"); + SimSubresourceRequest external_script("https://example.com/ext.js", + "text/javascript"); + + LoadURL("https://example.com/test.xhtml"); + + main_resource.Write( + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>" + "<script src=\"ext.js\"></script>"); + + auto* listener = MakeGarbageCollected<FinishResponseListener>( + &main_resource, /*final_data=*/String()); + GetDocument().addEventListener(AtomicString("finish-response"), listener); + + StringBuilder markup; + markup.Append( + "<script>" + "document.dispatchEvent(new Event('finish-response'));" + "document.documentElement.setAttribute('data-ready-state'," + " document.readyState);" + "</script>" + "</script>"); + for (int i = 0; i < kNestingDepth; ++i) { + markup.Append("<e"); + markup.AppendNumber(i); + markup.Append(" xmlns:n"); + markup.AppendNumber(i); + markup.Append("=\"http://example.com/ns/"); + markup.AppendNumber(i); + markup.Append("\">"); + } + main_resource.Write(markup.ToString()); + + external_script.Complete(""); + test::RunPendingTasks(); + + EXPECT_TRUE(listener->WasInvoked()); ... (file diff truncated)
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
new file mode 100644
index 0000000..2f659db
--- /dev/null
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser_sim_test.cc
@@ -0,0 +1,237 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/core/dom/document.h"
+#include "third_party/blink/renderer/core/dom/element.h"
+#include "third_party/blink/renderer/core/dom/events/event.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
+#include "third_party/blink/renderer/core/html/html_element.h"
+#include "third_party/blink/renderer/core/testing/sim/sim_request.h"
+#include "third_party/blink/renderer/core/testing/sim/sim_test.h"
+#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
+#include "third_party/blink/renderer/platform/wtf/text/strcat.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
+
+namespace blink {
+
+namespace {
+
+// Writes the given data (if any) to the response and then finishes it when
+// an event is dispatched. Dispatching the event from an inline script lets a
+// test deliver the end of the navigation body while XMLDocumentParser is
+// still parsing the chunk that contains the script.
+class FinishResponseListener final : public NativeEventListener {
+ public:
+ FinishResponseListener(SimRequest* response, const String& final_data)
+ : response_(response), final_data_(final_data) {}
+
+ void Invoke(ExecutionContext*, Event*) override {
+ EXPECT_FALSE(was_invoked_);
+ was_invoked_ = true;
+ if (!final_data_.empty()) {
+ response_->Write(final_data_);
+ }
+ response_->Finish();
+ }
+
+ bool WasInvoked() const { return was_invoked_; }
+
+ private:
+ SimRequest* const response_;
+ const String final_data_;
+ bool was_invoked_ = false;
+};
+
+constexpr int kNestingDepth = 24;
+
+// An inline script that reports the end of the response and records the
+// document's readyState right afterwards, followed by a deeply nested run of
+// elements with namespace declarations so that plenty of markup remains to
+// be parsed after the script.
+String BuildChunkAfterExternalScript(bool close_document) {
+ StringBuilder markup;
+ markup.Append(
+ "<script>"
+ "document.dispatchEvent(new Event('finish-response'));"
+ "document.documentElement.setAttribute('data-ready-state',"
+ " document.readyState);"
+ "</script>");
+ for (int i = 0; i < kNestingDepth; ++i) {
+ markup.Append("<e");
+ markup.AppendNumber(i);
+ markup.Append(" xmlns:n");
+ markup.AppendNumber(i);
+ markup.Append("=\"http://example.com/ns/");
+ markup.AppendNumber(i);
+ markup.Append("\">");
+ }
+ markup.Append("<p id=\"last\">x</p>");
+ for (int i = kNestingDepth - 1; i >= 0; --i) {
+ markup.Append("</e");
+ markup.AppendNumber(i);
+ markup.Append(">");
+ }
+ if (close_document) {
+ markup.Append("</body></html>");
+ }
+ return markup.ToString();
+}
+
+class XMLDocumentParserSimTest : public SimTest {};
+
+// The response of an XHTML navigation may finish while the parser is in the
+// middle of a chunk, e.g. when an inline script run by the parser causes the
+// remainder of the body to be consumed. The parser must defer the completion
+// until the chunk has been fully parsed instead of ending the document from
+// inside the script.
+TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScript) {
+ SimRequest main_resource("https://example.com/test.xhtml",
+ "application/xhtml+xml");
+ SimSubresourceRequest external_script("https://example.com/ext.js",
+ "text/javascript");
+
+ LoadURL("https://example.com/test.xhtml");
+
+ // The external script pauses parsing until it has loaded.
+ main_resource.Write(
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+ "<script src=\"ext.js\"></script>");
+
+ auto* listener = MakeGarbageCollected<FinishResponseListener>(
+ &main_resource, /*final_data=*/String());
+ GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+ // Buffered while the parser waits for the external script.
+ main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/true));
+
+ // Resumes parsing; the buffered chunk above is parsed in one go and the
+ // inline script finishes the response from within it.
+ external_script.Complete("");
+ test::RunPendingTasks();
+
+ EXPECT_TRUE(listener->WasInvoked());
+
+ // The document must not have been finished while the inline script was
+ // still running.
+ EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+ AtomicString("data-ready-state")),
+ "loading");
+
+ // The markup following the inline script must have been parsed normally.
+ Element* last = GetDocument().getElementById(AtomicString("last"));
+ ASSERT_TRUE(last);
+ ASSERT_TRUE(last->parentElement());
+ EXPECT_EQ(last->parentElement()->localName(),
+ StrCat({"e", String::Number(kNestingDepth - 1)}));
+
+ // The deferred completion must still finish the document.
+ EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+// Same as above, but the final piece of the body arrives together with the
+// completion while the parser is inside the chunk. The data must be parsed
+// before the document is finished.
+TEST_F(XMLDocumentParserSimTest, FinalDataAndLoadCompletionDuringInlineScript) {
+ SimRequest main_resource("https://example.com/test.xhtml",
+ "application/xhtml+xml");
+ SimSubresourceRequest external_script("https://example.com/ext.js",
+ "text/javascript");
+
+ LoadURL("https://example.com/test.xhtml");
+
+ main_resource.Write(
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+ "<script src=\"ext.js\"></script>");
+
+ auto* listener = MakeGarbageCollected<FinishResponseListener>(
+ &main_resource, /*final_data=*/"<p id=\"tail\">y</p></body></html>");
+ GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+ main_resource.Write(BuildChunkAfterExternalScript(/*close_document=*/false));
+
+ external_script.Complete("");
+ test::RunPendingTasks();
+
+ EXPECT_TRUE(listener->WasInvoked());
+
+ EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+ AtomicString("data-ready-state")),
+ "loading");
+
+ Element* last = GetDocument().getElementById(AtomicString("last"));
+ ASSERT_TRUE(last);
+ ASSERT_TRUE(last->parentElement());
+ EXPECT_EQ(last->parentElement()->localName(),
+ StrCat({"e", String::Number(kNestingDepth - 1)}));
+
+ // The data received together with the completion must have been parsed.
+ Element* tail = GetDocument().getElementById(AtomicString("tail"));
+ ASSERT_TRUE(tail);
+ EXPECT_EQ(tail->parentElement(), GetDocument().body());
+
+ EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+// Like LoadCompletionDuringInlineScript, but the markup after the inline
+// script is not well-formed: a stray end tag followed by a deep run of
+// unclosed elements with namespace declarations. The response finishing
+// during the inline script must not end the document from inside the chunk;
+// the parser has to fail the document in an orderly way afterwards.
+TEST_F(XMLDocumentParserSimTest, LoadCompletionDuringInlineScriptBadMarkup) {
+ SimRequest main_resource("https://example.com/test.xhtml",
+ "application/xhtml+xml");
+ SimSubresourceRequest external_script("https://example.com/ext.js",
+ "text/javascript");
+
+ LoadURL("https://example.com/test.xhtml");
+
+ main_resource.Write(
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>"
+ "<script src=\"ext.js\"></script>");
+
+ auto* listener = MakeGarbageCollected<FinishResponseListener>(
+ &main_resource, /*final_data=*/String());
+ GetDocument().addEventListener(AtomicString("finish-response"), listener);
+
+ StringBuilder markup;
+ markup.Append(
+ "<script>"
+ "document.dispatchEvent(new Event('finish-response'));"
+ "document.documentElement.setAttribute('data-ready-state',"
+ " document.readyState);"
+ "</script>"
+ "</script>");
+ for (int i = 0; i < kNestingDepth; ++i) {
+ markup.Append("<e");
+ markup.AppendNumber(i);
+ markup.Append(" xmlns:n");
+ markup.AppendNumber(i);
+ markup.Append("=\"http://example.com/ns/");
+ markup.AppendNumber(i);
+ markup.Append("\">");
+ }
+ main_resource.Write(markup.ToString());
+
+ external_script.Complete("");
+ test::RunPendingTasks();
+
+ EXPECT_TRUE(listener->WasInvoked());
+
+ // The document must not have been finished while the inline script was
+ // still running, even though the markup afterwards fails to parse.
+ EXPECT_EQ(GetDocument().documentElement()->getAttribute(
+ AtomicString("data-ready-state")),
+ "loading");
+
+ // The malformed document must still reach completion.
+ EXPECT_TRUE(GetDocument().LoadEventFinished());
+}
+
+} // namespace
+
+} // namespace blink
Original Bug Report
Heap UAF in libxml2 via XMLDocumentParser::Finish reentrancy guard bypass
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential reentrancy guard bypass in XMLDocumentParser::Finish allows xmlParseChunk to be called re-entrantly on an active libxml2 parser context during a suspended SAX callback. While XMLDocumentParser::Append and DoWrite protect against data reentrancy, Finish only checks parser_paused_ and proceeds to call end() -> DoEnd() unconditionally when unpaused. A nested parse chunk can reallocate ctxt->pushTab via xmlRealloc, leading to a use-after-free read in xmlParseEndTag2 and subsequent out-of-bounds heap operations in libxml2.
Affected files:
third_party/blink/renderer/core/xml/parser/xml_document_parser.ccthird_party/blink/renderer/core/xml/parser/xml_document_parser.hthird_party/blink/renderer/platform/loader/fetch/url_loader/navigation_body_loader.cc
Estimated timestamp from git blame: 2026-07-09
Potential heap UAF in libxml2 via XMLDocumentParser::Finish reentrancy guard bypass
Overview
Chromium’s XMLDocumentParser includes reentrancy protection against nested calls into libxml2’s push parser (xmlParseChunk). However, this protection covers only the data parsing entry points (Append and DoWrite). The document completion path (XMLDocumentParser::Finish) only checks whether parser_paused_ is true. If Finish() is invoked synchronously while the parser is inside an active xmlParseChunk call with parser_paused_ == false (for instance, during synchronous inline script execution under a SAX callback), Finish() invokes DoEnd(), which calls FinishParsing(Context()) -> xmlParseChunk(ctxt, nullptr, 0, 1) directly on the live parser context.
This re-entrant parse can reallocate internal libxml2 parser structures (such as ctxt->pushTab), leaving stale pointers in outer stack frames and resulting in a Use-After-Free (UAF) read and out-of-bounds heap operations.
Note: These steps and primitives are derived from static source code analysis; our automated tooling has not executed a live proof-of-concept run.
Affected Files
third_party/blink/renderer/core/xml/parser/xml_document_parser.ccthird_party/blink/renderer/core/xml/parser/xml_document_parser.hthird_party/libxml/src/parser.cthird_party/blink/renderer/platform/loader/fetch/url_loader/navigation_body_loader.cc
Root Cause Analysis
In third_party/blink/renderer/core/xml/parser/xml_document_parser.cc:
- Guarded data path:
// xml_document_parser.cc:423-426
if (parser_paused_ || in_parse_chunk_) {
pending_src_.Append(source);
return;
}
// xml_document_parser.cc:978-979
CHECK(!in_parse_chunk_);
base::AutoReset<bool> reentrancy_guard(&in_parse_chunk_, true);
- Unguarded finish path:
// xml_document_parser.cc:514-527
void XMLDocumentParser::Finish() {
Flush();
if (IsDetached())
return;
if (parser_paused_)
finish_called_ = true;
else
end(); // Missing check for in_parse_chunk_
}
end() (xml_document_parser.cc:477-484) calls DoEnd(), which bypasses DoWrite() and its CHECK(!in_parse_chunk_):
// xml_document_parser.cc:1809-1820
if (!IsStopped()) {
if (context_) {
{
XMLDocumentParserScope scope(GetDocument());
FinishParsing(Context()); // -> xmlParseChunk(ctxt, nullptr, 0, 1)
}
context_ = nullptr;
}
}
Because the outer DoWrite() holds scoped_refptr<XMLParserContext> context = context_; (line 972), the libxml2 ctxt remains alive when DoEnd() clears context_ = nullptr.
-
Inline script execution under SAX callback: When an inline
<script>is processed inEndElementNs(xml_document_parser.cc:1343-1354),script_runner_->ProcessScriptElement()runs the script block synchronously. For inline scripts,IsWaitingForScripts()is false, soPauseParsing()is not called andparser_paused_remainsfalsewhilein_parse_chunk_istrue. -
Undeferral and Drain Chain: If the inline script opens a JavaScript dialog (e.g.
alert()),ScopedPagePauseris created (chrome_client.cc:102). When the dialog is dismissed,~ScopedPagePauser()unpauses the page (page.cc:723), which triggersLocalFrame::SetContextPaused(false)(local_frame.cc:3241-3256) ->NavigationBodyLoader::SetDefersLoading(kNone)(navigation_body_loader.cc:430-438), which synchronously invokesOnReadable(MOJO_RESULT_OK).
If the response pipe has closed and network completion arrived (OnComplete), OnReadable() finishes the stream and synchronously calls BodyLoadingFinished() -> DocumentLoader::FinishedLoading() -> XMLDocumentParser::Finish().
- libxml2 Memory Corruption:
In
third_party/libxml/src/parser.c:11091,xmlParseEndTag2(ctxt, &ctxt->pushTab[ctxt->nameNr - 1])passes a pointer intoctxt->pushTabastag. During the synchronousFinish()call, innerxmlParseChunkexecutes onctxt. If the inner parse pushes additional elements exceedingctxt->nameMax(initial 10),nameNsPush()callsxmlRealloc(ctxt->pushTab, ...)(line 2080), freeing the memory block wheretagpoints.
When the outer xmlParseEndTag2 frame resumes after callback unwinding:
// third_party/libxml/src/parser.c:9424-9425
if (tag->nsNr != 0)
xmlParserNsPop(ctxt, tag->nsNr);
tag->nsNr is read from freed heap memory (UAF read). If tag->nsNr holds a value greater than ctxt->nsNr, xmlParserNsPop (parser.c:1820-1846) iterates with a negative index i:
- Reads out-of-bounds before
ctxt->nsTabandctxt->nsdb->extra. - Writes
bucket->index = extra->oldIndexwith OOB data. - Underflows
ctxt->nsNr -= nr, causing subsequent namespace pushes to perform out-of-bounds heap writes.
Potential Steps to Trigger
- Host a top-level XHTML resource served as
application/xhtml+xml. - Stream Chunk A: XML prolog + root +
<script src="ext.js"></script>to pause parsing while waiting forext.js. - Stream Chunk B:
<script>alert(1)</script></script>followed by nested XML elements (<a1 xmlns:n1="u1">...<a64>). Because the parser is paused, Chunk B is buffered intopending_src_. - Stream Chunk C: A body tail > 1 MiB (exceeding
network::kMaxNumConsumedBytesInTask), then close the response soURLLoaderClient::OnCompletearrives while residual bytes remain in the data pipe. - Respond to
ext.jssoPendingScriptFinishedrunsResumeParsing()->DoWrite(Chunk B)-> outerxmlParseChunk. - The inline script executes
alert(1)from within the SAX callback withparser_paused_ == falseandin_parse_chunk_ == true. - User dismisses the alert.
~ScopedPagePauserundeferral drains the remaining body and synchronously callsDocumentLoader::FinishedLoading()->XMLDocumentParser::Finish(). Finish()observesparser_paused_ == falseand re-entersxmlParseChunk(ctxt, nullptr, 0, 1)viaDoEnd(), triggeringxmlRealloconctxt->pushTaband causing UAF memory corruption whenalert()unwinds.
Suggested Fix
In XMLDocumentParser::Finish() (third_party/blink/renderer/core/xml/parser/xml_document_parser.cc), treat in_parse_chunk_ similarly to parser_paused_ by deferring the completion call:
void XMLDocumentParser::Finish() {
Flush();
if (IsDetached())
return;
if (parser_paused_ || in_parse_chunk_)
finish_called_ = true;
else
end();
}
Ensure that when outer DoWrite() exits and finish_called_ is true (and !parser_paused_), end() is called after the outer ParseChunk() call stack completely unwinds.
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
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.