Which @angular/* package(s) are the source of the bug?
platform-server (via its bundled domino dependency)
Is this a regression?
Yes — the remaining part of the regression reported in #70050.
fc7e40a ("escape fallback raw-content ancestor tags in comments and nested raw-text elements") closed the case where every ancestor is in the HTML namespace, but not the case where a foreign-content element sits between the payload and the fallback raw-content ancestor.
Same payload, same DOM shape, across three commits:
f88e5aa~1 (before the original regression) safe
<noscript><svg><foreignObject><xmp></noscript><img src=x onerror=alert(1)></xmp></foreignObject></svg></noscript>
f88e5aa (fix for GHSA-vpx6-8pjr-4g3v) BREAKOUT
<noscript><svg><foreignObject><xmp></noscript><img src=x onerror=alert(1)></xmp></foreignObject></svg></noscript>
fc7e40a (fix for #70050) BREAKOUT
<noscript><svg><foreignObject><xmp></noscript><img src=x onerror=alert(1)></xmp></foreignObject></svg></noscript>
Description
fallbackRawContentTags() collects the fallback raw-content ancestors whose closing tags must be escaped. Its loop condition terminates the walk at the first element that is not in the HTML namespace:
function fallbackRawContentTags(node) {
const tags = [];
while (node?.nodeType === 1 /*ELEMENT_NODE*/ && node.namespaceURI === NAMESPACE.HTML) {
if (hasRawContentFallback[node.tagName]) {
tags.push(node.localName);
}
node = node.parentNode;
}
return tags;
}
Walking up from <xmp> (HTML namespace), the next ancestor is <foreignObject> (SVG namespace). The condition fails, the loop exits, and the enclosing <noscript> is never collected — so its closing tag is never escaped.
The text node itself is emitted raw because its parent <xmp> is a non-fallback raw-content element, and the element branch then escapes only </xmp> plus the (empty) ancestor list.
Confirmed reachable through both foreign-content namespaces, for text nodes and comment nodes:
| shape |
result at fc7e40a |
noscript > svg > foreignObject > xmp (text) |
BREAKOUT |
noscript > svg > foreignObject > #comment |
BREAKOUT |
noscript > math > mtext > xmp (text) |
BREAKOUT |
noscript > xmp (fixed by fc7e40a) |
safe |
Not affected: processing-instruction targets are rejected by createProcessingInstruction() validation, and PI data is escaped by escapeProcessingInstructionContent().
Please provide a link to a minimal reproduction of the bug
1. domino directly (no Angular required)
git clone https://github.com/angular/domino.git && cd domino
git checkout fc7e40a
Save as repro.mjs:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const domino = require('./lib/index.js');
const SVG = 'http://www.w3.org/2000/svg';
const doc = domino.createDocument('<!DOCTYPE html><html><body></body></html>');
const ns = doc.createElement('noscript');
const svg = doc.createElementNS(SVG, 'svg');
const fo = doc.createElementNS(SVG, 'foreignObject');
const xmp = doc.createElement('xmp');
xmp.appendChild(doc.createTextNode('</noscript><img src=x onerror=alert(1)>'));
fo.appendChild(xmp); svg.appendChild(fo); ns.appendChild(svg);
doc.body.appendChild(ns);
console.log(ns.outerHTML);
Actual output — the </noscript> is emitted unescaped:
<noscript><svg><foreignObject><xmp></noscript><img src=x onerror=alert(1)></xmp></foreignObject></svg></noscript>
2. Angular template shape that produces this DOM
<noscript><svg><foreignObject><xmp>{{ value }}</xmp></foreignObject></svg></noscript>
Rendered through renderApplication(), this produces the same unescaped output, and loading the result in a browser executes the payload — the <img onerror> becomes a live element outside the <noscript>, which retains only the truncated text node <svg><foreignObject><xmp>.
Note: @angular/[email protected] still bundles the pre-fc7e40a domino, so this Angular-level check requires the fc7e40a serializer in node_modules/@angular/platform-server/third_party/domino/bundled-domino.mjs. The domino-level reproduction above is against upstream fc7e40a directly and needs no patching. This report therefore describes the state that will ship once the domino dependency is bumped.
Suggested fix
Continue the ancestor walk through foreign content instead of stopping at it, and apply the namespace check per node:
function fallbackRawContentTags(node) {
const tags = [];
- while (node?.nodeType === 1 /*ELEMENT_NODE*/ && node.namespaceURI === NAMESPACE.HTML) {
- if (hasRawContentFallback[node.tagName]) {
+ while (node?.nodeType === 1 /*ELEMENT_NODE*/) {
+ if (node.namespaceURI === NAMESPACE.HTML && hasRawContentFallback[node.tagName]) {
tags.push(node.localName);
}
node = node.parentNode;
}
return tags;
}
With this change applied to fc7e40a:
Please provide the environment you discovered this bug in
domino fc7e40a7086087c8f55a2108e9edc5be76d2833
@angular/platform-server 22.1.0
Node.js v25.6.0
OS Windows 11
Browsers Chrome, Firefox
Which @angular/* package(s) are the source of the bug?
platform-server (via its bundled
dominodependency)Is this a regression?
Yes — the remaining part of the regression reported in #70050.
fc7e40a("escape fallback raw-content ancestor tags in comments and nested raw-text elements") closed the case where every ancestor is in the HTML namespace, but not the case where a foreign-content element sits between the payload and the fallback raw-content ancestor.Same payload, same DOM shape, across three commits:
Description
fallbackRawContentTags()collects the fallback raw-content ancestors whose closing tags must be escaped. Its loop condition terminates the walk at the first element that is not in the HTML namespace:Walking up from
<xmp>(HTML namespace), the next ancestor is<foreignObject>(SVG namespace). The condition fails, the loop exits, and the enclosing<noscript>is never collected — so its closing tag is never escaped.The text node itself is emitted raw because its parent
<xmp>is a non-fallback raw-content element, and the element branch then escapes only</xmp>plus the (empty) ancestor list.Confirmed reachable through both foreign-content namespaces, for text nodes and comment nodes:
fc7e40anoscript > svg > foreignObject > xmp(text)noscript > svg > foreignObject > #commentnoscript > math > mtext > xmp(text)noscript > xmp(fixed byfc7e40a)Not affected: processing-instruction targets are rejected by
createProcessingInstruction()validation, and PI data is escaped byescapeProcessingInstructionContent().Please provide a link to a minimal reproduction of the bug
1. domino directly (no Angular required)
Save as
repro.mjs:Actual output — the
</noscript>is emitted unescaped:2. Angular template shape that produces this DOM
Rendered through
renderApplication(), this produces the same unescaped output, and loading the result in a browser executes the payload — the<img onerror>becomes a live element outside the<noscript>, which retains only the truncated text node<svg><foreignObject><xmp>.Note:
@angular/[email protected]still bundles the pre-fc7e40adomino, so this Angular-level check requires thefc7e40aserializer innode_modules/@angular/platform-server/third_party/domino/bundled-domino.mjs. The domino-level reproduction above is against upstreamfc7e40adirectly and needs no patching. This report therefore describes the state that will ship once the domino dependency is bumped.Suggested fix
Continue the ancestor walk through foreign content instead of stopping at it, and apply the namespace check per node:
function fallbackRawContentTags(node) { const tags = []; - while (node?.nodeType === 1 /*ELEMENT_NODE*/ && node.namespaceURI === NAMESPACE.HTML) { - if (hasRawContentFallback[node.tagName]) { + while (node?.nodeType === 1 /*ELEMENT_NODE*/) { + if (node.namespaceURI === NAMESPACE.HTML && hasRawContentFallback[node.tagName]) { tags.push(node.localName); } node = node.parentNode; } return tags; }With this change applied to
fc7e40a:</noscript>in comment and nested raw-text child nodes (regression in 22.0.7) #70050 remain escaped<noscript><img src=...>, standalone<style>/<script>, hydration comments)Please provide the environment you discovered this bug in