Skip to content

Incomplete fix for #70050: </noscript> breakout still reachable through SVG/MathML foreign content #70055

Description

@VenkatKwest

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>&lt;/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);
node repro.mjs

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: serverIssues related to server-side renderinggemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions