mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
92db2bac88
commit
7c42e2ebeb
18 changed files with 327 additions and 154 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export enum SecurityContext {
|
|||
SCRIPT = 3,
|
||||
URL = 4,
|
||||
RESOURCE_URL = 5,
|
||||
ATTRIBUTE_NO_BINDING = 6,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue