Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in XML
DescriptionInappropriate implementation in XML
ComponentXML
Bug ClassLogic Error
Tracker501740299
Fix commit08c1a0a10f54 (chromium/src) +99/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
modified
if
third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc
modified
XMLDocumentParserParameterizedTest
third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
modified
TEST_P
third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
  • third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc
  • third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
  • third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg
From 08c1a0a10f5409571caa073a82159785f349cfba Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Mon, 13 Apr 2026 14:20:13 -0700
Subject: [PATCH] Fix nested XML namespace reset bug in fragment parsing

This CL fixes a bug in Blink's XML fragment parser where nested
null-namespace declarations (xmlns="") were not correctly tracked.

This logic was originally introduced in crrev.com/c/7755979 (which
addressed crbug.com/463630186). However, that implementation used a
single pointer (ancestor_resetting_namespace_) to track the element that
triggered a namespace reset, but allowed inner declarations to overwrite
it. When an inner reset-triggering element was closed, it would clear
the pointer, losing the reset state for the outer ancestor and causing
subsequent siblings to incorrectly inherit the context's default
namespace.

The fix ensures that ancestor_resetting_namespace_ only tracks the
outermost reset-triggering element. Any inner resets are ignored since
the null namespace is already in effect, and the pointer is only cleared
when the outermost resetting element is closed.

This CL also adds:
- Parameterized regression tests to XMLDocumentParserTest.
- A regression test case to the existing WPT suite for XML namespaces.

Bug: 463630186
Change-Id: I409d07ced7b59386f87b898335edb2244ff8694f
Fixed: 501740299
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7755979
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Reviewed-by: David Baron <dbaron@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1613972}
---

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 a5f7f1a..5af1a04 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
@@ -1172,7 +1172,8 @@
 
   SetAttributes(new_element, prefixed_attributes, GetParserContentPolicy());
 
-  if (parsing_fragment_ && encountered_namespace_reset) {
+  if (parsing_fragment_ && encountered_namespace_reset &&
+      !ancestor_resetting_namespace_) {
     ancestor_resetting_namespace_ = new_element;
   }
 
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc
index 396dca4..08c5dd4 100644
--- a/third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc
@@ -488,7 +488,8 @@
 
   SetAttributes(new_element, prefixed_attributes, GetParserContentPolicy());
 
-  if (parsing_fragment_ && encountered_namespace_reset) {
+  if (parsing_fragment_ && encountered_namespace_reset &&
+      !ancestor_resetting_namespace_) {
     ancestor_resetting_namespace_ = new_element;
   }
 
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
index 35ae8b96..a079628 100644
--- a/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
@@ -13,6 +13,7 @@
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/runtime_enabled_features.h"
+#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/wtf/text/atomic_string.h"
 
@@ -70,4 +71,89 @@
   EXPECT_EQ(bar->localName(), "foo:bar");
 }
 
+class XMLDocumentParserParameterizedTest
+    : public testing::Test,
+      public testing::WithParamInterface<bool> {
+ public:
+  XMLDocumentParserParameterizedTest() : scoped_rust_(GetParam()) {}
+
+ protected:
+  ScopedXMLParsingRustForTest scoped_rust_;
+};
+
+INSTANTIATE_TEST_SUITE_P(All,
+                         XMLDocumentParserParameterizedTest,
+                         testing::Bool());
+
+// crbug.com/501740299
+TEST_P(XMLDocumentParserParameterizedTest, SingleNamespaceReset) {
+  test::TaskEnvironment task_environment;
+  ScopedNullExecutionContext execution_context;
+  execution_context.GetExecutionContext().SetUpSecurityContextForTesting();
+  auto& doc = *Document::CreateForTest(execution_context.GetExecutionContext());
+
+  // Create an XHTML context element.
+  DummyExceptionStateForTesting exception;
+  const AtomicString xhtml_ns("http://www.w3.org/1999/xhtml");
+  auto* div = doc.createElementNS(xhtml_ns, AtomicString("div"), exception);
+  ASSERT_TRUE(div);
+
+  DocumentFragment* fragment = DocumentFragment::Create(doc);
+  // Payload with a single xmlns="".
+  const char* payload = "<a xmlns=''><iframe/></a>";
+  EXPECT_TRUE(fragment->ParseXML(payload, div, ASSERT_NO_EXCEPTION));
+
+  auto* a = To<Element>(fragment->firstChild());
+  ASSERT_TRUE(a);
+  EXPECT_EQ(a->namespaceURI(), g_null_atom);
+
+  auto* iframe = To<Element>(a->firstChild());
+  ASSERT_TRUE(iframe);
+  EXPECT_EQ(iframe->localName(), "iframe");
+  EXPECT_EQ(iframe->namespaceURI(), g_null_atom)
+      << "iframe should be in null namespace inherited from <a>";
+}
+
+// crbug.com/501740299
+TEST_P(XMLDocumentParserParameterizedTest, NestedNamespaceReset) {
+  test::TaskEnvironment task_environment;
+  ScopedNullExecutionContext execution_context;
+  execution_context.GetExecutionContext().SetUpSecurityContextForTesting();
+  auto& doc = *Document::CreateForTest(execution_context.GetExecutionContext());
+
+  // Create an XHTML context element.
+  DummyExceptionStateForTesting exception;
+  const AtomicString xhtml_ns("http://www.w3.org/1999/xhtml");
+  auto* div = doc.createElementNS(xhtml_ns, AtomicString("div"), exception);
+  ASSERT_TRUE(div);
+
+  DocumentFragment* fragment = DocumentFragment::Create(doc);
+  // Payload with nested xmlns="".
+  // The outer <a> resets the namespace to null.
+  // The inner <b> also resets the namespace to null.
+  // Verification that closing </b> doesn't clear the reset state for outer <a>.
+  const char* payload = "<a xmlns=''><b xmlns=''>x</b><iframe/></a>";
+  EXPECT_TRUE(fragment->ParseXML(payload, div, ASSERT_NO_EXCEPTION));
+
+  // Structure: fragment -> <a> -> [<b>, <iframe>]
+  auto* a = To<Element>(fragment->firstChild());
+  ASSERT_TRUE(a);
+  EXPECT_EQ(a->localName(), "a");
+  EXPECT_EQ(a->namespaceURI(), g_null_atom);
+
+  auto* b = To<Element>(a->firstChild());
+  ASSERT_TRUE(b);
+  EXPECT_EQ(b->localName(), "b");
+  EXPECT_EQ(b->namespaceURI(), g_null_atom);
+
+  auto* iframe = To<Element>(b->nextSibling());
+  ASSERT_TRUE(iframe);
+  EXPECT_EQ(iframe->localName(), "iframe");
+
+  // Verify that the iframe correctly inherits the null namespace from <a>,
+  // ensuring that the inner <b> declaration didn't clobber the reset state.
+  EXPECT_EQ(iframe->namespaceURI(), g_null_atom)
+      << "iframe should be in null namespace due to outer <a> reset";
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg b/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg
index 2317ddc20..06ab1cc 100644
--- a/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg
+++ b/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg
@@ -86,6 +86,15 @@
       }, "default namespace applied to sibling of namespace-resetting element in parse of fragment.");
 
       test(() => {
+        prefixedContainer.innerHTML = "<e><f xmlns=''><g xmlns=''></g><h/></f><i></i></e>";
+        assert_equals(prefixedContainer.firstChild.namespaceURI, SVG_NS);
+        assert_equals(prefixedContainer.firstChild.firstChild.namespaceURI, null);
+        assert_equals(prefixedContainer.firstChild.firstChild.firstChild.namespaceURI, null);
+        assert_equals(prefixedContainer.firstChild.firstChild.lastChild.namespaceURI, null);
+        assert_equals(prefixedContainer.firstChild.lastChild.namespaceURI, SVG_NS);
+      }, "nested default namespace reset works inside parse of fragment");
+
+      test(() => {
         prefixedContainer.innerHTML = "<e><h:f xmlns:h='https://example.com/new-h'><g><h:d></h:d></g></h:f></e>";
         assert_equals(prefixedContainer.firstChild.firstChild.namespaceURI, "https://example.com/new-h");
         assert_equals(prefixedContainer.firstChild.firstChild.firstChild.namespaceURI, SVG_NS);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc b/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
index 35ae8b96..a079628 100644
--- a/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
+++ b/third_party/blink/renderer/core/xml/parser/xml_document_parser_test.cc
@@ -13,6 +13,7 @@
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/runtime_enabled_features.h"
+#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/wtf/text/atomic_string.h"
 
@@ -70,4 +71,89 @@
   EXPECT_EQ(bar->localName(), "foo:bar");
 }
 
+class XMLDocumentParserParameterizedTest
+    : public testing::Test,
+      public testing::WithParamInterface<bool> {
+ public:
+  XMLDocumentParserParameterizedTest() : scoped_rust_(GetParam()) {}
+
+ protected:
+  ScopedXMLParsingRustForTest scoped_rust_;
+};
+
+INSTANTIATE_TEST_SUITE_P(All,
+                         XMLDocumentParserParameterizedTest,
+                         testing::Bool());
+
+// crbug.com/501740299
+TEST_P(XMLDocumentParserParameterizedTest, SingleNamespaceReset) {
+  test::TaskEnvironment task_environment;
+  ScopedNullExecutionContext execution_context;
+  execution_context.GetExecutionContext().SetUpSecurityContextForTesting();
+  auto& doc = *Document::CreateForTest(execution_context.GetExecutionContext());
+
+  // Create an XHTML context element.
+  DummyExceptionStateForTesting exception;
+  const AtomicString xhtml_ns("http://www.w3.org/1999/xhtml");
+  auto* div = doc.createElementNS(xhtml_ns, AtomicString("div"), exception);
+  ASSERT_TRUE(div);
+
+  DocumentFragment* fragment = DocumentFragment::Create(doc);
+  // Payload with a single xmlns="".
+  const char* payload = "<a xmlns=''><iframe/></a>";
+  EXPECT_TRUE(fragment->ParseXML(payload, div, ASSERT_NO_EXCEPTION));
+
+  auto* a = To<Element>(fragment->firstChild());
+  ASSERT_TRUE(a);
+  EXPECT_EQ(a->namespaceURI(), g_null_atom);
+
+  auto* iframe = To<Element>(a->firstChild());
+  ASSERT_TRUE(iframe);
+  EXPECT_EQ(iframe->localName(), "iframe");
+  EXPECT_EQ(iframe->namespaceURI(), g_null_atom)
+      << "iframe should be in null namespace inherited from <a>";
+}
+
+// crbug.com/501740299
+TEST_P(XMLDocumentParserParameterizedTest, NestedNamespaceReset) {
+  test::TaskEnvironment task_environment;
+  ScopedNullExecutionContext execution_context;
+  execution_context.GetExecutionContext().SetUpSecurityContextForTesting();
+  auto& doc = *Document::CreateForTest(execution_context.GetExecutionContext());
+
+  // Create an XHTML context element.
+  DummyExceptionStateForTesting exception;
+  const AtomicString xhtml_ns("http://www.w3.org/1999/xhtml");
+  auto* div = doc.createElementNS(xhtml_ns, AtomicString("div"), exception);
+  ASSERT_TRUE(div);
+
+  DocumentFragment* fragment = DocumentFragment::Create(doc);
+  // Payload with nested xmlns="".
+  // The outer <a> resets the namespace to null.
+  // The inner <b> also resets the namespace to null.
+  // Verification that closing </b> doesn't clear the reset state for outer <a>.
+  const char* payload = "<a xmlns=''><b xmlns=''>x</b><iframe/></a>";
+  EXPECT_TRUE(fragment->ParseXML(payload, div, ASSERT_NO_EXCEPTION));
+
+  // Structure: fragment -> <a> -> [<b>, <iframe>]
+  auto* a = To<Element>(fragment->firstChild());
+  ASSERT_TRUE(a);
+  EXPECT_EQ(a->localName(), "a");
+  EXPECT_EQ(a->namespaceURI(), g_null_atom);
+
+  auto* b = To<Element>(a->firstChild());
+  ASSERT_TRUE(b);
+  EXPECT_EQ(b->localName(), "b");
+  EXPECT_EQ(b->namespaceURI(), g_null_atom);
+
+  auto* iframe = To<Element>(b->nextSibling());
+  ASSERT_TRUE(iframe);
+  EXPECT_EQ(iframe->localName(), "iframe");
+
+  // Verify that the iframe correctly inherits the null namespace from <a>,
+  // ensuring that the inner <b> declaration didn't clobber the reset state.
+  EXPECT_EQ(iframe->namespaceURI(), g_null_atom)
+      << "iframe should be in null namespace due to outer <a> reset";
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg b/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg
index 2317ddc20..06ab1cc 100644
--- a/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg
+++ b/third_party/blink/web_tests/external/wpt/html/webappapis/dynamic-markup-insertion/the-innerhtml-property/innerhtml-and-xml-namespaces.svg
@@ -86,6 +86,15 @@
       }, "default namespace applied to sibling of namespace-resetting element in parse of fragment.");
 
       test(() => {
+        prefixedContainer.innerHTML = "<e><f xmlns=''><g xmlns=''></g><h/></f><i></i></e>";
+        assert_equals(prefixedContainer.firstChild.namespaceURI, SVG_NS);
+        assert_equals(prefixedContainer.firstChild.firstChild.namespaceURI, null);
+        assert_equals(prefixedContainer.firstChild.firstChild.firstChild.namespaceURI, null);
+        assert_equals(prefixedContainer.firstChild.firstChild.lastChild.namespaceURI, null);
+        assert_equals(prefixedContainer.firstChild.lastChild.namespaceURI, SVG_NS);
+      }, "nested default namespace reset works inside parse of fragment");
+
+      test(() => {
         prefixedContainer.innerHTML = "<e><h:f xmlns:h='https://example.com/new-h'><g><h:d></h:d></g></h:f></e>";
         assert_equals(prefixedContainer.firstChild.firstChild.namespaceURI, "https://example.com/new-h");
         assert_equals(prefixedContainer.firstChild.firstChild.firstChild.namespaceURI, SVG_NS);
Loading diff…

Original Bug Report

reported by vm...@google.com

Namespace Confusion in XML Fragment Parsing via Nested xmlns=""

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.

Overview: A logic error in Chromium’s XML fragment parser fails to correctly track nested null-namespace declarations (xmlns="") because it uses a single pointer instead of a stack. When an inner element with a null namespace is closed, the parser erroneously clears the namespace reset state, causing subsequent sibling elements to incorrectly inherit the context’s default namespace (e.g., XHTML). This namespace confusion provides a potential mutation XSS (mXSS) primitive that can bypass XML-aware HTML sanitizers.

Affected files:

  • third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
  • third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc
  • third_party/blink/renderer/core/xml/parser/xml_document_parser.h
  • third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.h

Estimated timestamp from git blame: 2025-12-02

Description

There is a potential namespace confusion vulnerability in Blink’s XML fragment parsing logic (XMLDocumentParser and its Rust equivalent XMLDocumentParserRs). When injecting markup via fragment APIs like innerHTML, Blink attempts to fix up namespace URIs that libxml2 leaves blank.

To prevent the context’s default namespace (e.g., the XHTML namespace) from incorrectly applying to elements that explicitly reset the namespace to null via xmlns="", the parser tracks the ancestor element that triggered the reset using a member variable: ancestor_resetting_namespace_.

However, this member is implemented as a single pointer rather than a stack. If an attacker nests elements that declare xmlns="", the inner element overwrites ancestor_resetting_namespace_. When the inner element is closed, EndElementNs clears the pointer to nullptr. This permanently loses the state of the outer namespace-resetting element. As a result, subsequent sibling elements that should be in the null namespace are erroneously promoted to the context’s default namespace (e.g., http://www.w3.org/1999/xhtml), instantiating them as live HTML elements rather than inert XML elements.

Affected Code

  • third_party/blink/renderer/core/xml/parser/xml_document_parser.cc
  • third_party/blink/renderer/core/xml/parser/xml_document_parser_rs.cc

In both files, StartElementNs overwrites the pointer without saving the previous state:

if (parsing_fragment_ && encountered_namespace_reset) {
  ancestor_resetting_namespace_ = new_element;
}

And EndElementNs blindly clears it:

if (ancestor_resetting_namespace_ == n) {
  ancestor_resetting_namespace_ = nullptr;
}

Potential Attacker Steps

Note: These are suggested steps based on static code analysis. Our tooling agent does not currently possess the ability to execute code or run a live proof-of-concept.

  1. An attacker crafts a payload containing nested xmlns="" declarations, for example: <a xmlns=""><b xmlns="">x</b><iframe srcdoc="&lt;script&gt;alert(document.domain)&lt;/script&gt;"></iframe></a>
  2. The payload is supplied to a web application that sanitizes input using an XML-aware sanitizer (like DOMPurify).
  3. The sanitizer parses the payload as a full document. Full document parsing relies on libxml2’s robust namespace tracking (bypassing Blink’s fragment fixup logic). The sanitizer correctly observes the <iframe> as an inert XML element in the null namespace and permits the srcdoc attribute to pass through unmodified.
  4. The application takes the sanitized string and injects it into an active XML/XHTML document using a fragment parsing API, such as document.getElementById('target').innerHTML = sanitized_string;, where the target element possesses an XHTML default namespace.
  5. During fragment parsing, <b> overwrites ancestor_resetting_namespace_ and subsequently clears it upon closing.
  6. When the parser reaches the <iframe>, ancestor_resetting_namespace_ is nullptr. The parser incorrectly falls back to the target’s default XHTML namespace, instantiating an executable HTMLIFrameElement.
  7. The srcdoc script executes in the context of the application’s domain, resulting in Mutation XSS (mXSS).

Suggested Fix

Replace the single Member<ContainerNode> ancestor_resetting_namespace_ pointer with a stack (e.g., HeapVector<Member<ContainerNode>>), or track the depth/count of namespace resets.

  • In StartElementNs, if a namespace reset is encountered, push the new element onto the stack.
  • When checking for namespace resets during URI fixup, evaluate if the stack is non-empty.
  • In EndElementNs, if the closing element matches the top of the stack, pop it off, thereby restoring the previous namespace-resetting context.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.

View on issue tracker
Links in the report