Angular Stored XSS Vulnerability via SVG Animation, SVG URL and MathML Attributes
Description
Angular is a development platform for building mobile and desktop web applications using TypeScript/JavaScript and other languages. Prior to 21.0.2, 20.3.15, and 19.2.17, A Stored Cross-Site Scripting (XSS) vulnerability has been identified in the Angular Template Compiler. It occurs because the compiler's internal security schema is incomplete, allowing attackers to bypass Angular's built-in security sanitization. Specifically, the schema fails to classify certain URL-holding attributes (e.g., those that could contain javascript: URLs) as requiring strict URL security, enabling the injection of malicious scripts. This vulnerability is fixed in 21.0.2, 20.3.15, and 19.2.17.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@angular/compilernpm | >= 21.0.0-next.0, < 21.0.2 | 21.0.2 |
@angular/compilernpm | >= 20.0.0-next.0, < 20.3.15 | 20.3.15 |
@angular/compilernpm | >= 19.0.0-next.0, < 19.2.17 | 19.2.17 |
@angular/compilernpm | <= 18.2.14 | — |
Affected products
1Patches
11c6b0704fb63fix(compiler): prevent XSS via SVG animation `attributeName` and MathML/SVG URLs
18 files changed · +323 −150
goldens/public-api/core/errors.api.md+2 −0 modified@@ -186,6 +186,8 @@ export const enum RuntimeErrorCode { // (undocumented) UNKNOWN_ELEMENT = 304, // (undocumented) + UNSAFE_ATTRIBUTE_BINDING = -910, + // @deprecated (undocumented) UNSAFE_IFRAME_ATTRS = -910, // (undocumented) UNSAFE_VALUE_IN_RESOURCE_URL = 904,
packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/elements/iframe_attrs.js+1 −1 modified@@ -3,6 +3,6 @@ template: function MyComponent_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelement(0, "iframe", 0); } if (rf & 2) { - i0.ɵɵattribute("fetchpriority", "low", i0.ɵɵvalidateIframeAttribute)("allowfullscreen", ctx.fullscreen, i0.ɵɵvalidateIframeAttribute); + i0.ɵɵattribute("fetchpriority", "low", i0.ɵɵvalidateAttribute)("allowfullscreen", ctx.fullscreen, i0.ɵɵvalidateAttribute); } }
packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js+25 −1 modified@@ -923,10 +923,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE export class HostBindingIframeDir { constructor() { this.evil = 'evil'; + this.nonEvil = 'nonEvil'; } } HostBindingIframeDir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingIframeDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); -HostBindingIframeDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingIframeDir, isStandalone: true, selector: "iframe[hostBindingIframeDir]", host: { properties: { "innerHtml": "evil", "attr.style": "evil", "src": "evil", "sandbox": "evil" } }, ngImport: i0 }); +HostBindingIframeDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingIframeDir, isStandalone: true, selector: "iframe[hostBindingIframeDir]", host: { properties: { "innerHtml": "evil", "attr.style": "evil", "src": "evil", "sandbox": "evil", "attr.attributeName": "nonEvil" } }, ngImport: i0 }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingIframeDir, decorators: [{ type: Directive, args: [{ @@ -936,6 +937,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE '[attr.style]': 'evil', '[src]': 'evil', '[sandbox]': 'evil', + '[attr.attributeName]': 'nonEvil', + }, + }] + }] }); +export class HostBindingSvgAnimateDir { + constructor() { + this.evil = 'evil'; + } +} +HostBindingSvgAnimateDir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingSvgAnimateDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); +HostBindingSvgAnimateDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingSvgAnimateDir, isStandalone: true, selector: "animateMotion[hostBindingSvgAnimateDir]", host: { properties: { "attr.attributeName": "evil" } }, ngImport: i0 }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingSvgAnimateDir, decorators: [{ + type: Directive, + args: [{ + selector: 'animateMotion[hostBindingSvgAnimateDir]', + host: { + '[attr.attributeName]': 'evil', }, }] }] }); @@ -956,9 +974,15 @@ export declare class HostBindingImageDir { } export declare class HostBindingIframeDir { evil: string; + nonEvil: string; static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingIframeDir, never>; static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingIframeDir, "iframe[hostBindingIframeDir]", never, {}, {}, never, never, true, never>; } +export declare class HostBindingSvgAnimateDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingSvgAnimateDir, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingSvgAnimateDir, "animateMotion[hostBindingSvgAnimateDir]", never, {}, {}, never, never, true, never>; +} /**************************************************************************************************** * PARTIAL FILE: security_sensitive_constant_attributes.js
packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js+8 −2 modified@@ -14,7 +14,13 @@ hostBindings: function HostBindingImageDir_HostBindings(rf, ctx) { … hostBindings: function HostBindingIframeDir_HostBindings(rf, ctx) { if (rf & 2) { - $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, $r3$.ɵɵsanitizeResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateIframeAttribute); - $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle); + $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, $r3$.ɵɵsanitizeResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute); + $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil); } } +… +hostBindings: function HostBindingSvgAnimateDir_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("attributeName", ctx.evil, i0.ɵɵvalidateAttribute); + } +}
packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts+12 −0 modified@@ -31,8 +31,20 @@ export class HostBindingImageDir { '[attr.style]': 'evil', '[src]': 'evil', '[sandbox]': 'evil', + '[attr.attributeName]': 'nonEvil', }, }) export class HostBindingIframeDir { evil = 'evil'; + nonEvil = 'nonEvil'; +} + +@Directive({ + selector: 'animateMotion[hostBindingSvgAnimateDir]', + host: { + '[attr.attributeName]': 'evil', + }, +}) +export class HostBindingSvgAnimateDir { + evil = 'evil'; }
packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/sanitization.js+1 −1 modified@@ -11,7 +11,7 @@ template: function MyComponent_Template(rf, ctx) { $r3$.ɵɵadvance(); $r3$.ɵɵdomProperty("src", ctx.evil, $r3$.ɵɵsanitizeUrl); $r3$.ɵɵadvance(); - $r3$.ɵɵdomProperty("sandbox", ctx.evil, $r3$.ɵɵvalidateIframeAttribute); + $r3$.ɵɵdomProperty("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute); $r3$.ɵɵadvance(); $r3$.ɵɵdomProperty("href", $r3$.ɵɵinterpolate2("", ctx.evil, "", ctx.evil), $r3$.ɵɵsanitizeUrl); $r3$.ɵɵadvance();
packages/compiler-cli/test/ngtsc/ngtsc_spec.ts+33 −6 modified@@ -9258,6 +9258,33 @@ runInEachFileSystem((os: string) => { }); }); + describe('SVG animation processing', () => { + it('should generate SVG animation validation instruction', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '<svg><animate [attr.attributeName]="attr"></animate></svg>', + standalone: false, + }) + export class TestCmp { + attr = 'opacity'; + } + `, + ); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + expect(jsContents).toContain( + 'i0.ɵɵattribute("attributeName", ctx.attr, i0.ɵɵvalidateAttribute);', + ); + }); + }); + describe('inline resources', () => { it('should process inline <style> tags', () => { env.write( @@ -9679,11 +9706,11 @@ runInEachFileSystem((os: string) => { // Only `sandbox` has an extra validation fn (since it's security-sensitive), // the `title` property doesn't have an extra validation fn. expect(jsContents).toContain( - 'ɵɵdomProperty("sandbox", "", i0.ɵɵvalidateIframeAttribute)("title", "Hi!")', + 'ɵɵdomProperty("sandbox", "", i0.ɵɵvalidateAttribute)("title", "Hi!")', ); // The `allow` property is also security-sensitive, thus an extra validation fn. - expect(jsContents).toContain('ɵɵattribute("allow", "", i0.ɵɵvalidateIframeAttribute)'); + expect(jsContents).toContain('ɵɵattribute("allow", "", i0.ɵɵvalidateAttribute)'); }); it( @@ -9713,7 +9740,7 @@ runInEachFileSystem((os: string) => { // Make sure that the `sandbox` has an extra validation fn, // and the check is case-insensitive (since the `setAttribute` DOM API // is case-insensitive as well). - expect(jsContents).toContain('ɵɵattribute("SANDBOX", "", i0.ɵɵvalidateIframeAttribute)'); + expect(jsContents).toContain('ɵɵattribute("SANDBOX", "", i0.ɵɵvalidateAttribute)'); }, ); @@ -9771,11 +9798,11 @@ runInEachFileSystem((os: string) => { // The `sandbox` is potentially a security-sensitive attribute of an <iframe>. // Generate an extra validation function to invoke at runtime, which would // check if an underlying host element is an <iframe>. - expect(jsContents).toContain('ɵɵdomProperty("sandbox", "", i0.ɵɵvalidateIframeAttribute)'); + expect(jsContents).toContain('ɵɵdomProperty("sandbox", "", i0.ɵɵvalidateAttribute)'); // Similar to the above, but for an attribute binding (host attributes are // represented via `ɵɵattribute`). - expect(jsContents).toContain('ɵɵattribute("allow", "", i0.ɵɵvalidateIframeAttribute)'); + expect(jsContents).toContain('ɵɵattribute("allow", "", i0.ɵɵvalidateAttribute)'); }); it( @@ -9801,7 +9828,7 @@ runInEachFileSystem((os: string) => { // Make sure that we generate a validation fn for the `sandbox` attribute, // even when it was declared as `SANDBOX`. - expect(jsContents).toContain('ɵɵattribute("SANDBOX", "", i0.ɵɵvalidateIframeAttribute)'); + expect(jsContents).toContain('ɵɵattribute("SANDBOX", "", i0.ɵɵvalidateAttribute)'); }, ); });
packages/compiler/src/core.ts+1 −0 modified@@ -84,6 +84,7 @@ export enum SecurityContext { SCRIPT = 3, URL = 4, RESOURCE_URL = 5, + ATTRIBUTE_NO_BINDING = 6, } /**
packages/compiler/src/render3/r3_identifiers.ts+4 −4 modified@@ -455,6 +455,10 @@ export class Identifiers { // sanitization-related functions static sanitizeHtml: o.ExternalReference = {name: 'ɵɵsanitizeHtml', moduleName: CORE}; static sanitizeStyle: o.ExternalReference = {name: 'ɵɵsanitizeStyle', moduleName: CORE}; + static validateAttribute: o.ExternalReference = { + name: 'ɵɵvalidateAttribute', + moduleName: CORE, + }; static sanitizeResourceUrl: o.ExternalReference = { name: 'ɵɵsanitizeResourceUrl', moduleName: CORE, @@ -470,10 +474,6 @@ export class Identifiers { name: 'ɵɵtrustConstantResourceUrl', moduleName: CORE, }; - static validateIframeAttribute: o.ExternalReference = { - name: 'ɵɵvalidateIframeAttribute', - moduleName: CORE, - }; // Decorators static inputDecorator: o.ExternalReference = {name: 'Input', moduleName: CORE};
packages/compiler/src/schema/dom_security_schema.ts+100 −29 modified@@ -15,7 +15,6 @@ import {SecurityContext} from '../core'; // ================================================================================================= // // DO NOT EDIT THIS LIST OF SECURITY SENSITIVE PROPERTIES WITHOUT A SECURITY REVIEW! -// Reach out to mprobst for details. // // ================================================================================================= @@ -36,6 +35,7 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { 'area|ping', 'audio|src', 'a|href', + 'a|xlink:href', 'a|ping', 'blockquote|cite', 'body|background', @@ -49,7 +49,77 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { 'track|src', 'video|poster', 'video|src', + + // MathML namespace + // https://crsrc.org/c/third_party/blink/renderer/core/sanitizer/sanitizer.cc;l=753-768;drc=b3eb16372dcd3317d65e9e0265015e322494edcd;bpv=1;bpt=1 + 'annotation|href', + 'annotation|xlink:href', + 'annotation-xml|href', + 'annotation-xml|xlink:href', + 'maction|href', + 'maction|xlink:href', + 'malignmark|href', + 'malignmark|xlink:href', + 'math|href', + 'math|xlink:href', + 'mroot|href', + 'mroot|xlink:href', + 'msqrt|href', + 'msqrt|xlink:href', + 'merror|href', + 'merror|xlink:href', + 'mfrac|href', + 'mfrac|xlink:href', + 'mglyph|href', + 'mglyph|xlink:href', + 'msub|href', + 'msub|xlink:href', + 'msup|href', + 'msup|xlink:href', + 'msubsup|href', + 'msubsup|xlink:href', + 'mmultiscripts|href', + 'mmultiscripts|xlink:href', + 'mprescripts|href', + 'mprescripts|xlink:href', + 'mi|href', + 'mi|xlink:href', + 'mn|href', + 'mn|xlink:href', + 'mo|href', + 'mo|xlink:href', + 'mpadded|href', + 'mpadded|xlink:href', + 'mphantom|href', + 'mphantom|xlink:href', + 'mrow|href', + 'mrow|xlink:href', + 'ms|href', + 'ms|xlink:href', + 'mspace|href', + 'mspace|xlink:href', + 'mstyle|href', + 'mstyle|xlink:href', + 'mtable|href', + 'mtable|xlink:href', + 'mtd|href', + 'mtd|xlink:href', + 'mtr|href', + 'mtr|xlink:href', + 'mtext|href', + 'mtext|xlink:href', + 'mover|href', + 'mover|xlink:href', + 'munder|href', + 'munder|xlink:href', + 'munderover|href', + 'munderover|xlink:href', + 'semantics|href', + 'semantics|xlink:href', + 'none|href', + 'none|xlink:href', ]); + registerContext(SecurityContext.RESOURCE_URL, [ 'applet|code', 'applet|codebase', @@ -65,38 +135,39 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { 'object|data', 'script|src', ]); + + // Keep this in sync with SECURITY_SENSITIVE_ELEMENTS in packages/core/src/sanitization/sanitization.ts + // Unknown is the internal tag name for unknown elements example used for host-bindings. + // These are unsafe as `attributeName` can be `href` or `xlink:href` + // See: http://b/463880509#comment7 + + registerContext(SecurityContext.ATTRIBUTE_NO_BINDING, [ + 'animate|attributeName', + 'set|attributeName', + 'animateMotion|attributeName', + 'animateTransform|attributeName', + + 'unknown|attributeName', + + 'iframe|sandbox', + 'iframe|allow', + 'iframe|allowFullscreen', + 'iframe|referrerPolicy', + 'iframe|csp', + 'iframe|fetchPriority', + + 'unknown|sandbox', + 'unknown|allow', + 'unknown|allowFullscreen', + 'unknown|referrerPolicy', + 'unknown|csp', + 'unknown|fetchPriority', + ]); } + return _SECURITY_SCHEMA; } function registerContext(ctx: SecurityContext, specs: string[]) { for (const spec of specs) _SECURITY_SCHEMA[spec.toLowerCase()] = ctx; } - -/** - * The set of security-sensitive attributes of an `<iframe>` that *must* be - * applied as a static attribute only. This ensures that all security-sensitive - * attributes are taken into account while creating an instance of an `<iframe>` - * at runtime. - * - * Note: avoid using this set directly, use the `isIframeSecuritySensitiveAttr` function - * in the code instead. - */ -export const IFRAME_SECURITY_SENSITIVE_ATTRS = new Set([ - 'sandbox', - 'allow', - 'allowfullscreen', - 'referrerpolicy', - 'csp', - 'fetchpriority', -]); - -/** - * Checks whether a given attribute name might represent a security-sensitive - * attribute of an <iframe>. - */ -export function isIframeSecuritySensitiveAttr(attrName: string): boolean { - // The `setAttribute` DOM API is case-insensitive, so we lowercase the value - // before checking it against a known security-sensitive attributes. - return IFRAME_SECURITY_SENSITIVE_ATTRS.has(attrName.toLowerCase()); -}
packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts+4 −38 modified@@ -9,10 +9,8 @@ import {SecurityContext} from '../../../../core'; import * as o from '../../../../output/output_ast'; import {Identifiers} from '../../../../render3/r3_identifiers'; -import {isIframeSecuritySensitiveAttr} from '../../../../schema/dom_security_schema'; import * as ir from '../../ir'; import {CompilationJob, CompilationJobKind} from '../compilation'; -import {createOpXrefMap} from '../util/elements'; /** * Map of security contexts to their sanitizer function. @@ -23,6 +21,7 @@ const sanitizerFns = new Map<SecurityContext, o.ExternalReference>([ [SecurityContext.SCRIPT, Identifiers.sanitizeScript], [SecurityContext.STYLE, Identifiers.sanitizeStyle], [SecurityContext.URL, Identifiers.sanitizeUrl], + [SecurityContext.ATTRIBUTE_NO_BINDING, Identifiers.validateAttribute], ]); /** @@ -38,8 +37,6 @@ const trustedValueFns = new Map<SecurityContext, o.ExternalReference>([ */ export function resolveSanitizers(job: CompilationJob): void { for (const unit of job.units) { - const elements = createOpXrefMap(unit); - // For normal element bindings we create trusted values for security sensitive constant // attributes. However, for host bindings we skip this step (this matches what // TemplateDefinitionBuilder does). @@ -63,8 +60,8 @@ export function resolveSanitizers(job: CompilationJob): void { if ( Array.isArray(op.securityContext) && op.securityContext.length === 2 && - op.securityContext.indexOf(SecurityContext.URL) > -1 && - op.securityContext.indexOf(SecurityContext.RESOURCE_URL) > -1 + op.securityContext.includes(SecurityContext.URL) && + op.securityContext.includes(SecurityContext.RESOURCE_URL) ) { // When the host element isn't known, some URL attributes (such as "src" and "href") may // be part of multiple different security contexts. In this case we use special @@ -74,46 +71,15 @@ export function resolveSanitizers(job: CompilationJob): void { } else { sanitizerFn = sanitizerFns.get(getOnlySecurityContext(op.securityContext)) ?? null; } + op.sanitizer = sanitizerFn !== null ? o.importExpr(sanitizerFn) : null; - // If there was no sanitization function found based on the security context of an - // attribute/property, check whether this attribute/property is one of the - // security-sensitive <iframe> attributes (and that the current element is actually an - // <iframe>). - if (op.sanitizer === null) { - let isIframe = false; - if (job.kind === CompilationJobKind.Host || op.kind === ir.OpKind.DomProperty) { - // Note: for host bindings defined on a directive, we do not try to find all - // possible places where it can be matched, so we can not determine whether - // the host element is an <iframe>. In this case, we just assume it is and append a - // validation function, which is invoked at runtime and would have access to the - // underlying DOM element to check if it's an <iframe> and if so - run extra checks. - isIframe = true; - } else { - // For a normal binding we can just check if the element its on is an iframe. - const ownerOp = elements.get(op.target); - if (ownerOp === undefined || !ir.isElementOrContainerOp(ownerOp)) { - throw Error('Property should have an element-like owner'); - } - isIframe = isIframeElement(ownerOp); - } - if (isIframe && isIframeSecuritySensitiveAttr(op.name)) { - op.sanitizer = o.importExpr(Identifiers.validateIframeAttribute); - } - } break; } } } } -/** - * Checks whether the given op represents an iframe element. - */ -function isIframeElement(op: ir.ElementOrContainerOps): boolean { - return op.kind === ir.OpKind.ElementStart && op.tag?.toLowerCase() === 'iframe'; -} - /** * Asserts that there is only a single security context and returns it. */
packages/compiler/test/security_spec.ts+0 −28 removed@@ -1,28 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import {IFRAME_SECURITY_SENSITIVE_ATTRS, SECURITY_SCHEMA} from '../src/schema/dom_security_schema'; - -describe('security-related tests', () => { - it('should have no overlap between `IFRAME_SECURITY_SENSITIVE_ATTRS` and `SECURITY_SCHEMA`', () => { - // The `IFRAME_SECURITY_SENSITIVE_ATTRS` and `SECURITY_SCHEMA` tokens configure sanitization - // and validation rules and used to pick the right sanitizer function. - // This test verifies that there is no overlap between two sets of rules to flag - // a situation when 2 sanitizer functions may be needed at the same time (in which - // case, compiler logic should be extended to support that). - const schema = new Set(); - Object.keys(SECURITY_SCHEMA()).forEach((key: string) => schema.add(key.toLowerCase())); - let hasOverlap = false; - IFRAME_SECURITY_SENSITIVE_ATTRS.forEach((attr) => { - if (schema.has('*|' + attr) || schema.has('iframe|' + attr)) { - hasOverlap = true; - } - }); - expect(hasOverlap).toBeFalse(); - }); -});
packages/core/src/core_render3_private_export.ts+1 −1 modified@@ -301,8 +301,8 @@ export { ɵɵsanitizeUrlOrResourceUrl, ɵɵtrustConstantHtml, ɵɵtrustConstantResourceUrl, + ɵɵvalidateAttribute, } from './sanitization/sanitization'; -export {ɵɵvalidateIframeAttribute} from './sanitization/iframe_attrs_validation'; export {noSideEffects as ɵnoSideEffects} from './util/closure'; export {AfterRenderManager as ɵAfterRenderManager} from './render3/after_render/manager'; export {depsTracker as ɵdepsTracker} from './render3/deps_tracker/deps_tracker';
packages/core/src/errors.ts+5 −0 modified@@ -120,6 +120,11 @@ export const enum RuntimeErrorCode { TYPE_IS_NOT_STANDALONE = 907, MISSING_ZONEJS = 908, UNEXPECTED_ZONE_STATE = 909, + UNSAFE_ATTRIBUTE_BINDING = -910, + /** + * @deprecated use `UNSAFE_ATTRIBUTE_BINDING` instead. + */ + // tslint:disable-next-line:no-duplicate-enum-values UNSAFE_IFRAME_ATTRS = -910, VIEW_ALREADY_DESTROYED = 911, COMPONENT_ID_COLLISION = -912,
packages/core/src/render3/jit/environment.ts+1 −1 modified@@ -183,11 +183,11 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({ 'ɵɵsanitizeStyle': sanitization.ɵɵsanitizeStyle, 'ɵɵsanitizeResourceUrl': sanitization.ɵɵsanitizeResourceUrl, 'ɵɵsanitizeScript': sanitization.ɵɵsanitizeScript, + 'ɵɵvalidateAttribute': sanitization.ɵɵvalidateAttribute, 'ɵɵsanitizeUrl': sanitization.ɵɵsanitizeUrl, 'ɵɵsanitizeUrlOrResourceUrl': sanitization.ɵɵsanitizeUrlOrResourceUrl, 'ɵɵtrustConstantHtml': sanitization.ɵɵtrustConstantHtml, 'ɵɵtrustConstantResourceUrl': sanitization.ɵɵtrustConstantResourceUrl, - 'ɵɵvalidateIframeAttribute': iframe_attrs_validation.ɵɵvalidateIframeAttribute, 'forwardRef': forwardRef, 'resolveForwardRef': resolveForwardRef,
packages/core/src/sanitization/iframe_attrs_validation.ts+15 −36 modified@@ -6,52 +6,31 @@ * found in the LICENSE file at https://angular.dev/license */ -import {RuntimeError, RuntimeErrorCode} from '../errors'; -import {getTemplateLocationDetails} from '../render3/instructions/element_validation'; -import {TNodeType} from '../render3/interfaces/node'; -import {RComment, RElement} from '../render3/interfaces/renderer_dom'; import {RENDERER} from '../render3/interfaces/view'; import {nativeRemoveNode} from '../render3/dom_node_manipulation'; -import {getLView, getSelectedTNode} from '../render3/state'; -import {getNativeByTNode} from '../render3/util/view_utils'; +import {getLView} from '../render3/state'; import {trustedHTMLFromString} from '../util/security/trusted_types'; /** - * Validation function invoked at runtime for each binding that might potentially - * represent a security-sensitive attribute of an <iframe>. - * See `IFRAME_SECURITY_SENSITIVE_ATTRS` in the - * `packages/compiler/src/schema/dom_security_schema.ts` script for the full list + * Enforces security by neutralizing an `<iframe>` if a security-sensitive attribute is set. + * + * This function is invoked at runtime when a security-sensitive attribute is bound to an `<iframe>`. + * It clears the `src` and `srcdoc` attributes and removes the `<iframe>` from the DOM to prevent + * potential security risks. + * + * @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts) for the full list * of such attributes. * * @codeGenApi */ -export function ɵɵvalidateIframeAttribute(attrValue: any, tagName: string, attrName: string) { +export function enforceIframeSecurity(iframe: HTMLIFrameElement): void { const lView = getLView(); - const tNode = getSelectedTNode()!; - const element = getNativeByTNode(tNode, lView) as RElement | RComment; - - // Restrict any dynamic bindings of security-sensitive attributes/properties - // on an <iframe> for security reasons. - if (tNode.type === TNodeType.Element && tagName.toLowerCase() === 'iframe') { - const iframe = element as HTMLIFrameElement; - - // Unset previously applied `src` and `srcdoc` if we come across a situation when - // a security-sensitive attribute is set later via an attribute/property binding. - iframe.src = ''; - iframe.srcdoc = trustedHTMLFromString('') as unknown as string; - // Also remove the <iframe> from the document. - nativeRemoveNode(lView[RENDERER], iframe); + // Unset previously applied `src` and `srcdoc` if we come across a situation when + // a security-sensitive attribute is set later via an attribute/property binding. + iframe.src = ''; + iframe.srcdoc = trustedHTMLFromString('') as unknown as string; - const errorMessage = - ngDevMode && - `Angular has detected that the \`${attrName}\` was applied ` + - `as a binding to an <iframe>${getTemplateLocationDetails(lView)}. ` + - `For security reasons, the \`${attrName}\` can be set on an <iframe> ` + - `as a static attribute only. \n` + - `To fix this, switch the \`${attrName}\` binding to a static attribute ` + - `in a template or in host bindings section.`; - throw new RuntimeError(RuntimeErrorCode.UNSAFE_IFRAME_ATTRS, errorMessage); - } - return attrValue; + // Also remove the <iframe> from the document. + nativeRemoveNode(lView[RENDERER], iframe); }
packages/core/src/sanitization/sanitization.ts+67 −1 modified@@ -8,10 +8,14 @@ import {XSS_SECURITY_URL} from '../error_details_base_url'; import {RuntimeError, RuntimeErrorCode} from '../errors'; +import {getTemplateLocationDetails} from '../render3/instructions/element_validation'; import {getDocument} from '../render3/interfaces/document'; +import {TNodeType} from '../render3/interfaces/node'; +import {RElement} from '../render3/interfaces/renderer_dom'; import {ENVIRONMENT} from '../render3/interfaces/view'; -import {getLView} from '../render3/state'; +import {getLView, getSelectedTNode} from '../render3/state'; import {renderStringify} from '../render3/util/stringify_utils'; +import {getNativeByTNode} from '../render3/util/view_utils'; import {TrustedHTML, TrustedScript, TrustedScriptURL} from '../util/security/trusted_type_defs'; import {trustedHTMLFromString, trustedScriptURLFromString} from '../util/security/trusted_types'; import { @@ -22,6 +26,7 @@ import { import {allowSanitizationBypassAndThrow, BypassType, unwrapSafeValue} from './bypass'; import {_sanitizeHtml as _sanitizeHtml} from './html_sanitizer'; +import {enforceIframeSecurity} from './iframe_attrs_validation'; import {Sanitizer} from './sanitizer'; import {SecurityContext} from './security'; import {_sanitizeUrl as _sanitizeUrl} from './url_sanitizer'; @@ -273,3 +278,64 @@ function getSanitizer(): Sanitizer | null { const lView = getLView(); return lView && lView[ENVIRONMENT].sanitizer; } + +const attributeName: ReadonlySet<string> = new Set(['attributename']); + +/** + * @remarks Keep this in sync with DOM Security Schema. + * @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts) + */ +const SECURITY_SENSITIVE_ELEMENTS: Readonly<Record<string, ReadonlySet<string>>> = { + 'iframe': new Set([ + 'sandbox', + 'allow', + 'allowfullscreen', + 'referrerpolicy', + 'csp', + 'fetchpriority', + ]), + 'animate': attributeName, + 'set': attributeName, + 'animatemotion': attributeName, + 'animatetransform': attributeName, +}; + +/** + * Validates that the attribute binding is safe to use. + * + * @param value The value of the attribute. + * @param tagName The name of the tag. + * @param attributeName The name of the attribute. + */ +export function ɵɵvalidateAttribute( + value: unknown, + tagName: string, + attributeName: string, +): unknown { + const lowerCaseTagName = tagName.toLowerCase(); + const lowerCaseAttrName = attributeName.toLowerCase(); + if (!SECURITY_SENSITIVE_ELEMENTS[lowerCaseTagName]?.has(lowerCaseAttrName)) { + return value; + } + + const tNode = getSelectedTNode()!; + if (tNode.type !== TNodeType.Element) { + return value; + } + + const lView = getLView(); + if (lowerCaseTagName === 'iframe') { + const element = getNativeByTNode(tNode, lView) as RElement; + enforceIframeSecurity(element as HTMLIFrameElement); + } + + const errorMessage = + ngDevMode && + `Angular has detected that the \`${attributeName}\` was applied ` + + `as a binding to the <${tagName}> element${getTemplateLocationDetails(lView)}. ` + + `For security reasons, the \`${attributeName}\` can be set on the <${tagName}> element ` + + `as a static attribute only. \n` + + `To fix this, switch the \`${attributeName}\` binding to a static attribute ` + + `in a template or in host bindings section.`; + throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); +}
packages/core/test/acceptance/security_spec.ts+43 −1 modified@@ -67,7 +67,7 @@ describe('iframe processing', () => { }); }); function getErrorMessageRegexp() { - const errorMessagePart = 'NG0' + Math.abs(RuntimeErrorCode.UNSAFE_IFRAME_ATTRS).toString(); + const errorMessagePart = 'NG0' + Math.abs(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING).toString(); return new RegExp(errorMessagePart); } @@ -726,3 +726,45 @@ describe('iframe processing', () => { }); }); }); + +describe('SVG animation processing', () => { + it('should error when `attributeName` is bound', () => { + @Component({ + template: '<svg><animate [attr.attributeName]="attr"></animate></svg>', + }) + class TestCmp { + attr = 'href'; + } + + expect(() => { + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + }).toThrowError( + /NG0910: Angular has detected that the `attributeName` was applied as a binding to the <animate>/, + ); + }); + + it(`should error when a directive sets a 'attributeName' as an attribute binding`, () => { + @Directive({ + selector: '[dir]', + host: { + '[attr.attributeName]': "'href'", + }, + }) + class animateAttrDir {} + + @Component({ + imports: [animateAttrDir], + selector: 'my-comp', + template: '<svg><animate dir></animate></svg>', + }) + class TestCmp {} + + expect(() => { + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + }).toThrowError( + /NG0910: Angular has detected that the `attributeName` was applied as a binding to the <animate>/, + ); + }); +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-v4hv-rgfq-gp49ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66412ghsaADVISORY
- github.com/angular/angular/commit/1c6b0704fb63d051fab8acff84d076abfbc4893aghsax_refsource_MISCWEB
- github.com/angular/angular/security/advisories/GHSA-v4hv-rgfq-gp49ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.