fix(compiler): prevent XSS via SVG animation attributeName and MathML/SVG URLs

This commit implements a security fix to prevent XSS vulnerabilities where SVG animation elements (`<animate>`, `<set>`, etc.) could be used to modify the `href` or `xlink:href` attributes of other elements to `javascript:` URLs.

The fix introduces a runtime validation step:
- A new [ɵɵValidateAttribute](cci:1://file:///usr/local/google/home/alanagius/git/angular/packages/core/src/sanitization/sanitization.ts:276:0-288:1) instruction is used when `attributeName` is bound on SVG animation elements.
- If executed, a `RuntimeError` is thrown, preventing the binding.
- The compiler now identifies `attributeName` on SVG animation elements as security-sensitive and injects this validation.

Additionally, the DOM security schema has been updated to include a comprehensive list of MathML and SVG elements that accept `href` or `xlink:href` attributes, ensuring they are correctly treated as `SecurityContext.URL` and sanitized. This prevents malicious URLs from being bound to these attributes.

http://b/463880509
This commit is contained in:
Alan Agius 2025-12-01 08:07:46 +00:00 committed by Kristiyan Kostadinov
parent 92db2bac88
commit 7c42e2ebeb
18 changed files with 327 additions and 154 deletions

View file

@ -162,6 +162,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,

View file

@ -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);
}
}

View file

@ -843,7 +843,7 @@ export class HostBindingDir {
}
}
HostBindingDir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
HostBindingDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingDir, isStandalone: true, selector: "[hostBindingDir]", host: { properties: { "innerHtml": "evil", "href": "evil", "attr.style": "evil", "src": "evil", "sandbox": "evil" } }, ngImport: i0 });
HostBindingDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingDir, isStandalone: true, selector: "[hostBindingDir]", host: { properties: { "innerHtml": "evil", "href": "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: HostBindingDir, decorators: [{
type: Directive,
args: [{
@ -854,16 +854,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
'[attr.style]': 'evil',
'[src]': 'evil',
'[sandbox]': 'evil',
'[attr.attributeName]': 'nonEvil',
},
}]
}] });
export class HostBindingDir2 {
constructor() {
this.evil = 'evil';
this.nonEvil = 'nonEvil';
}
}
HostBindingDir2.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingDir2, deps: [], target: i0.ɵɵFactoryTarget.Directive });
HostBindingDir2.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingDir2, isStandalone: true, selector: "a", host: { properties: { "innerHtml": "evil", "href": "evil", "attr.style": "evil", "src": "evil", "sandbox": "evil" } }, ngImport: i0 });
HostBindingDir2.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingDir2, isStandalone: true, selector: "a", host: { properties: { "innerHtml": "evil", "href": "evil", "attr.style": "evil", "src": "evil", "sandbox": "nonEvil" } }, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingDir2, decorators: [{
type: Directive,
args: [{
@ -873,7 +875,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
'[href]': 'evil',
'[attr.style]': 'evil',
'[src]': 'evil',
'[sandbox]': 'evil',
'[sandbox]': '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',
},
}]
}] });
@ -889,9 +907,15 @@ export declare class HostBindingDir {
}
export declare class HostBindingDir2 {
evil: string;
nonEvil: string;
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingDir2, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingDir2, "a", 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

View file

@ -1,13 +1,19 @@
hostBindings: function HostBindingDir_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵhostProperty("innerHtml", ctx.evil, i0.ɵɵsanitizeHtml)("href", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("sandbox", ctx.evil, i0.ɵɵvalidateIframeAttribute);
i0.ɵɵattribute("style", ctx.evil, i0.ɵɵsanitizeStyle);
i0.ɵɵhostProperty("innerHtml", ctx.evil, i0.ɵɵsanitizeHtml)("href", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("sandbox", ctx.evil, i0.ɵɵvalidateAttribute);
i0.ɵɵattribute("style", ctx.evil, i0.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil, i0.ɵɵvalidateAttribute);
}
}
hostBindings: function HostBindingDir2_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵhostProperty("innerHtml", ctx.evil, i0.ɵɵsanitizeHtml)("href", ctx.evil, i0.ɵɵsanitizeUrl)("src", ctx.evil)("sandbox", ctx.evil, i0.ɵɵvalidateIframeAttribute);
i0.ɵɵhostProperty("innerHtml", ctx.evil, i0.ɵɵsanitizeHtml)("href", ctx.evil, i0.ɵɵsanitizeUrl)("src", ctx.evil)("sandbox", ctx.nonEvil);
i0.ɵɵattribute("style", ctx.evil, i0.ɵɵsanitizeStyle);
}
}
hostBindings: function HostBindingSvgAnimateDir_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵattribute("attributeName", ctx.evil, i0.ɵɵvalidateAttribute);
}
}

View file

@ -8,6 +8,7 @@ import {Directive} from '@angular/core';
'[attr.style]': 'evil',
'[src]': 'evil',
'[sandbox]': 'evil',
'[attr.attributeName]': 'nonEvil',
},
})
export class HostBindingDir {
@ -21,9 +22,20 @@ export class HostBindingDir {
'[href]': 'evil',
'[attr.style]': 'evil',
'[src]': 'evil',
'[sandbox]': 'evil',
'[sandbox]': 'nonEvil',
},
})
export class HostBindingDir2 {
evil = 'evil';
nonEvil = 'nonEvil';
}
@Directive({
selector: 'animateMotion[hostBindingSvgAnimateDir]',
host: {
'[attr.attributeName]': 'evil',
},
})
export class HostBindingSvgAnimateDir {
evil = 'evil';
}

View file

@ -11,7 +11,7 @@ template: function MyComponent_Template(rf, ctx) {
i0.ɵɵadvance();
i0.ɵɵproperty("src", ctx.evil, i0.ɵɵsanitizeUrl);
i0.ɵɵadvance();
i0.ɵɵproperty("sandbox", ctx.evil, i0.ɵɵvalidateIframeAttribute);
i0.ɵɵproperty("sandbox", ctx.evil, i0.ɵɵvalidateAttribute);
i0.ɵɵadvance();
i0.ɵɵpropertyInterpolate2("href", "", ctx.evil, "", ctx.evil, "", i0.ɵɵsanitizeUrl);
i0.ɵɵadvance();

View file

@ -9009,6 +9009,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(
@ -9430,11 +9457,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(
'ɵɵproperty("sandbox", "", i0.ɵɵvalidateIframeAttribute)("title", "Hi!")',
'ɵɵproperty("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(
@ -9464,7 +9491,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)');
},
);
@ -9523,11 +9550,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('ɵɵhostProperty("sandbox", "", i0.ɵɵvalidateIframeAttribute)');
expect(jsContents).toContain('ɵɵhostProperty("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(
@ -9553,7 +9580,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)');
},
);
});

View file

@ -83,6 +83,7 @@ export enum SecurityContext {
SCRIPT = 3,
URL = 4,
RESOURCE_URL = 5,
ATTRIBUTE_NO_BINDING = 6,
}
/**

View file

@ -570,6 +570,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,
@ -585,10 +589,6 @@ export class Identifiers {
name: 'ɵɵtrustConstantResourceUrl',
moduleName: CORE,
};
static validateIframeAttribute: o.ExternalReference = {
name: 'ɵɵvalidateIframeAttribute',
moduleName: CORE,
};
// type-checking
static InputSignalBrandWriteType = {name: 'ɵINPUT_SIGNAL_BRAND_WRITE_TYPE', moduleName: CORE};

View file

@ -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());
}

View file

@ -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.HostProperty) {
// 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.
*/

View file

@ -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();
});
});

View file

@ -303,8 +303,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 {

View file

@ -115,6 +115,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,

View file

@ -203,11 +203,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,

View file

@ -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;
// 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);
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);
}

View file

@ -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);
}

View file

@ -61,7 +61,7 @@ describe('comment node text escaping', () => {
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);
}
@ -761,3 +761,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>/,
);
});
});