diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js index 993805dc69ac..9328636dca1f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js @@ -981,6 +981,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE }, }] }] }); +export class HostBindingCustomSrcdocDir { + evil = 'evil'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); + static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcdocDir, isStandalone: true, selector: "safe-srcdoc-carrier", host: { properties: { "attr.srcdoc": "evil" } }, ngImport: i0 }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, decorators: [{ + type: Directive, + args: [{ + selector: 'safe-srcdoc-carrier', + host: { + '[attr.srcdoc]': 'evil', + }, + }] + }] }); +export class HostBindingCustomSrcDir { + evil = 'evil'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); + static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcDir, isStandalone: true, selector: "safe-src-carrier", host: { properties: { "attr.src": "evil" } }, ngImport: i0 }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, decorators: [{ + type: Directive, + args: [{ + selector: 'safe-src-carrier', + host: { + '[attr.src]': 'evil', + }, + }] + }] }); +export class HostBindingCustomDataDir { + evil = 'evil'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); + static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomDataDir, isStandalone: true, selector: "safe-data-carrier", host: { properties: { "attr.data": "evil" } }, ngImport: i0 }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, decorators: [{ + type: Directive, + args: [{ + selector: 'safe-data-carrier', + host: { + '[attr.data]': 'evil', + }, + }] + }] }); /**************************************************************************************************** * PARTIAL FILE: sanitization.d.ts @@ -1008,6 +1050,21 @@ export declare class HostBindingSvgAnimateDir { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵdir: i0.ɵɵDirectiveDeclaration; } +export declare class HostBindingCustomSrcdocDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomSrcDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomDataDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} /**************************************************************************************************** * PARTIAL FILE: security_sensitive_constant_attributes.js diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js index 9ea142fafedc..073c1eac4008 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js @@ -1,21 +1,21 @@ hostBindings: function HostBindingLinkDir_HostBindings(rf, ctx) { if (rf & 2) { - $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrl); + $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrlOrResourceUrl); $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle); } } … hostBindings: function HostBindingImageDir_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrl); + i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrlOrResourceUrl); i0.ɵɵattribute("style", ctx.evil, i0.ɵɵsanitizeStyle); } } … hostBindings: function HostBindingIframeDir_HostBindings(rf, ctx) { if (rf & 2) { - $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute); - $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil); + $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute); + $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil, i0.ɵɵvalidateAttribute); } } … @@ -24,3 +24,21 @@ hostBindings: function HostBindingSvgAnimateDir_HostBindings(rf, ctx) { i0.ɵɵattribute("attributeName", ctx.evil, i0.ɵɵvalidateAttribute); } } +… +hostBindings: function HostBindingCustomSrcdocDir_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("srcdoc", ctx.evil, i0.ɵɵsanitizeHtml); + } +} +… +hostBindings: function HostBindingCustomSrcDir_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl); + } +} +… +hostBindings: function HostBindingCustomDataDir_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("data", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts index 92a17ab2e3f5..54669e19872f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts @@ -49,3 +49,33 @@ export class HostBindingIframeDir { export class HostBindingSvgAnimateDir { evil = 'evil'; } + +@Directive({ + selector: 'safe-srcdoc-carrier', + host: { + '[attr.srcdoc]': 'evil', + }, +}) +export class HostBindingCustomSrcdocDir { + evil = 'evil'; +} + +@Directive({ + selector: 'safe-src-carrier', + host: { + '[attr.src]': 'evil', + }, +}) +export class HostBindingCustomSrcDir { + evil = 'evil'; +} + +@Directive({ + selector: 'safe-data-carrier', + host: { + '[attr.data]': 'evil', + }, +}) +export class HostBindingCustomDataDir { + evil = 'evil'; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts index f0fae600478d..8ee2476afe40 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts @@ -21,4 +21,18 @@ export declare class HostBindingSvgAnimateDir { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵdir: i0.ɵɵDirectiveDeclaration; } - +export declare class HostBindingCustomSrcdocDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomSrcDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomDataDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index e8d53ab0c475..291cbd11754b 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -8614,7 +8614,7 @@ runInEachFileSystem((os: string) => { hostVars: 6, hostBindings: function UnsafeAttrsDirective_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle); + i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrlOrResourceUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle); } } `; @@ -8660,14 +8660,14 @@ runInEachFileSystem((os: string) => { hostVars: 3, hostBindings: function UnsafePropsDirective_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle); + i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle); } } `; expect(trim(jsContents)).toContain(trim(hostBindingsFn)); }); - it('should not generate sanitizers for URL properties in hostBindings fn in Component', () => { + it('should generate concrete-host URL sanitizers in hostBindings fn in Component', () => { env.write( `test.ts`, ` @@ -8700,8 +8700,40 @@ runInEachFileSystem((os: string) => { hostVars: 5, hostBindings: function FooCmp_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrl)("title", ctx.titleProp); - i0.ɵɵattribute("src", ctx.srcAttr)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrl)("title", ctx.titleAttr); + i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleProp); + i0.ɵɵattribute("src", ctx.srcAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleAttr); + } + } + `; + expect(trim(jsContents)).toContain(trim(hostBindingsFn)); + }); + + it('should generate sanitizers for pure :not selector host bindings', () => { + env.write( + `test.ts`, + ` + import {Component} from '@angular/core'; + + @Component({ + selector: ':not(iframe)', + template: '', + host: { + '[attr.srcdoc]': 'srcdoc', + } + }) + class FooCmp { + srcdoc: any; + } + `, + ); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + const hostBindingsFn = ` + hostVars: 1, + hostBindings: function FooCmp_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("srcdoc", ctx.srcdoc, i0.ɵɵsanitizeHtml); } } `; diff --git a/packages/compiler/src/schema/dom_security_schema.ts b/packages/compiler/src/schema/dom_security_schema.ts index fae14f75b5db..fbd3c81f1368 100644 --- a/packages/compiler/src/schema/dom_security_schema.ts +++ b/packages/compiler/src/schema/dom_security_schema.ts @@ -56,8 +56,8 @@ type SecuritySchema = Record< >; let _SECURITY_SCHEMA!: SecuritySchema; -const SVG_NAMESPACE = 'svg'; -const MATH_ML_NAMESPACE = 'math'; +export const SVG_NAMESPACE = 'svg'; +export const MATH_ML_NAMESPACE = 'math'; const NO_NAMESPACE = ''; const MATCH_ALL_ELEMENTS = '*'; const createNullObj = () => Object.create(null); diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 9b63a8584191..db2b2069a630 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -21,7 +21,7 @@ import { } from '../../../render3/view/api'; import {icuFromI18nMessage} from '../../../render3/view/i18n/util'; import {DomElementSchemaRegistry} from '../../../schema/dom_element_schema_registry'; -import {BindingParser} from '../../../template_parser/binding_parser'; +import {BindingParser, calcPossibleSecurityContexts} from '../../../template_parser/binding_parser'; import * as ir from '../ir'; import { @@ -125,19 +125,21 @@ export function ingestHostBinding( if (property.isAnimation) { bindingKind = ir.BindingKind.Animation; } - const securityContexts = bindingParser - .calcPossibleSecurityContexts( - input.componentSelector, - property.name, - bindingKind === ir.BindingKind.Attribute, - ) - .filter((context) => context !== SecurityContext.NONE); + const securityContexts = calcHostBindingSecurityContexts( + bindingParser, + input.componentSelector, + property.name, + bindingKind === ir.BindingKind.Attribute, + ); ingestDomProperty(job, property, bindingKind, securityContexts); } for (const [name, expr] of Object.entries(input.attributes) ?? []) { - const securityContexts = bindingParser - .calcPossibleSecurityContexts(input.componentSelector, name, true) - .filter((context) => context !== SecurityContext.NONE); + const securityContexts = calcHostBindingSecurityContexts( + bindingParser, + input.componentSelector, + name, + true, + ); ingestHostAttribute(job, name, expr, securityContexts); } for (const event of input.events ?? []) { @@ -146,6 +148,42 @@ export function ingestHostBinding( return job; } +function calcHostBindingSecurityContexts( + bindingParser: BindingParser, + selector: string, + name: string, + isAttribute: boolean, +): SecurityContext[] { + const declaringSelectorContexts = bindingParser.calcPossibleSecurityContexts( + selector, + name, + isAttribute, + ); + const concreteHostContexts = calcPossibleSecurityContexts( + domSchema, + null, + domSchema.getMappedPropName(name), + isAttribute, + ); + const concreteHostNonNoneContexts = concreteHostContexts.filter( + (context) => context !== SecurityContext.NONE, + ); + const concreteHostNonNoneCount = concreteHostNonNoneContexts.length; + const hasConcreteHostNoneContext = concreteHostNonNoneCount !== concreteHostContexts.length; + + // Host bindings can run against a concrete host whose element name differs from the declaring + // selector, including dynamic root components whose TNode name is `#host`. + if (hasConcreteHostNoneContext && concreteHostNonNoneCount > 0) { + return concreteHostContexts; + } + + if (concreteHostNonNoneContexts.some((context) => !declaringSelectorContexts.includes(context))) { + return concreteHostContexts; + } + + return declaringSelectorContexts.filter((context) => context !== SecurityContext.NONE); +} + // TODO: We should refactor the parser to use the same types and structures for host bindings as // with ordinary components. This would allow us to share a lot more ingestion code. export function ingestDomProperty( diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts index c1b9ba3c6f44..d2c3731598ca 100644 --- a/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts @@ -60,14 +60,12 @@ export function resolveSanitizers(job: CompilationJob): void { let sanitizerFn: o.ExternalReference | null = null; if ( Array.isArray(op.securityContext) && - op.securityContext.length === 2 && - op.securityContext.includes(SecurityContext.URL) && - op.securityContext.includes(SecurityContext.RESOURCE_URL) + hasCompositeUrlSecurityContext(op.securityContext) ) { - // 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 - // sanitization function and select the actual sanitizer at runtime based on a tag name - // that is provided while invoking sanitization function. + // When the host element isn't known, attributes such as `href`, `src`, `data`, + // `action`, and `codebase` may be part of multiple security contexts. In this case we + // use a special sanitization function and select the actual behavior at runtime based + // on the concrete host element. sanitizerFn = Identifiers.sanitizeUrlOrResourceUrl; } else { sanitizerFn = sanitizerFns.get(getOnlySecurityContext(op.securityContext)) ?? null; @@ -81,21 +79,56 @@ export function resolveSanitizers(job: CompilationJob): void { } } +function hasCompositeUrlSecurityContext(securityContext: SecurityContext[]): boolean { + let hasUrlContext = false; + let hasResourceUrlContext = false; + let hasNoneContext = false; + + for (const context of securityContext) { + switch (context) { + case SecurityContext.URL: + hasUrlContext = true; + break; + case SecurityContext.RESOURCE_URL: + hasResourceUrlContext = true; + break; + case SecurityContext.NONE: + hasNoneContext = true; + break; + default: + return false; + } + } + + return ( + ((hasUrlContext || hasResourceUrlContext) && hasNoneContext) || + (hasUrlContext && hasResourceUrlContext) + ); +} + /** - * Asserts that there is only a single security context and returns it. + * Asserts that there is only a single non-NONE security context and returns it. */ function getOnlySecurityContext( securityContext: SecurityContext | SecurityContext[], ): SecurityContext { - if (Array.isArray(securityContext)) { - if (securityContext.length > 1) { - // TODO: What should we do here? TDB just took the first one, but this feels like something we - // would want to know about and create a special case for like we did for Url/ResourceUrl. My - // guess is that, outside of the Url/ResourceUrl case, this never actually happens. If there - // do turn out to be other cases, throwing an error until we can address it feels safer. - throw Error(`AssertionError: Ambiguous security context`); - } - return securityContext[0] || SecurityContext.NONE; + if (!Array.isArray(securityContext)) { + return securityContext; + } + + if (securityContext.length < 2) { + return securityContext[0] ?? SecurityContext.NONE; + } + + const nonNoneSecurityContexts = securityContext.filter( + (context) => context !== SecurityContext.NONE, + ); + if (nonNoneSecurityContexts.length > 1) { + // TODO: What should we do here? TDB just took the first one, but this feels like something we + // would want to know about and create a special case for like we did for Url/ResourceUrl. My + // guess is that, outside of the Url/ResourceUrl case, this never actually happens. If there + // do turn out to be other cases, throwing an error until we can address it feels safer. + throw Error(`AssertionError: Ambiguous security context`); } - return securityContext; + return nonNoneSecurityContexts[0] ?? SecurityContext.NONE; } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 71c5056cc0de..7693c877baf0 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -21,6 +21,7 @@ import {ComponentRef as AbstractComponentRef} from '../linker/component_factory' import {createElementRef, ElementRef} from '../linker/element_ref'; import {NgModuleRef} from '../linker/ng_module_factory'; import {RendererFactory2} from '../render/api'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema'; import {Sanitizer} from '../sanitization/sanitizer'; import {attachPatchData} from './context_discovery'; @@ -42,6 +43,7 @@ import { TElementContainerNode, TElementNode, TNode, + TNodeName, TNodeType, } from './interfaces/node'; import {RElement, RNode} from './interfaces/renderer_dom'; @@ -55,7 +57,6 @@ import { TVIEW, TViewType, } from './interfaces/view'; -import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; import {ProfilerEvent} from '../../primitives/devtools'; import {TracingService} from '../application/tracing'; @@ -369,7 +370,7 @@ export class ComponentFactory { HEADER_OFFSET, rootLView, TNodeType.Element, - '#host', + TNodeName.DynamicHost, () => rootTView.directiveRegistry, true, 0, diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index b2a9bae32d4c..1aaf0df3479c 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -73,6 +73,8 @@ import { setTIcu, setTNodeInsertBeforeIndex, } from './i18n_util'; +import {splitNsName} from '../util/tags'; +import {NAMESPACE_URIS} from '../namespaces'; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+:?\d*�\s*,\s*\S{6}\s*,[\s\S]*})/gi; @@ -658,7 +660,7 @@ function parseICUBlock(pattern: string): IcuExpression { const parts = i18nParseTextIntoPartsAndICU(pattern) as string[]; // Looking for (key block)+ sequence. One of the keys has to be "other". - for (let pos = 0; pos < parts.length; ) { + for (let pos = 0; pos < parts.length;) { let key = parts[pos++].trim(); if (icuType === IcuType.plural) { // Key can be "=x", we just want "x" @@ -814,13 +816,10 @@ function walkIcuTree( const attr = elAttrs.item(i)!; const lowerAttrName = attr.name.toLowerCase(); const hasBinding = !!attr.value.match(BINDING_REGEXP); - const elementNS = element.namespaceURI; - const tagNameWithNamespace = - elementNS === 'http://www.w3.org/2000/svg' - ? `:svg:${tagName}` - : elementNS === 'http://www.w3.org/1998/Math/MathML' - ? `:math:${tagName}` - : tagName; + const namespaceUri = element.namespaceURI; + const namespace = namespaceUri && NAMESPACE_URIS[namespaceUri]; + const tagNameWithNamespace = namespace ? `:${namespace}:${tagName}` : tagName; + if (hasBinding) { if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { generateBindingUpdateOpCodes( @@ -984,24 +983,6 @@ function addCreateAttribute( create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue); } -function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] { - if (elementName[0] != ':') { - return [null, elementName]; - } - - const colonIndex = elementName.indexOf(':', 1); - - if (colonIndex === -1) { - if (fatal) { - throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); - } else { - return [null, elementName]; - } - } - - return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; -} - function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null { let schemaContext: SecurityContext; diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index d6cca96f020b..74898f95f7d7 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -16,6 +16,14 @@ import {CssSelector} from './projection'; import {RNode} from './renderer_dom'; import type {LView, TView} from './view'; +/** + * Internal tag name used for a root host `TNode` when Angular creates a component against an + * existing host element. The concrete DOM tag is resolved from the native element at runtime. + */ +export const enum TNodeName { + DynamicHost = '#host', +} + /** * TNodeType corresponds to the {@link TNode} `type` property. * diff --git a/packages/core/src/render3/namespaces.ts b/packages/core/src/render3/namespaces.ts index 56ccf98f1029..ad1268061a23 100644 --- a/packages/core/src/render3/namespaces.ts +++ b/packages/core/src/render3/namespaces.ts @@ -6,5 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -export const SVG_NAMESPACE = 'svg'; -export const MATH_ML_NAMESPACE = 'math'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema'; + +export const NAMESPACE_URIS: Record = { + 'http://www.w3.org/2000/svg': SVG_NAMESPACE, + 'http://www.w3.org/1998/Math/MathML': MATH_ML_NAMESPACE, +}; diff --git a/packages/core/src/render3/state.ts b/packages/core/src/render3/state.ts index 0731b607a644..b0dfd8ee33fc 100644 --- a/packages/core/src/render3/state.ts +++ b/packages/core/src/render3/state.ts @@ -7,6 +7,7 @@ */ import {InternalInjectFlags} from '../di/interface/injector'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema'; import { assertDefined, assertEqual, @@ -31,7 +32,6 @@ import { TView, TViewType, } from './interfaces/view'; -import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; import {getTNode, walkUpViews} from './util/view_utils'; /** diff --git a/packages/core/src/render3/util/tags.ts b/packages/core/src/render3/util/tags.ts new file mode 100644 index 000000000000..b9c57f5d0b23 --- /dev/null +++ b/packages/core/src/render3/util/tags.ts @@ -0,0 +1,32 @@ +/** + * @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 + */ + +/** + * Splits an element name into its namespace and local name. + * + * @param elementName The element name to split, in the format ":namespace:name". + * @param fatal If true, throws an error if the element name is not in the correct format. + * @returns A tuple containing the namespace and local name. + */ +export function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] { + if (elementName[0] != ':') { + return [null, elementName]; + } + + const colonIndex = elementName.indexOf(':', 1); + + if (colonIndex === -1) { + if (fatal) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } else { + return [null, elementName]; + } + } + + return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; +} diff --git a/packages/core/src/sanitization/dom_security_schema.ts b/packages/core/src/sanitization/dom_security_schema.ts index fae14f75b5db..fbd3c81f1368 100644 --- a/packages/core/src/sanitization/dom_security_schema.ts +++ b/packages/core/src/sanitization/dom_security_schema.ts @@ -56,8 +56,8 @@ type SecuritySchema = Record< >; let _SECURITY_SCHEMA!: SecuritySchema; -const SVG_NAMESPACE = 'svg'; -const MATH_ML_NAMESPACE = 'math'; +export const SVG_NAMESPACE = 'svg'; +export const MATH_ML_NAMESPACE = 'math'; const NO_NAMESPACE = ''; const MATCH_ALL_ELEMENTS = '*'; const createNullObj = () => Object.create(null); diff --git a/packages/core/src/sanitization/sanitization.ts b/packages/core/src/sanitization/sanitization.ts index 253b77da016b..4c4fee794ea2 100644 --- a/packages/core/src/sanitization/sanitization.ts +++ b/packages/core/src/sanitization/sanitization.ts @@ -10,12 +10,13 @@ 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 {TNode, TNodeType} from '../render3/interfaces/node'; +import {TNode, TNodeName, TNodeType} from '../render3/interfaces/node'; import {RElement} from '../render3/interfaces/renderer_dom'; import {ENVIRONMENT} from '../render3/interfaces/view'; import {getLView, getSelectedIndex, getSelectedTNode} from '../render3/state'; import {renderStringify} from '../render3/util/stringify_utils'; import {getNativeByTNode} from '../render3/util/view_utils'; +import {NAMESPACE_URIS} from '../render3/namespaces'; import {TrustedHTML, TrustedScript, TrustedScriptURL} from '../util/security/trusted_type_defs'; import {trustedHTMLFromString, trustedScriptURLFromString} from '../util/security/trusted_types'; import { @@ -28,8 +29,9 @@ import {allowSanitizationBypassAndThrow, BypassType, unwrapSafeValue} from './by import {_sanitizeHtml} from './html_sanitizer'; import {enforceIframeSecurity} from './iframe_attrs_validation'; import {Sanitizer} from './sanitizer'; -import {SecurityContext} from './dom_security_schema'; +import {checkSecurityContext, SecurityContext, SVG_NAMESPACE} from './dom_security_schema'; import {_sanitizeUrl} from './url_sanitizer'; +import {splitNsName} from '../render3/util/tags'; /** * An `html` sanitizer which converts untrusted `html` **string** into trusted string by removing @@ -46,7 +48,19 @@ import {_sanitizeUrl} from './url_sanitizer'; * * @codeGenApi */ -export function ɵɵsanitizeHtml(unsafeHtml: any): TrustedHTML | string { +export function ɵɵsanitizeHtml( + unsafeHtml: any, + tagName?: string, + propName?: string, +): TrustedHTML | string { + if ( + tagName !== undefined && + propName !== undefined && + getSecurityContext(tagName, propName) !== SecurityContext.HTML + ) { + return unsafeHtml; + } + const sanitizer = getSanitizer(); if (sanitizer) { return trustedHTMLFromStringBypass(sanitizer.sanitize(SecurityContext.HTML, unsafeHtml) || ''); @@ -213,29 +227,22 @@ export function ɵɵtrustConstantResourceUrl(url: TemplateStringsArray): Trusted return trustedScriptURLFromString(url[0]); } -// Define sets outside the function for O(1) lookups and memory efficiency -const RESOURCE_MAP: Record | undefined> = { - 'embed': {'src': true}, - 'frame': {'src': true}, - 'iframe': {'src': true}, - 'media': {'src': true}, - - 'base': {'href': true}, - 'link': {'href': true}, - 'object': {'data': true, 'codebase': true}, -}; - /** * Detects which sanitizer to use for URL property, based on tag name and prop name. * - * The rules are based on the RESOURCE_URL context config from + * The rules are based on the URL and RESOURCE_URL context config from * `packages/compiler/src/schema/dom_security_schema.ts`. - * If tag and prop names don't match Resource URL schema, use URL sanitizer. + * If tag and prop names don't match URL or Resource URL schema, no sanitizer is required. */ export function getUrlSanitizer(tag: string, prop: string) { - const isResource = RESOURCE_MAP[tag.toLowerCase()]?.[prop.toLowerCase()] === true; - - return isResource ? ɵɵsanitizeResourceUrl : ɵɵsanitizeUrl; + switch (getSecurityContext(tag, prop)) { + case SecurityContext.RESOURCE_URL: + return ɵɵsanitizeResourceUrl; + case SecurityContext.URL: + return ɵɵsanitizeUrl; + default: + return null; + } } /** @@ -253,8 +260,8 @@ export function getUrlSanitizer(tag: string, prop: string) { * * @codeGenApi */ -export function ɵɵsanitizeUrlOrResourceUrl(unsafeUrl: any, tag: string, prop: string): any { - return getUrlSanitizer(tag, prop)(unsafeUrl); +export function ɵɵsanitizeUrlOrResourceUrl(unsafeUrl: any, tag: string, prop: string) { + return getUrlSanitizer(tag, prop)?.(unsafeUrl) ?? unsafeUrl; } export function validateAgainstEventProperties(name: string) { @@ -273,6 +280,37 @@ function getSanitizer(): Sanitizer | null { return lView && lView[ENVIRONMENT].sanitizer; } +function getSecurityContext(tagName: string, propName: string): SecurityContext { + const [namespace, resolvedTagName] = resolveElement(tagName); + return checkSecurityContext(resolvedTagName, propName, namespace); +} + +function resolveElement(tagName: string): [namespace: string | null | undefined, tagName: string] { + tagName = tagName.toLowerCase(); + const splitResult = splitNsName(tagName, false); + if (splitResult[0]) { + return splitResult; + } + + const index = getSelectedIndex(); + const tNode = index === -1 ? null : getSelectedTNode(); + let namespace = tNode?.namespace; + + if (tagName === TNodeName.DynamicHost && tNode?.type === TNodeType.Element) { + const element = getNativeByTNode(tNode, getLView()) as RElement; + if (element.tagName) { + tagName = element.tagName.toLowerCase(); + } + + if (namespace == null) { + const namespaceURI = (element as RElement & {namespaceURI?: string | null}).namespaceURI; + namespace = namespaceURI && NAMESPACE_URIS[namespaceURI]; + } + } + + return [namespace, tagName]; +} + /** * Set of attributes that are sensitive and should be sanitized. */ @@ -282,28 +320,16 @@ const SECURITY_SENSITIVE_ATTRIBUTE_NAMES: ReadonlySet = new Set(['href', * @remarks Keep this in sync with DOM Security Schema. * @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts) */ -const SECURITY_SENSITIVE_ELEMENTS: Record< +const SVG_ANIMATION_SENSITIVE_STATIC_VALUES: Record< string, - Record> | undefined + Record> | undefined > = { - 'iframe': { - 'sandbox': true, - 'allow': true, - 'allowfullscreen': true, - 'referrerpolicy': true, - 'csp': true, - 'fetchpriority': true, - 'credentialless': true, - }, - ':svg:animate': { - 'attributename': true, + 'animate': { 'to': SECURITY_SENSITIVE_ATTRIBUTE_NAMES, 'values': SECURITY_SENSITIVE_ATTRIBUTE_NAMES, 'from': SECURITY_SENSITIVE_ATTRIBUTE_NAMES, }, - ':svg:set': {'attributename': true, 'to': SECURITY_SENSITIVE_ATTRIBUTE_NAMES}, - ':svg:animatemotion': {'attributename': true}, - ':svg:animatetransform': {'attributename': true}, + 'set': {'to': SECURITY_SENSITIVE_ATTRIBUTE_NAMES}, }; /** @@ -314,78 +340,58 @@ const SECURITY_SENSITIVE_ELEMENTS: Record< * @param attributeName The name of the attribute. */ export function ɵɵvalidateAttribute(value: T, tagName: string, attributeName: string): T { - const lowerCaseTagName = tagName.toLowerCase(); - const lowerCaseAttrName = attributeName.toLowerCase(); - const index = getSelectedIndex(); const tNode: TNode | null = index === -1 ? null : getSelectedTNode(); if (tNode && tNode.type !== TNodeType.Element) { return value; } - // Leverage tNode.namespace if active, otherwise check both namespaced and base variants. - const fullTagName = - lowerCaseTagName[0] !== ':' && tNode?.namespace - ? `:${tNode.namespace}:${lowerCaseTagName}` - : lowerCaseTagName; + const [namespace, resolvedTagName] = resolveElement(tagName); + const securityContext = checkSecurityContext(resolvedTagName, attributeName, namespace); - const validationConfig = SECURITY_SENSITIVE_ELEMENTS[fullTagName]?.[lowerCaseAttrName]; - - if (!validationConfig) { + if (securityContext !== SecurityContext.ATTRIBUTE_NO_BINDING) { return value; } const lView = getLView(); - if (tNode && lowerCaseTagName === 'iframe') { - const element = getNativeByTNode(tNode, lView) as RElement; - enforceIframeSecurity(element as HTMLIFrameElement); - } - - const displayTagName = tagName[0] === ':' ? tagName.split(':').pop()! : tagName; - - if (typeof validationConfig !== 'boolean') { - if (!tNode) { - const errorMessage = - ngDevMode && - `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${tagName}> element. ` + - `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); - } - - const element = getNativeByTNode(tNode, lView) as SVGAnimateElement; - const attributeNameValue = getSecuritySensitiveSVGAnimationAttributeName( - element, - validationConfig, - ); - - if (attributeNameValue) { - const errorMessage = - ngDevMode && - `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${displayTagName}> element${getTemplateLocationDetails(lView)}. ` + - `For security reasons, the \`${attributeName}\` can be set on the <${displayTagName}> element ` + - `as a static attribute only when the "attributeName" is set to \'${attributeNameValue}\'. \n` + - `To fix this, switch the \`${attributeNameValue}\` binding to a static attribute ` + - `in a template or in host bindings section.`; - - throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); + if (tNode) { + if (resolvedTagName === 'iframe') { + const element = getNativeByTNode(tNode, lView) as RElement; + enforceIframeSecurity(element as HTMLIFrameElement); + } else if (namespace === SVG_NAMESPACE) { + const config = + SVG_ANIMATION_SENSITIVE_STATIC_VALUES[resolvedTagName]?.[attributeName.toLowerCase()]; + if (config) { + const element = getNativeByTNode(tNode, lView) as SVGAnimateElement; + const attributeNameValue = getSecuritySensitiveSVGAnimationAttributeName(element, config); + + if (attributeNameValue) { + const errorMessage = + ngDevMode && + `Angular has detected that the \`${attributeName}\` was applied ` + + `as a binding to the <${resolvedTagName}> element${getTemplateLocationDetails(lView)}. ` + + `For security reasons, the \`${attributeName}\` can be set on the <${resolvedTagName}> element ` + + `as a static attribute only when the "attributeName" is set to \'${attributeNameValue}\'. \n` + + `To fix this, switch the \`${attributeNameValue}\` binding to a static attribute ` + + `in a template or in host bindings section.`; + + throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); + } + + return value; + } } - - return value; } const errorMessage = ngDevMode && `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${displayTagName}> element${tNode ? getTemplateLocationDetails(lView) : ''}. ` + - `For security reasons, the \`${attributeName}\` can be set on the <${displayTagName}> element ` + + `as a binding to the <${resolvedTagName}> element${tNode ? getTemplateLocationDetails(lView) : ''}. ` + + `For security reasons, the \`${attributeName}\` can be set on the <${resolvedTagName}> 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); } diff --git a/packages/core/test/acceptance/security_spec.ts b/packages/core/test/acceptance/security_spec.ts index a317064ac1bb..352b51accf7c 100644 --- a/packages/core/test/acceptance/security_spec.ts +++ b/packages/core/test/acceptance/security_spec.ts @@ -9,11 +9,15 @@ import {NgIf} from '@angular/common'; import {DomSanitizer} from '@angular/platform-browser'; import { + ApplicationRef, Component, + ComponentRef, createComponent, Directive, EnvironmentInjector, inject, + inputBinding, + Input, provideZoneChangeDetection, TemplateRef, Type, @@ -25,6 +29,9 @@ import {RuntimeErrorCode} from '../../src/errors'; import {global} from '../../src/util/global'; import {ComponentFixture, TestBed} from '../../testing'; +const SVG_NAMESPACE_URI = 'http://www.w3.org/2000/svg'; +const MATH_ML_NAMESPACE_URI = 'http://www.w3.org/1998/Math/MathML'; + describe('comment node text escaping', () => { // see: https://html.spec.whatwg.org/multipage/syntax.html#comments [ @@ -872,6 +879,391 @@ describe('innerHTML processing', () => { }); }); +describe('host binding sanitization', () => { + const HOST_BINDING_URL = 'http://server/asset'; + const HOST_BINDING_UNSAFE_URL = 'javascript:custom-data'; + const UNSAFE_HTML = `` + '

safe

'; + const SANITIZED_HTML = '

safe

'; + const resourceUrlError = /NG0904: unsafe value used in a resource URL context.*/; + + async function expectHostBinding(options: { + tagName: string; + attrName: string; + value: string; + expected?: string; + expectedError?: RegExp; + namespace?: string; + componentSelector?: string; + }): Promise { + // Avoid duplicate selector generation. + const randomIdentifier = Math.floor(Math.random() * 100); + + const { + tagName, + attrName, + value, + expected, + expectedError, + namespace, + componentSelector = `dynamic-host-${randomIdentifier}`, + } = options; + + @Directive({ + selector: `[safe-data-carrier-${randomIdentifier}]`, + host: {[`[attr.${attrName}]`]: 'val'}, + }) + class CarrierDirective { + val = value; + } + + @Component({ + selector: componentSelector, + template: '', + }) + class DynamicComponent {} + + const hostElement = namespace + ? document.createElementNS(namespace, tagName) + : document.createElement(tagName); + + let componentRef: ComponentRef | undefined; + + @Component({ + template: '', + }) + class AppHost { + private appRef = inject(ApplicationRef); + private environmentInjector = inject(EnvironmentInjector); + + constructor() { + componentRef = createComponent(DynamicComponent, { + hostElement, + environmentInjector: this.environmentInjector, + directives: [CarrierDirective], + }); + this.appRef.attachView(componentRef.hostView); + } + } + + const fixture = TestBed.createComponent(AppHost); + + try { + if (expectedError) { + await expectAsync(fixture.whenStable()).toBeRejectedWithError(expectedError); + } else { + await fixture.whenStable(); + expect(hostElement.getAttribute(attrName)).toBe(expected ?? null); + } + } finally { + componentRef?.destroy(); + } + } + + it('should not sanitize resource URL attribute names on non-resource concrete hosts', async () => { + await expectHostBinding({ + tagName: 'div', + attrName: 'data', + value: HOST_BINDING_URL, + expected: HOST_BINDING_URL, + }); + await expectHostBinding({ + tagName: 'div', + attrName: 'data', + value: HOST_BINDING_UNSAFE_URL, + expected: HOST_BINDING_UNSAFE_URL, + }); + }); + + it('should sanitize href host bindings on SVG links', async () => { + await expectHostBinding({ + tagName: 'a', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: SVG_NAMESPACE_URI, + }); + }); + + it('should not sanitize href host bindings on non-link SVG elements', async () => { + await expectHostBinding({ + tagName: 'rect', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: HOST_BINDING_UNSAFE_URL, + namespace: SVG_NAMESPACE_URI, + }); + }); + + it('should sanitize xlink:href host bindings on SVG links', async () => { + await expectHostBinding({ + tagName: 'a', + attrName: 'xlink:href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: SVG_NAMESPACE_URI, + }); + }); + + it('should sanitize href host bindings on MathML elements', async () => { + await expectHostBinding({ + tagName: 'mi', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: MATH_ML_NAMESPACE_URI, + }); + }); + + it('should sanitize href host bindings on dynamic SVG hosts as URLs', async () => { + await expectHostBinding({ + tagName: 'a', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: SVG_NAMESPACE_URI, + }); + }); + + it('should sanitize href host bindings on dynamic MathML hosts as URLs', async () => { + await expectHostBinding({ + tagName: 'base', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: MATH_ML_NAMESPACE_URI, + }); + }); + + it('should sanitize a dynamic directive host binding against the concrete host element', async () => { + @Component({ + selector: 'iframe', + template: '', + }) + class DynamicIframeHostComponent {} + + @Directive({ + selector: 'safe-srcdoc-carrier', + host: {'[attr.srcdoc]': 'srcdoc'}, + }) + class SafeSrcdocCarrierDirective { + @Input() srcdoc = ''; + } + + @Component({ + template: '', + imports: [DynamicIframeHostComponent], + }) + class App { + componentRef: ComponentRef; + + private viewContainerRef = inject(ViewContainerRef); + private environmentInjector = inject(EnvironmentInjector); + + constructor() { + this.componentRef = this.viewContainerRef.createComponent(DynamicIframeHostComponent, { + environmentInjector: this.environmentInjector, + directives: [ + { + type: SafeSrcdocCarrierDirective, + bindings: [inputBinding('srcdoc', () => UNSAFE_HTML)], + }, + ], + }); + } + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const iframe = fixture.componentInstance.componentRef.location + .nativeElement as HTMLIFrameElement; + expect(iframe.getAttribute('srcdoc')).toBe(SANITIZED_HTML); + expect(iframe.getAttribute('srcdoc')).not.toContain('

safe

'; + + expect(ɵɵsanitizeHtml(html, 'div', 'srcdoc')).toBe(html); + expect(ɵɵsanitizeHtml(html, 'iframe', 'srcdoc').toString()).toBe('

safe

'); + }); + it('should sanitize url', () => { expect(ɵɵsanitizeUrl('http://server')).toEqual('http://server'); expect(ɵɵsanitizeUrl(new Wrap('http://server'))).toEqual('http://server'); @@ -111,7 +118,6 @@ describe('sanitization', () => { // making sure security schema we have on compiler side is in sync with the `getUrlSanitizer` // runtime function definition const schema = SECURITY_SCHEMA(); - const contextsByProp: Map> = new Map(); const sanitizerNameByContext: Map = new Map([ [SecurityContext.URL, ɵɵsanitizeUrl], [SecurityContext.RESOURCE_URL, ɵɵsanitizeResourceUrl], @@ -119,21 +125,21 @@ describe('sanitization', () => { for (const [prop, nsSchema] of Object.entries(schema)) { for (const [ns, tagSchema] of Object.entries(nsSchema)) { + // `getUrlSanitizer` resolves namespaces from the selected runtime `TNode`, so direct + // unit tests only cover non-namespaced schema entries. Namespaced host bindings are + // covered by acceptance tests. + if (ns !== '') { + continue; + } + for (const [tag, context] of Object.entries(tagSchema)) { if (context !== SecurityContext.URL && context !== SecurityContext.RESOURCE_URL) { continue; } - const contexts = contextsByProp.get(prop) || new Set(); - contexts.add(context); - contextsByProp.set(prop, contexts); - - // check only in case a prop can be a part of both URL contexts - if (contexts.size === 2) { - expect(getUrlSanitizer(tag, prop)) - .withContext(`ns: ${ns}, tag: ${tag}, prop: ${prop}, context: ${context}`) - .toEqual(sanitizerNameByContext.get(context)!); - } + expect(getUrlSanitizer(tag, prop)) + .withContext(`ns: ${ns}, tag: ${tag}, prop: ${prop}, context: ${context}`) + .toEqual(sanitizerNameByContext.get(context)!); } } } @@ -143,7 +149,8 @@ describe('sanitization', () => { expect(getUrlSanitizer('IFRAME', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); expect(getUrlSanitizer('IFRAME', 'src')).toEqual(ɵɵsanitizeResourceUrl); expect(getUrlSanitizer('iframe', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); - expect(getUrlSanitizer('ScRiPt', 'xLiNk:HrEf')).toEqual(ɵɵsanitizeUrl); + + expect(getUrlSanitizer('DiV', 'DaTa')).toBeNull(); expect(getUrlSanitizer('A', 'HREF')).toEqual(ɵɵsanitizeUrl); }); @@ -156,10 +163,6 @@ describe('sanitization', () => { expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'iframe', 'SRC')).toThrowError(ERROR); - expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'ScRiPt', 'xLiNk:HrEf')).toEqual( - 'unsafe:javascript:true', - ); - expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'A', 'HREF')).toEqual( 'unsafe:javascript:true', ); @@ -200,6 +203,8 @@ describe('sanitization', () => { expect( ɵɵsanitizeUrlOrResourceUrl(bypassSanitizationTrustUrl('javascript:true'), 'a', 'href'), ).toEqual('javascript:true'); + + expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'div', 'data')).toBe('javascript:true'); }); it('should only trust constant strings from template literal tags without interpolation', () => {