Angular: Template and Attribute Namespace Sanitization Bypass (XSS)
Description
Angular @angular/compiler and @angular/core packages allow bypass of element/attribute sanitization via namespaced script elements, leading to XSS.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Angular @angular/compiler and @angular/core packages allow bypass of element/attribute sanitization via namespaced script elements, leading to XSS.
Vulnerability
The @angular/compiler and @angular/core packages in Angular fail to properly sanitize namespaced elements and attributes. Specifically, the template preparser does not classify namespaced script elements (e.g., <svg:script> or <:svg:script>) as PreparsedElementType.SCRIPT, allowing them to bypass script-stripping logic [2]. Additionally, security context schema mappings for attributes on namespaced elements (e.g., href and xlink:href on <svg:a>) are missing or inconsistent, leading to bypass of runtime and compile-time sanitizers [1]. Affected versions are those prior to the patches in PRs #68868 and #68689 [1][2].
Exploitation
To exploit this vulnerability, an attacker must supply user-controlled template input that is compiled at runtime by Angular. The attacker crafts a template containing a namespaced script element (e.g., <svg:script>alert(1)</svg:script>) or a namespaced element with a malicious attribute binding (e.g., <svg:a [href]="javascript:alert(1)">). Because the namespace prevents proper identification, the script element is not stripped, and attribute sanitization is not applied, allowing the malicious content to be rendered [3][4].
Impact
Successful exploitation leads to client-side Cross-Site Scripting (XSS). An attacker can execute arbitrary JavaScript in the context of the victim's browser, potentially leading to session hijacking, sensitive data exposure, or unauthorized actions on behalf of the user [3][4].
Mitigation
Apply the patches provided in pull requests #68868 and #68689 [1][2]. These updates fix the template preparser to strip namespaced script elements and correct security context mappings for attributes on namespaced elements. Upgrade to the patched versions as soon as they are released. As a workaround, avoid compiling user-controlled templates at runtime or apply additional sanitization before passing input to the Angular compiler [3][4].
- fix(compiler): sanitize dynamic href and xlink:href bindings on SVG a… by alan-agius4 · Pull Request #68868 · angular/angular
- fix(compiler): strip namespaced SVG script elements during template compilation by alan-agius4 · Pull Request #68689 · angular/angular
- CVE-2026-50557 - GitHub Advisory Database
- Template and Attribute Namespace Sanitization Bypass (XSS)
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
2c4b8ceabead7Merge 561690dfe55b5c88aff3908928a7119327eeecf3 into 4f9ee3c056afe3f43f7d1e6837e2f92ace208eb5
7 files changed · +159 −14
packages/compiler/src/schema/dom_element_schema_registry.ts+23 −13 modified@@ -7,7 +7,8 @@ */ import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata, SecurityContext} from '../core'; -import {isNgContainer, isNgContent} from '../ml_parser/tags'; +import {isNgContainer, isNgContent, splitNsName} from '../ml_parser/tags'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces'; import {dashCaseToCamelCase} from '../util'; import {SECURITY_SCHEMA} from './dom_security_schema'; import {ElementSchemaRegistry} from './element_schema_registry'; @@ -17,6 +18,13 @@ const NUMBER = 'number'; const STRING = 'string'; const OBJECT = 'object'; +function normalizeTagName(tagName: string): string { + const tagNameLower = tagName.toLowerCase(); + const [ns, name] = splitNsName(tagNameLower, false); + + return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name; +} + /** * This array represents the DOM schema. It encodes inheritance, properties, and events. * @@ -388,8 +396,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { return true; } - if (tagName.indexOf('-') > -1) { - if (isNgContainer(tagName) || isNgContent(tagName)) { + const normalizedTag = normalizeTagName(tagName); + if (normalizedTag.includes('-')) { + if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) { return false; } @@ -400,8 +409,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { } } - const elementProperties = - this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!; + const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!; return elementProperties.has(propName); } @@ -410,8 +418,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { return true; } - if (tagName.indexOf('-') > -1) { - if (isNgContainer(tagName) || isNgContent(tagName)) { + const normalizedTag = normalizeTagName(tagName); + if (normalizedTag.includes('-')) { + if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) { return true; } @@ -421,7 +430,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { } } - return this._schema.has(tagName.toLowerCase()); + return this._schema.has(normalizedTag); } /** @@ -444,12 +453,12 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { propName = this.getMappedPropName(propName); } - tagName = tagName.toLowerCase(); + const normalizedTag = normalizeTagName(tagName); propName = propName.toLowerCase(); const securitySchema = SECURITY_SCHEMA(); const ctx = - securitySchema[tagName + '|' + propName] ?? + securitySchema[normalizedTag + '|' + propName] ?? securitySchema['*|' + propName] ?? SecurityContext.NONE; @@ -493,14 +502,15 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { } allKnownAttributesOfElement(tagName: string): string[] { - const elementProperties = - this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!; + const normalizedTag = normalizeTagName(tagName); + const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!; // Convert properties to attributes. return Array.from(elementProperties.keys()).map((prop) => _PROP_TO_ATTR.get(prop) ?? prop); } allKnownEventsOfElement(tagName: string): string[] { - return Array.from(this._eventSchema.get(tagName.toLowerCase()) ?? []); + const normalizedTag = normalizeTagName(tagName); + return Array.from(this._eventSchema.get(normalizedTag) ?? []); } override normalizeAnimationStyleProperty(propName: string): string {
packages/compiler/src/schema/dom_security_schema.ts+2 −0 modified@@ -115,6 +115,8 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { ['object', ['codebase', 'data']], ]); + registerContext(SecurityContext.URL, SVG_NAMESPACE, [['a', ['href', 'xlink:href']]]); + // Keep this in sync with SECURITY_SENSITIVE_ELEMENTS in packages/core/src/sanitization/sanitization.ts // The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings) // and the directive can be applied to multiple different elements (with different tag names). In this case we generate
packages/compiler/test/schema/dom_element_schema_registry_spec.ts+23 −0 modified@@ -168,6 +168,12 @@ If 'onAnything' is a directive input, make sure the directive is imported by the expect(registry.securityContext(':svg:set', 'to', false)).toBe( SecurityContext.ATTRIBUTE_NO_BINDING, ); + + // SVG link attributes + expect(registry.securityContext(':svg:a', 'href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'xlink:href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'href', true)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'xlink:href', true)).toBe(SecurityContext.URL); }); it('should detect properties on namespaced elements', () => { @@ -198,6 +204,23 @@ If 'onAnything' is a directive input, make sure the directive is imported by the }); }); + describe('Custom XML / XHTML namespaces', () => { + it('should support elements with custom namespaces', () => { + expect(registry.hasElement(':xhtml:a', [])).toBeTruthy(); + expect(registry.hasElement(':foo:div', [])).toBeTruthy(); + }); + + it('should support properties on custom namespaced elements', () => { + expect(registry.hasProperty(':xhtml:a', 'href', [])).toBeTruthy(); + expect(registry.hasProperty(':foo:div', 'id', [])).toBeTruthy(); + }); + + it('should return correct security contexts for custom namespaced elements', () => { + expect(registry.securityContext(':xhtml:a', 'href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':foo:div', 'innerHTML', false)).toBe(SecurityContext.HTML); + }); + }); + // Uncomment to see the generated schema which can then be pasted to the DomElementSchemaRegistry // if (!isNode) { // it('generate a new schema', () => {
packages/core/src/render3/i18n/i18n_parse.ts+27 −1 modified@@ -73,6 +73,7 @@ import { } from './i18n_util'; import {createTNodeAtIndex} from '../tnode_manipulation'; import {allocExpando} from '../view/construction'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../namespaces'; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+:?\d*�\s*,\s*\S{6}\s*,[\s\S]*})/gi; @@ -984,9 +985,34 @@ function addCreateAttribute( create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue); } +function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] { + if (elementName[0] != ':') { + return [null, elementName]; + } + + const colonIndex = elementName.indexOf(':', 1); + + if (colonIndex === -1) { + if (fatal) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } else { + return [null, elementName]; + } + } + + return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; +} + +function normalizeTagName(tagName: string): string { + const tagNameLower = tagName.toLowerCase(); + const [ns, name] = splitNsName(tagNameLower, false); + + return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name; +} + function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null { const lowerAttrName = attrName.toLowerCase(); - const lowerTagName = tagName ? tagName.toLowerCase() : '*'; + const lowerTagName = tagName ? normalizeTagName(tagName) : '*'; const schema = SECURITY_SCHEMA(); const schemaContext = schema[`${lowerTagName}|${lowerAttrName}`] ||
packages/core/src/sanitization/dom_security_schema.ts+2 −0 modified@@ -115,6 +115,8 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { ['object', ['codebase', 'data']], ]); + registerContext(SecurityContext.URL, SVG_NAMESPACE, [['a', ['href', 'xlink:href']]]); + // Keep this in sync with SECURITY_SENSITIVE_ELEMENTS in packages/core/src/sanitization/sanitization.ts // The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings) // and the directive can be applied to multiple different elements (with different tag names). In this case we generate
packages/core/test/acceptance/security_spec.ts+54 −0 modified@@ -916,3 +916,57 @@ describe('SVG <script> bindings', () => { expect(fixture.nativeElement.querySelector('script')).toBeFalsy(); }); }); + +describe('SVG <a> link sanitization', () => { + it('should sanitize dynamic `href` bindings on <svg:a>', () => { + @Component({ + template: '<svg><a [attr.href]="url"></a></svg>', + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp { + url = 'javascript:alert(1)'; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)'); + }); + + it('should sanitize dynamic `xlink:href` bindings on <svg:a>', () => { + @Component({ + template: '<svg><a [attr.xlink:href]="url"></a></svg>', + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp { + url = 'javascript:alert(1)'; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('xlink:href')).toEqual('unsafe:javascript:alert(1)'); + }); + + it('should allow static unsafe `href` and `xlink:href` on <svg:a>', () => { + @Component({ + template: ` + <svg> + <a href="javascript:alert(1)"></a> + <a xlink:href="javascript:alert(2)"></a> + </svg> + `, + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp {} + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const links = fixture.nativeElement.querySelectorAll('a'); + expect(links[0].getAttribute('href')).toEqual('javascript:alert(1)'); + expect(links[1].getAttribute('xlink:href')).toEqual('javascript:alert(2)'); + }); +});
packages/core/test/linker/security_integration_spec.ts+28 −0 modified@@ -209,6 +209,22 @@ describe('security integration tests', function () { checkEscapeOfHrefProperty(fixture); }); + it('should escape unsafe attributes on custom namespaced elements', () => { + const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" [attr.href]="ctxProp">Link Title</xhtml:a>`; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + const fixture = TestBed.createComponent(SecuredComponent); + + checkEscapeOfHrefProperty(fixture); + }); + + it('should escape unsafe properties on custom namespaced elements', () => { + const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" [href]="ctxProp">Link Title</xhtml:a>`; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + const fixture = TestBed.createComponent(SecuredComponent); + + checkEscapeOfHrefProperty(fixture); + }); + it('should escape unsafe properties if they are used in host bindings', () => { @Directive({ selector: '[dirHref]', @@ -348,6 +364,18 @@ describe('security integration tests', function () { expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)'); }); + it('should sanitize translated static href attributes on custom namespaced elements', () => { + loadTranslations({[computeMsgId('/safe')]: 'javascript:alert(1)'}); + const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" href="/safe" i18n-href>Link</xhtml:a>`; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + const fixture = TestBed.createComponent(SecuredComponent); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)'); + }); + it('should throw error on security-sensitive attributes with constant values', () => { const template = `<iframe srcdoc="foo" i18n-srcdoc></iframe>`; TestBed.overrideComponent(SecuredComponent, {set: {template}});
90494cd909d7fix(compiler): strip namespaced SVG script elements during template compilation
6 files changed · +30 −54
packages/compiler-cli/test/ngtsc/ngtsc_spec.ts+0 −28 modified@@ -8648,34 +8648,6 @@ runInEachFileSystem((os: string) => { expect(trim(jsContents)).toContain(trim(hostBindingsFn)); }); - it('should generate sanitizers for URL properties in SVG script fn in Component', () => { - env.write( - 'test.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - selector: 'test-cmp', - template: \` - <svg> - <script [attr.xlink:href]="attr" [attr.href]="attr"></script> - </svg> - \`, - }) - export class TestCmp { - attr = './script.js'; - } - `, - ); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - expect(jsContents).toContain( - 'i0.ɵɵattribute("href", ctx.attr, i0.ɵɵsanitizeResourceUrl, "xlink")("href", ctx.attr, i0.ɵɵsanitizeResourceUrl);', - ); - }); - it('should not generate sanitizers for URL properties in hostBindings fn in Component', () => { env.write( `test.ts`,
packages/compiler/src/schema/dom_security_schema.ts+0 −6 modified@@ -115,12 +115,6 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { ['object', ['codebase', 'data']], ]); - // The below are for Script SVG - // See: https://developer.mozilla.org/en-US/docs/Web/API/SVGScriptElement/href - registerContext(SecurityContext.RESOURCE_URL, SVG_NAMESPACE, [ - ['script', ['src', 'href', 'xlink:href']], - ]); - // Keep this in sync with SECURITY_SENSITIVE_ELEMENTS in packages/core/src/sanitization/sanitization.ts // The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings) // and the directive can be applied to multiple different elements (with different tag names). In this case we generate
packages/compiler/src/template_parser/template_preparser.ts+11 −14 modified@@ -14,8 +14,8 @@ const LINK_ELEMENT = 'link'; const LINK_STYLE_REL_ATTR = 'rel'; const LINK_STYLE_HREF_ATTR = 'href'; const LINK_STYLE_REL_VALUE = 'stylesheet'; -const STYLE_ELEMENT = 'style'; -const SCRIPT_ELEMENT = 'script'; +const STYLE_ELEMENTS: ReadonlySet<string> = new Set([':svg:style', 'style']); +const SCRIPT_ELEMENTS: ReadonlySet<string> = new Set([':svg:script', 'script']); const NG_NON_BINDABLE_ATTR = 'ngNonBindable'; const NG_PROJECT_AS = 'ngProjectAs'; @@ -25,7 +25,8 @@ export function preparseElement(ast: html.Element): PreparsedElement { let relAttr: string | null = null; let nonBindable = false; let projectAs = ''; - ast.attrs.forEach((attr) => { + + for (const attr of ast.attrs) { const lcAttrName = attr.name.toLowerCase(); if (lcAttrName == NG_CONTENT_SELECT_ATTR) { selectAttr = attr.value; @@ -40,15 +41,18 @@ export function preparseElement(ast: html.Element): PreparsedElement { projectAs = attr.value; } } - }); - selectAttr = normalizeNgContentSelect(selectAttr); + } + + // Normalize selector to '*' if empty + selectAttr ||= '*'; + const nodeName = ast.name.toLowerCase(); let type = PreparsedElementType.OTHER; if (isNgContent(nodeName)) { type = PreparsedElementType.NG_CONTENT; - } else if (nodeName == STYLE_ELEMENT) { + } else if (STYLE_ELEMENTS.has(nodeName)) { type = PreparsedElementType.STYLE; - } else if (nodeName == SCRIPT_ELEMENT) { + } else if (SCRIPT_ELEMENTS.has(nodeName)) { type = PreparsedElementType.SCRIPT; } else if (nodeName == LINK_ELEMENT && relAttr == LINK_STYLE_REL_VALUE) { type = PreparsedElementType.STYLESHEET; @@ -73,10 +77,3 @@ export class PreparsedElement { public projectAs: string, ) {} } - -function normalizeNgContentSelect(selectAttr: string | null): string { - if (selectAttr === null || selectAttr.length === 0) { - return '*'; - } - return selectAttr; -}
packages/core/src/sanitization/sanitization.ts+1 −2 modified@@ -219,8 +219,7 @@ const RESOURCE_MAP: Record<string, Record<string, true | undefined> | undefined> 'frame': {'src': true}, 'iframe': {'src': true}, 'media': {'src': true}, - 'script': {'src': true, 'href': true, 'xlink:href': true}, - ':svg:script': {'src': true, 'href': true, 'xlink:href': true}, + 'base': {'href': true}, 'link': {'href': true}, 'object': {'data': true, 'codebase': true},
packages/core/test/acceptance/security_spec.ts+14 −0 modified@@ -902,3 +902,17 @@ describe('Component host element validation', () => { } }); }); + +describe('SVG <script> bindings', () => { + it(`should remove svg <script> element`, () => { + @Component({ + template: `<svg><script src="https://bad.com/script.js"></script></svg>`, + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp {} + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('script')).toBeFalsy(); + }); +});
packages/core/test/sanitization/sanitization_spec.ts+4 −4 modified@@ -117,7 +117,7 @@ describe('sanitization', () => { [SecurityContext.RESOURCE_URL, ɵɵsanitizeResourceUrl], ]); Object.entries(schema).forEach(([key, context]) => { - if (context === SecurityContext.URL || SecurityContext.RESOURCE_URL) { + if (context === SecurityContext.URL || context === SecurityContext.RESOURCE_URL) { const [tag, prop] = key.split('|'); const contexts = contextsByProp.get(prop) || new Set<number>(); contexts.add(context); @@ -136,7 +136,7 @@ describe('sanitization', () => { expect(getUrlSanitizer('IFRAME', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); expect(getUrlSanitizer('IFRAME', 'src')).toEqual(ɵɵsanitizeResourceUrl); expect(getUrlSanitizer('iframe', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); - expect(getUrlSanitizer('ScRiPt', 'xLiNk:HrEf')).toEqual(ɵɵsanitizeResourceUrl); + expect(getUrlSanitizer('ScRiPt', 'xLiNk:HrEf')).toEqual(ɵɵsanitizeUrl); expect(getUrlSanitizer('A', 'HREF')).toEqual(ɵɵsanitizeUrl); }); @@ -149,8 +149,8 @@ describe('sanitization', () => { expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'iframe', 'SRC')).toThrowError(ERROR); - expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'ScRiPt', 'xLiNk:HrEf')).toThrowError( - ERROR, + expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'ScRiPt', 'xLiNk:HrEf')).toEqual( + 'unsafe:javascript:true', ); expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'A', 'HREF')).toEqual(
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
4News mentions
0No linked articles in our index yet.