diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 5bc6148e4a6b..d1a9ae46398a 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1706,6 +1706,8 @@ export interface SchemaMetadata { // @public export enum SecurityContext { + // (undocumented) + ATTRIBUTE_NO_BINDING = 6, // (undocumented) HTML = 1, // (undocumented) diff --git a/integration/cli-hello-world-ivy-i18n/size.json b/integration/cli-hello-world-ivy-i18n/size.json index 7edb4e7db0fc..6e17d691f055 100644 --- a/integration/cli-hello-world-ivy-i18n/size.json +++ b/integration/cli-hello-world-ivy-i18n/size.json @@ -1,4 +1,4 @@ { - "dist/main.js": 135813, + "dist/main.js": 144843, "dist/polyfills.js": 35883 } diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 2862253522de..0dfd7f2c9a85 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -8432,9 +8432,6 @@ runInEachFileSystem((os: string) => { @HostBinding('attr.action') attrAction: string; - @HostBinding('attr.profile') - attrProfile: string; - @HostBinding('attr.innerHTML') attrInnerHTML: string; @@ -8461,10 +8458,10 @@ runInEachFileSystem((os: string) => { env.driveMain(); const jsContents = env.getContents('test.js'); const hostBindingsFn = ` - hostVars: 6, + hostVars: 5, 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, i0.ɵɵsanitizeResourceUrl)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle); + i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrl)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle); } } `; @@ -8491,9 +8488,6 @@ runInEachFileSystem((os: string) => { @HostBinding('action') propAction: string; - @HostBinding('profile') - propProfile: string; - @HostBinding('innerHTML') propInnerHTML: string; @@ -8520,44 +8514,16 @@ runInEachFileSystem((os: string) => { env.driveMain(); const jsContents = env.getContents('test.js'); const hostBindingsFn = ` - hostVars: 6, + hostVars: 5, hostBindings: function UnsafePropsDirective_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.propSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.propAction, i0.ɵɵsanitizeUrl)("profile", ctx.propProfile, i0.ɵɵsanitizeResourceUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle); + i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.propSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.propAction, i0.ɵɵsanitizeUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle); } } `; expect(trim(jsContents)).toContain(trim(hostBindingsFn)); }); - it('should generate sanitizers for URL properties in SVG script fn in Component', () => { - env.write( - 'test.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - selector: 'test-cmp', - template: \` - - - - \`, - }) - export class TestCmp { - attr = './script.js'; - } - `, - ); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - expect(jsContents).toContain( - 'i0.ɵɵattribute("href", ctx.attr, i0.ɵɵsanitizeResourceUrl, "xlink")("href", ctx.attr, i0.ɵɵsanitizeResourceUrl);', - ); - }); - it('should not generate sanitizers for URL properties in hostBindings fn in Component', () => { env.write( `test.ts`, diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 1884ee594fed..9cc91af53602 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -32,7 +32,6 @@ import {publishFacade} from './jit_compiler_facade'; import * as outputAst from './output/output_ast'; import {global} from './util'; -export {SECURITY_SCHEMA} from './schema/dom_security_schema'; export {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata} from './core'; export {core}; diff --git a/packages/compiler/src/core.ts b/packages/compiler/src/core.ts index b8c3a3c8a639..b5c3b0a2baf2 100644 --- a/packages/compiler/src/core.ts +++ b/packages/compiler/src/core.ts @@ -76,16 +76,6 @@ export interface Type extends Function { } export const Type = Function; -export enum SecurityContext { - NONE = 0, - HTML = 1, - STYLE = 2, - SCRIPT = 3, - URL = 4, - RESOURCE_URL = 5, - ATTRIBUTE_NO_BINDING = 6, -} - /** * Injection flags for DI. */ @@ -328,3 +318,5 @@ export const enum AttributeMarker { */ I18n = 6, } + +export {SecurityContext} from './schema/dom_security_schema'; diff --git a/packages/compiler/src/schema/dom_element_schema_registry.ts b/packages/compiler/src/schema/dom_element_schema_registry.ts index 8d3bdd62a10d..804e2e366426 100644 --- a/packages/compiler/src/schema/dom_element_schema_registry.ts +++ b/packages/compiler/src/schema/dom_element_schema_registry.ts @@ -7,7 +7,8 @@ */ import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata, SecurityContext} from '../core'; -import {isNgContainer, isNgContent} from '../ml_parser/tags'; +import {isNgContainer, isNgContent, splitNsName} from '../ml_parser/tags'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces'; import {dashCaseToCamelCase} from '../util'; import {SECURITY_SCHEMA} from './dom_security_schema'; @@ -18,6 +19,13 @@ const NUMBER = 'number'; const STRING = 'string'; const OBJECT = 'object'; +function normalizeTagName(tagName: string): string { + const tagNameLower = tagName.toLowerCase(); + const [ns, name] = splitNsName(tagNameLower, false); + + return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name; +} + /** * This array represents the DOM schema. It encodes inheritance, properties, and events. * @@ -377,8 +385,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { return true; } - if (tagName.indexOf('-') > -1) { - if (isNgContainer(tagName) || isNgContent(tagName)) { + const normalizedTag = normalizeTagName(tagName); + if (normalizedTag.includes('-')) { + if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) { return false; } @@ -389,8 +398,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { } } - const elementProperties = - this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!; + const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!; return elementProperties.has(propName); } @@ -399,8 +407,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { return true; } - if (tagName.indexOf('-') > -1) { - if (isNgContainer(tagName) || isNgContent(tagName)) { + const normalizedTag = normalizeTagName(tagName); + if (normalizedTag.includes('-')) { + if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) { return true; } @@ -410,7 +419,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { } } - return this._schema.has(tagName.toLowerCase()); + return this._schema.has(normalizedTag); } /** @@ -433,16 +442,16 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { propName = this.getMappedPropName(propName); } - // Make sure comparisons are case insensitive, so that case differences between attribute and - // property names do not have a security impact. - tagName = tagName.toLowerCase(); + const normalizedTag = normalizeTagName(tagName); propName = propName.toLowerCase(); - let ctx = SECURITY_SCHEMA()[tagName + '|' + propName]; - if (ctx) { - return ctx; - } - ctx = SECURITY_SCHEMA()['*|' + propName]; - return ctx ? ctx : SecurityContext.NONE; + + const securitySchema = SECURITY_SCHEMA(); + const ctx = + securitySchema[normalizedTag + '|' + propName] ?? + securitySchema['*|' + propName] ?? + SecurityContext.NONE; + + return ctx; } override getMappedPropName(propName: string): string { @@ -482,14 +491,15 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { } allKnownAttributesOfElement(tagName: string): string[] { - const elementProperties = - this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!; + const normalizedTag = normalizeTagName(tagName); + const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!; // Convert properties to attributes. return Array.from(elementProperties.keys()).map((prop) => _PROP_TO_ATTR.get(prop) ?? prop); } allKnownEventsOfElement(tagName: string): string[] { - return Array.from(this._eventSchema.get(tagName.toLowerCase()) ?? []); + const normalizedTag = normalizeTagName(tagName); + return Array.from(this._eventSchema.get(normalizedTag) ?? []); } override normalizeAnimationStyleProperty(propName: string): string { diff --git a/packages/compiler/src/schema/dom_security_schema.ts b/packages/compiler/src/schema/dom_security_schema.ts index dfe5618dd53d..c478ffba5a8d 100644 --- a/packages/compiler/src/schema/dom_security_schema.ts +++ b/packages/compiler/src/schema/dom_security_schema.ts @@ -6,7 +6,24 @@ * found in the LICENSE file at https://angular.dev/license */ -import {SecurityContext} from '../core'; +/** + * A SecurityContext marks a location that has dangerous security implications, e.g. a DOM property + * like `innerHTML` that could cause Cross Site Scripting (XSS) security bugs when improperly + * handled. + * + * See DomSanitizer for more details on security in Angular applications. + * + * @publicApi + */ +export enum SecurityContext { + NONE = 0, + HTML = 1, + STYLE = 2, + SCRIPT = 3, + URL = 4, + RESOURCE_URL = 5, + ATTRIBUTE_NO_BINDING = 6, +} // ================================================================================================= // ================================================================================================= @@ -18,160 +35,134 @@ import {SecurityContext} from '../core'; // // ================================================================================================= -/** Map from tagName|propertyName to SecurityContext. Properties applying to all tags use '*'. */ +/** + * Map from tagName|propertyName to SecurityContext. Properties applying to all tags use '*'. + */ let _SECURITY_SCHEMA!: {[k: string]: SecurityContext}; +const SVG_NAMESPACE = 'svg'; +const MATH_ML_NAMESPACE = 'math'; +/** + * @remarks Keep is a copy of DOM Security Schema. + * @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts) + */ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { if (!_SECURITY_SCHEMA) { _SECURITY_SCHEMA = {}; // Case is insignificant below, all element and attribute names are lower-cased for lookup. - registerContext(SecurityContext.HTML, ['iframe|srcdoc', '*|innerHTML', '*|outerHTML']); - registerContext(SecurityContext.STYLE, ['*|style']); + registerContext(SecurityContext.HTML, /** Namespace */ undefined, [ + ['iframe', ['srcdoc']], + ['*', ['innerHTML', 'outerHTML']], + ]); + registerContext(SecurityContext.STYLE, /** Namespace */ undefined, [['*', ['style']]]); // NB: no SCRIPT contexts here, they are never allowed due to the parser stripping them. - registerContext(SecurityContext.URL, [ - '*|formAction', - 'area|href', - 'area|ping', - 'audio|src', - 'a|href', - 'a|xlink:href', - 'a|ping', - 'blockquote|cite', - 'body|background', - 'del|cite', - 'form|action', - 'img|src', - 'input|src', - 'ins|cite', - 'q|cite', - 'source|src', - 'track|src', - 'video|poster', - 'video|src', + registerContext(SecurityContext.URL, /** Namespace */ undefined, [ + ['*', ['formAction']], + ['area', ['href']], + ['a', ['href', 'xlink:href']], + ['form', ['action']], + // The below two items are safe and should be removed but they require a G3 clean-up as a small number of tests fail. + ['img', ['src']], + ['video', ['src']], + ]); + + registerContext(SecurityContext.URL, MATH_ML_NAMESPACE, [ // 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', + ['annotation', ['href', 'xlink:href']], + ['annotation-xml', ['href', 'xlink:href']], + ['maction', ['href', 'xlink:href']], + ['malignmark', ['href', 'xlink:href']], + ['math', ['href', 'xlink:href']], + ['mroot', ['href', 'xlink:href']], + ['msqrt', ['href', 'xlink:href']], + ['merror', ['href', 'xlink:href']], + ['mfrac', ['href', 'xlink:href']], + ['mglyph', ['href', 'xlink:href']], + ['msub', ['href', 'xlink:href']], + ['msup', ['href', 'xlink:href']], + ['msubsup', ['href', 'xlink:href']], + ['mmultiscripts', ['href', 'xlink:href']], + ['mprescripts', ['href', 'xlink:href']], + ['mi', ['href', 'xlink:href']], + ['mn', ['href', 'xlink:href']], + ['mo', ['href', 'xlink:href']], + ['mpadded', ['href', 'xlink:href']], + ['mphantom', ['href', 'xlink:href']], + ['mrow', ['href', 'xlink:href']], + ['ms', ['href', 'xlink:href']], + ['mspace', ['href', 'xlink:href']], + ['mstyle', ['href', 'xlink:href']], + ['mtable', ['href', 'xlink:href']], + ['mtd', ['href', 'xlink:href']], + ['mtr', ['href', 'xlink:href']], + ['mtext', ['href', 'xlink:href']], + ['mover', ['href', 'xlink:href']], + ['munder', ['href', 'xlink:href']], + ['munderover', ['href', 'xlink:href']], + ['semantics', ['href', 'xlink:href']], + ['none', ['href', 'xlink:href']], ]); - registerContext(SecurityContext.RESOURCE_URL, [ - 'applet|code', - 'applet|codebase', - 'base|href', - 'embed|src', - 'frame|src', - 'head|profile', - 'html|manifest', - 'iframe|src', - 'link|href', - 'media|src', - 'object|codebase', - 'object|data', - 'script|src', - // The below two are for Script SVG - // See: https://developer.mozilla.org/en-US/docs/Web/API/SVGScriptElement/href - 'script|href', - 'script|xlink:href', + registerContext(SecurityContext.RESOURCE_URL, /** Namespace */ undefined, [ + ['base', ['href']], + ['embed', ['src']], + ['frame', ['src']], + ['iframe', ['src']], + ['link', ['href']], + ['object', ['codebase', 'data']], ]); + registerContext(SecurityContext.URL, SVG_NAMESPACE, [['a', ['href', 'xlink:href']]]); + // Keep this in sync with SECURITY_SENSITIVE_ELEMENTS in packages/core/src/sanitization/sanitization.ts // 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, SVG_NAMESPACE, [ + ['animate', ['attributeName', 'values', 'to', 'from']], + ['set', ['to', 'attributeName']], + ['animateMotion', ['attributeName']], + ['animateTransform', ['attributeName']], + ]); - 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', + registerContext(SecurityContext.ATTRIBUTE_NO_BINDING, /** Namespace */ undefined, [ + [ + 'unknown', + [ + 'attributeName', + 'values', + 'to', + 'from', + 'sandbox', + 'allow', + 'allowFullscreen', + 'referrerPolicy', + 'csp', + 'fetchPriority', + ], + ], + ['iframe', ['sandbox', 'allow', 'allowFullscreen', 'referrerPolicy', 'csp', 'fetchPriority']], ]); } return _SECURITY_SCHEMA; } -function registerContext(ctx: SecurityContext, specs: string[]) { - for (const spec of specs) _SECURITY_SCHEMA[spec.toLowerCase()] = ctx; +function registerContext( + ctx: SecurityContext, + namespace: string | undefined, + specs: readonly [tagName: string, attributeNames: readonly string[]][], +): void { + for (const [element, attributeNames] of specs) { + let tagName = + namespace && element !== '*' && element !== 'unknown' ? `:${namespace}:${element}` : element; + tagName = tagName.toLowerCase(); + + for (const attr of attributeNames) { + _SECURITY_SCHEMA[`${tagName}|${attr.toLowerCase()}`] = ctx; + } + } } diff --git a/packages/compiler/src/template/pipeline/src/emit.ts b/packages/compiler/src/template/pipeline/src/emit.ts index db4d882fe57f..c1e8242e2c83 100644 --- a/packages/compiler/src/template/pipeline/src/emit.ts +++ b/packages/compiler/src/template/pipeline/src/emit.ts @@ -70,6 +70,7 @@ import {resolveDeferDepsFns} from './phases/resolve_defer_deps_fns'; import {resolveDollarEvent} from './phases/resolve_dollar_event'; import {resolveI18nElementPlaceholders} from './phases/resolve_i18n_element_placeholders'; import {resolveI18nExpressionPlaceholders} from './phases/resolve_i18n_expression_placeholders'; +import {resolveI18nAttrSanitizers} from './phases/resolve_i18n_attr_sanitizers'; import {resolveNames} from './phases/resolve_names'; import {resolveSanitizers} from './phases/resolve_sanitizers'; import {saveAndRestoreView} from './phases/save_restore_view'; @@ -150,6 +151,7 @@ const phases: Phase[] = [ {kind: Kind.Tmpl, fn: resolveI18nExpressionPlaceholders}, {kind: Kind.Tmpl, fn: extractI18nMessages}, {kind: Kind.Tmpl, fn: collectI18nConsts}, + {kind: Kind.Tmpl, fn: resolveI18nAttrSanitizers}, {kind: Kind.Tmpl, fn: collectConstExpressions}, {kind: Kind.Both, fn: collectElementConsts}, {kind: Kind.Tmpl, fn: removeI18nContexts}, diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 7fba286f8304..0c89f47e5b2f 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -29,6 +29,7 @@ import { type ViewCompilationUnit, } from './compilation'; import {BINARY_OPERATORS, namespaceForKey, prefixWithNamespace} from './conversion'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; const compatibilityMode = ir.CompatibilityMode.TemplateDefinitionBuilder; @@ -1299,7 +1300,24 @@ function ingestElementBindings( for (const attr of element.attributes) { // Attribute literal bindings, such as `attr.foo="bar"`. - const securityContext = domSchema.securityContext(element.name, attr.name, true); + const [ns, elementName] = splitNsName(element.name); + let namespace = ns; + if (!ns) { + switch (op.namespace) { + case ir.Namespace.SVG: + namespace = SVG_NAMESPACE; + break; + case ir.Namespace.Math: + namespace = MATH_ML_NAMESPACE; + break; + } + } + + const securityContext = domSchema.securityContext( + namespace ? `:${namespace}:${elementName}` : elementName, + attr.name, + true, + ); bindings.push( ir.createBindingOp( op.xref, diff --git a/packages/compiler/src/template/pipeline/src/namespaces.ts b/packages/compiler/src/template/pipeline/src/namespaces.ts new file mode 100644 index 000000000000..56ccf98f1029 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/namespaces.ts @@ -0,0 +1,10 @@ +/** + * @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 + */ + +export const SVG_NAMESPACE = 'svg'; +export const MATH_ML_NAMESPACE = 'math'; diff --git a/packages/compiler/src/template/pipeline/src/phases/const_collection.ts b/packages/compiler/src/template/pipeline/src/phases/const_collection.ts index f02daf404b0f..aae103b96e40 100644 --- a/packages/compiler/src/template/pipeline/src/phases/const_collection.ts +++ b/packages/compiler/src/template/pipeline/src/phases/const_collection.ts @@ -190,10 +190,7 @@ class ElementAttributes { if (value === null) { throw Error('Attribute, i18n attribute, & style element attributes must have a value'); } - if (trustedValueFn !== null) { - if (!ir.isStringLiteral(value)) { - throw Error('AssertionError: extracted attribute value should be string literal'); - } + if (trustedValueFn !== null && ir.isStringLiteral(value)) { array.push( o.taggedTemplate( trustedValueFn, diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_attr_sanitizers.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_attr_sanitizers.ts new file mode 100644 index 000000000000..6b4252be9d60 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_attr_sanitizers.ts @@ -0,0 +1,75 @@ +/** + * @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 {SecurityContext} from '../../../../core'; +import * as o from '../../../../output/output_ast'; +import {Identifiers} from '../../../../render3/r3_identifiers'; +import * as ir from '../../ir'; +import {CompilationJob} from '../compilation'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../namespaces'; + +/** + * Wraps static i18n extracted attributes in their corresponding sanitizers/validators. + */ +export function resolveI18nAttrSanitizers(job: CompilationJob): void { + const tagNamesByElement = new Map(); + + for (const unit of job.units) { + for (const op of unit.ops()) { + if (op.kind === ir.OpKind.ElementStart || op.kind === ir.OpKind.Template) { + let tag = op.tag ?? ''; + switch (op.namespace) { + case ir.Namespace.SVG: + tag = `:${SVG_NAMESPACE}:${tag}`; + break; + case ir.Namespace.Math: + tag = `:${MATH_ML_NAMESPACE}:${tag}`; + break; + } + + tagNamesByElement.set(op.xref, tag); + } + } + } + + for (const unit of job.units) { + for (const op of unit.create) { + if ( + op.kind === ir.OpKind.ExtractedAttribute && + op.i18nContext !== null && + op.expression !== null + ) { + const tagName = tagNamesByElement.get(op.target) ?? ''; + let expr = op.expression; + switch (op.securityContext) { + case SecurityContext.HTML: + expr = o.importExpr(Identifiers.sanitizeHtml).callFn([expr]); + break; + case SecurityContext.STYLE: + expr = o.importExpr(Identifiers.sanitizeStyle).callFn([expr]); + break; + case SecurityContext.SCRIPT: + expr = o.importExpr(Identifiers.sanitizeScript).callFn([expr]); + break; + case SecurityContext.URL: + expr = o.importExpr(Identifiers.sanitizeUrl).callFn([expr]); + break; + case SecurityContext.RESOURCE_URL: + expr = o.importExpr(Identifiers.sanitizeResourceUrl).callFn([expr]); + break; + case SecurityContext.ATTRIBUTE_NO_BINDING: + expr = o + .importExpr(Identifiers.validateAttribute) + .callFn([expr, o.literal(tagName), o.literal(op.name)]); + break; + } + op.expression = expr; + } + } + } +} diff --git a/packages/compiler/src/template_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts index fe31021a1c73..b99efedcae08 100644 --- a/packages/compiler/src/template_parser/binding_parser.ts +++ b/packages/compiler/src/template_parser/binding_parser.ts @@ -33,12 +33,13 @@ import { } from '../expression_parser/ast'; import {Parser} from '../expression_parser/parser'; import {InterpolationConfig} from '../ml_parser/defaults'; -import {mergeNsAndName} from '../ml_parser/tags'; +import {mergeNsAndName, splitNsName} from '../ml_parser/tags'; import {InterpolatedAttributeToken, InterpolatedTextToken} from '../ml_parser/tokens'; import {ParseError, ParseErrorLevel, ParseSourceSpan} from '../parse_util'; import {ElementSchemaRegistry} from '../schema/element_schema_registry'; import {CssSelector} from '../directive_matching'; import {splitAtColon, splitAtPeriod} from '../util'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces'; const PROPERTY_PARTS_SEPARATOR = '.'; const ATTRIBUTE_PREFIX = 'attr'; @@ -902,20 +903,42 @@ export function calcPossibleSecurityContexts( isAttribute: boolean, ): SecurityContext[] { let ctxs: SecurityContext[]; - const nameToContext = (elName: string) => registry.securityContext(elName, propName, isAttribute); - - if (selector === null) { - ctxs = registry.allKnownElementNames().map(nameToContext); + const [namespaceKey, baseSelector] = selector ? splitNsName(selector, false) : [null, selector]; + const nameToContext = (elName: string) => { + const [nsStr, name] = splitNsName(elName, false); + const ns = nsStr ?? namespaceKey; + const fullName = ns ? `:${ns}:${name}` : name; + return registry.securityContext(fullName, propName, isAttribute); + }; + + const allKnownElements = registry.allKnownElementNames(); + if (baseSelector === null) { + ctxs = allKnownElements.map(nameToContext); } else { ctxs = []; - CssSelector.parse(selector).forEach((selector) => { - const elementNames = selector.element ? [selector.element] : registry.allKnownElementNames(); + CssSelector.parse(baseSelector).forEach((selector) => { + let elementNames = selector.element ? [selector.element] : allKnownElements; + if (selector.element && !registry.hasElement(selector.element, [])) { + const svgElement = `:${SVG_NAMESPACE}:${selector.element}`; + const mathElement = `:${MATH_ML_NAMESPACE}:${selector.element}`; + if (registry.hasElement(svgElement, [])) { + elementNames = [svgElement]; + } else if (registry.hasElement(mathElement, [])) { + elementNames = [mathElement]; + } + } const notElementNames = new Set( selector.notSelectors .filter((selector) => selector.isElementSelector()) - .map((selector) => selector.element), + .map((selector) => selector.element?.toLowerCase()), ); - const possibleElementNames = elementNames.filter((elName) => !notElementNames.has(elName)); + const possibleElementNames = elementNames.filter((elName) => { + const elNameLowerCase = elName.toLowerCase(); + return ( + !notElementNames.has(elNameLowerCase) && + !notElementNames.has(splitNsName(elNameLowerCase)[1]) + ); + }); ctxs.push(...possibleElementNames.map(nameToContext)); }); diff --git a/packages/compiler/src/template_parser/template_preparser.ts b/packages/compiler/src/template_parser/template_preparser.ts index 5097352081cd..2c5889cae6c0 100644 --- a/packages/compiler/src/template_parser/template_preparser.ts +++ b/packages/compiler/src/template_parser/template_preparser.ts @@ -14,8 +14,8 @@ const LINK_ELEMENT = 'link'; const LINK_STYLE_REL_ATTR = 'rel'; const LINK_STYLE_HREF_ATTR = 'href'; const LINK_STYLE_REL_VALUE = 'stylesheet'; -const STYLE_ELEMENT = 'style'; -const SCRIPT_ELEMENT = 'script'; +const STYLE_ELEMENTS: ReadonlySet = new Set([':svg:style', 'style']); +const SCRIPT_ELEMENTS: ReadonlySet = new Set([':svg:script', 'script']); const NG_NON_BINDABLE_ATTR = 'ngNonBindable'; const NG_PROJECT_AS = 'ngProjectAs'; @@ -25,7 +25,8 @@ export function preparseElement(ast: html.Element): PreparsedElement { let relAttr: string | null = null; let nonBindable = false; let projectAs = ''; - ast.attrs.forEach((attr) => { + + for (const attr of ast.attrs) { const lcAttrName = attr.name.toLowerCase(); if (lcAttrName == NG_CONTENT_SELECT_ATTR) { selectAttr = attr.value; @@ -40,15 +41,18 @@ export function preparseElement(ast: html.Element): PreparsedElement { projectAs = attr.value; } } - }); - selectAttr = normalizeNgContentSelect(selectAttr); + } + + // Normalize selector to '*' if empty + selectAttr ||= '*'; + const nodeName = ast.name.toLowerCase(); let type = PreparsedElementType.OTHER; if (isNgContent(nodeName)) { type = PreparsedElementType.NG_CONTENT; - } else if (nodeName == STYLE_ELEMENT) { + } else if (STYLE_ELEMENTS.has(nodeName)) { type = PreparsedElementType.STYLE; - } else if (nodeName == SCRIPT_ELEMENT) { + } else if (SCRIPT_ELEMENTS.has(nodeName)) { type = PreparsedElementType.SCRIPT; } else if (nodeName == LINK_ELEMENT && relAttr == LINK_STYLE_REL_VALUE) { type = PreparsedElementType.STYLESHEET; @@ -73,10 +77,3 @@ export class PreparsedElement { public projectAs: string, ) {} } - -function normalizeNgContentSelect(selectAttr: string | null): string { - if (selectAttr === null || selectAttr.length === 0) { - return '*'; - } - return selectAttr; -} diff --git a/packages/compiler/test/schema/dom_element_schema_registry_spec.ts b/packages/compiler/test/schema/dom_element_schema_registry_spec.ts index 678c19078de9..7e81d533f696 100644 --- a/packages/compiler/test/schema/dom_element_schema_registry_spec.ts +++ b/packages/compiler/test/schema/dom_element_schema_registry_spec.ts @@ -17,8 +17,6 @@ import {isNode} from '@angular/private/testing'; import {Element} from '../../src/ml_parser/ast'; import {HtmlParser} from '../../src/ml_parser/html_parser'; -import {extractSchema} from './schema_extractor'; - describe('DOMElementSchema', () => { let registry: DomElementSchemaRegistry; beforeEach(() => { @@ -156,8 +154,26 @@ If 'onAnything' is a directive input, make sure the directive is imported by the expect(registry.securityContext('p', 'innerHTML', false)).toBe(SecurityContext.HTML); expect(registry.securityContext('a', 'href', false)).toBe(SecurityContext.URL); expect(registry.securityContext('a', 'style', false)).toBe(SecurityContext.STYLE); - expect(registry.securityContext('ins', 'cite', false)).toBe(SecurityContext.URL); expect(registry.securityContext('base', 'href', false)).toBe(SecurityContext.RESOURCE_URL); + // SVG animate and set attributes + expect(registry.securityContext(':svg:animate', 'to', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + expect(registry.securityContext(':svg:animate', 'from', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + expect(registry.securityContext(':svg:animate', 'values', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + expect(registry.securityContext(':svg:set', 'to', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + + // SVG link attributes + expect(registry.securityContext(':svg:a', 'href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'xlink:href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'href', true)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'xlink:href', true)).toBe(SecurityContext.URL); }); it('should detect properties on namespaced elements', () => { @@ -188,17 +204,33 @@ If 'onAnything' is a directive input, make sure the directive is imported by the }); }); - if (!isNode) { - it('generate a new schema', () => { - let schema = '\n'; - extractSchema()!.forEach((props, name) => { - schema += `'${name}|${props.join(',')}',\n`; - }); - // Uncomment this line to see: - // the generated schema which can then be pasted to the DomElementSchemaRegistry - // console.log(schema); + describe('Custom XML / XHTML namespaces', () => { + it('should support elements with custom namespaces', () => { + expect(registry.hasElement(':xhtml:a', [])).toBeTruthy(); + expect(registry.hasElement(':foo:div', [])).toBeTruthy(); }); - } + + it('should support properties on custom namespaced elements', () => { + expect(registry.hasProperty(':xhtml:a', 'href', [])).toBeTruthy(); + expect(registry.hasProperty(':foo:div', 'id', [])).toBeTruthy(); + }); + + it('should return correct security contexts for custom namespaced elements', () => { + expect(registry.securityContext(':xhtml:a', 'href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':foo:div', 'innerHTML', false)).toBe(SecurityContext.HTML); + }); + }); + + // Uncomment to see the generated schema which can then be pasted to the DomElementSchemaRegistry + // if (!isNode) { + // it('generate a new schema', () => { + // let schema = '\n'; + // extractSchema()!.forEach((props, name) => { + // schema += `'${name}|${props.join(',')}',\n`; + // }); + // console.log(schema); + // }); + // } describe('normalizeAnimationStyleProperty', () => { it('should normalize the given CSS property to camelCase', () => { diff --git a/packages/compiler/test/schema/schema_extractor.ts b/packages/compiler/test/schema/schema_extractor.ts deleted file mode 100644 index 7eebbcc67f2c..000000000000 --- a/packages/compiler/test/schema/schema_extractor.ts +++ /dev/null @@ -1,370 +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 - */ - -const SVG_PREFIX = ':svg:'; -const MATH_PREFIX = ':math:'; - -// Element | Node interfaces -// see https://developer.mozilla.org/en-US/docs/Web/API/Element -// see https://developer.mozilla.org/en-US/docs/Web/API/Node -const ELEMENT_IF = '[Element]'; -// HTMLElement interface -// see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement -const HTMLELEMENT_IF = '[HTMLElement]'; - -const HTMLELEMENT_TAGS = - 'abbr,address,article,aside,b,bdi,bdo,cite,content,code,dd,dfn,dt,em,figcaption,figure,footer,header,hgroup,i,kbd,main,mark,nav,noscript,rb,rp,rt,rtc,ruby,s,samp,search,section,small,strong,sub,sup,u,var,wbr'; - -const ALL_HTML_TAGS = - // https://www.w3.org/TR/html5/index.html - 'a,abbr,address,area,article,aside,audio,b,base,bdi,bdo,blockquote,body,br,button,canvas,caption,cite,code,col,colgroup,content,data,datalist,dd,del,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,img,input,ins,kbd,keygen,label,legend,li,link,main,map,mark,meta,meter,nav,noscript,object,ol,optgroup,option,output,p,param,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,search,section,select,small,source,span,strong,style,sub,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,u,ul,var,video,wbr,' + - // https://html.spec.whatwg.org/ - 'details,summary,menu,menuitem'; - -// Via https://developer.mozilla.org/en-US/docs/Web/MathML -const ALL_MATH_TAGS = - 'math,maction,menclose,merror,mfenced,mfrac,mi,mmultiscripts,mn,mo,mover,mpadded,mphantom,mroot,mrow,ms,mspace,msqrt,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,semantics'; - -// Elements missing from Chrome (HtmlUnknownElement), to be manually added -const MISSING_FROM_CHROME: {[el: string]: string[]} = { - 'data^[HTMLElement]': ['value'], - 'keygen^[HTMLElement]': ['!autofocus', 'challenge', '!disabled', 'form', 'keytype', 'name'], - // TODO(vicb): Figure out why Chrome and WhatWG do not agree on the props - // 'menu^[HTMLElement]': ['type', 'label'], - 'menuitem^[HTMLElement]': [ - 'type', - 'label', - 'icon', - '!disabled', - '!checked', - 'radiogroup', - '!default', - ], - 'summary^[HTMLElement]': [], - 'time^[HTMLElement]': ['dateTime'], - ':svg:cursor^:svg:': [], -}; - -const _G: any = - (typeof window != 'undefined' && window) || - (typeof global != 'undefined' && global) || - (typeof self != 'undefined' && self); - -const document: any = typeof _G['document'] == 'object' ? _G['document'] : null; - -export function extractSchema(): Map | null { - if (!document) return null; - const SVGGraphicsElement = _G['SVGGraphicsElement']; - if (!SVGGraphicsElement) return null; - - const element = document.createElement('video'); - const descMap: Map = new Map(); - const visited: {[name: string]: boolean} = {}; - - // HTML top level - extractProperties(Node, element, visited, descMap, ELEMENT_IF, ''); - extractProperties(Element, element, visited, descMap, ELEMENT_IF, ''); - extractProperties(HTMLElement, element, visited, descMap, HTMLELEMENT_IF, ELEMENT_IF); - extractProperties(HTMLElement, element, visited, descMap, HTMLELEMENT_TAGS, HTMLELEMENT_IF); - extractProperties(HTMLMediaElement, element, visited, descMap, 'media', HTMLELEMENT_IF); - - // SVG top level - const svgAnimation = document.createElementNS('http://www.w3.org/2000/svg', 'set'); - const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const svgFeFuncA = document.createElementNS('http://www.w3.org/2000/svg', 'feFuncA'); - const svgGradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); - const svgText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - - const SVGAnimationElement = _G['SVGAnimationElement']; - const SVGGeometryElement = _G['SVGGeometryElement']; - const SVGComponentTransferFunctionElement = _G['SVGComponentTransferFunctionElement']; - const SVGGradientElement = _G['SVGGradientElement']; - const SVGTextContentElement = _G['SVGTextContentElement']; - const SVGTextPositioningElement = _G['SVGTextPositioningElement']; - extractProperties(SVGElement, svgText, visited, descMap, SVG_PREFIX, HTMLELEMENT_IF); - - extractProperties( - SVGGraphicsElement, - svgText, - visited, - descMap, - SVG_PREFIX + 'graphics', - SVG_PREFIX, - ); - extractProperties( - SVGAnimationElement, - svgAnimation, - visited, - descMap, - SVG_PREFIX + 'animation', - SVG_PREFIX, - ); - extractProperties( - SVGGeometryElement, - svgPath, - visited, - descMap, - SVG_PREFIX + 'geometry', - SVG_PREFIX, - ); - extractProperties( - SVGComponentTransferFunctionElement, - svgFeFuncA, - visited, - descMap, - SVG_PREFIX + 'componentTransferFunction', - SVG_PREFIX, - ); - extractProperties( - SVGGradientElement, - svgGradient, - visited, - descMap, - SVG_PREFIX + 'gradient', - SVG_PREFIX, - ); - extractProperties( - SVGTextContentElement, - svgText, - visited, - descMap, - SVG_PREFIX + 'textContent', - SVG_PREFIX + 'graphics', - ); - extractProperties( - SVGTextPositioningElement, - svgText, - visited, - descMap, - SVG_PREFIX + 'textPositioning', - SVG_PREFIX + 'textContent', - ); - - // Get all element types - const types = Object.getOwnPropertyNames(window).filter((k) => /^(HTML|SVG).*?Element$/.test(k)); - - types.sort(); - - types.forEach((type) => { - extractRecursiveProperties(visited, descMap, (window as any)[type]); - }); - - // Add elements missed by Chrome auto-detection - Object.keys(MISSING_FROM_CHROME).forEach((elHierarchy) => { - descMap.set(elHierarchy, MISSING_FROM_CHROME[elHierarchy]); - }); - - // Needed because we're running tests against some older Android versions. - if (typeof MathMLElement !== 'undefined') { - // Math top level - const math = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'math'); - extractProperties(MathMLElement, math, visited, descMap, MATH_PREFIX, HTMLELEMENT_IF); - - // This script is written under the assumption that each tag has a corresponding class name, e.g. - // `` -> `SVGCircleElement` however this doesn't hold for Math elements which are all - // `MathMLElement`. Furthermore, they don't have special property names, but rather are - // configured exclusively via attributes. Register them as plain elements that inherit from - // the top-level `:math` namespace. - ALL_MATH_TAGS.split(',').forEach((tag) => - descMap.set(`${MATH_PREFIX}${tag}^${MATH_PREFIX}`, []), - ); - } - - assertNoMissingTags(descMap); - - return descMap; -} - -function assertNoMissingTags(descMap: Map): void { - const extractedTags: string[] = []; - - Array.from(descMap.keys()).forEach((key: string) => { - extractedTags.push(...key.split('|')[0].split('^')[0].split(',')); - }); - - const missingTags = [ - ...ALL_HTML_TAGS.split(','), - ...(typeof MathMLElement === 'undefined' - ? [] - : ALL_MATH_TAGS.split(',').map((tag) => MATH_PREFIX + tag)), - ].filter((tag) => !extractedTags.includes(tag)); - - if (missingTags.length) { - throw new Error(`DOM schema misses tags: ${missingTags.join(',')}`); - } -} - -function extractRecursiveProperties( - visited: {[name: string]: boolean}, - descMap: Map, - type: Function, -): string { - const name = extractName(type)!; - - if (visited[name]) { - return name; - } - - let superName: string; - switch (name) { - case ELEMENT_IF: - // ELEMENT_IF is the top most interface (Element | Node) - superName = ''; - break; - case HTMLELEMENT_IF: - superName = ELEMENT_IF; - break; - default: - superName = extractRecursiveProperties( - visited, - descMap, - type.prototype.__proto__.constructor, - ); - } - - let instance: HTMLElement | null = null; - name.split(',').forEach((tagName) => { - instance = type['name'].startsWith('SVG') - ? document.createElementNS('http://www.w3.org/2000/svg', tagName.replace(SVG_PREFIX, '')) - : document.createElement(tagName); - - let htmlType: Function; - - switch (tagName) { - case 'cite': - // interface is `HTMLQuoteElement` - htmlType = HTMLElement; - break; - default: - htmlType = type; - } - - if (!(instance instanceof htmlType)) { - throw new Error(`Tag <${tagName}> is not an instance of ${htmlType['name']}`); - } - }); - - extractProperties(type, instance, visited, descMap, name, superName); - - return name; -} - -function extractProperties( - type: Function, - instance: any, - visited: {[name: string]: boolean}, - descMap: Map, - name: string, - superName: string, -) { - if (!type) return; - - visited[name] = true; - - const fullName = name + (superName ? '^' + superName : ''); - - const props: string[] = descMap.has(fullName) ? descMap.get(fullName)! : []; - - const prototype = type.prototype; - const keys = Object.getOwnPropertyNames(prototype); - - keys.sort(); - keys.forEach((name) => { - if (name.startsWith('on')) { - props.push('*' + name.slice(2)); - } else { - const typeCh = _TYPE_MNEMONICS[typeof instance[name]]; - const descriptor = Object.getOwnPropertyDescriptor(prototype, name); - const isSetter = descriptor && descriptor.set; - if (typeCh !== void 0 && !name.startsWith('webkit') && isSetter) { - props.push(typeCh + name); - } - } - }); - - // There is no point in using `Node.nodeValue`, filter it out - descMap.set(fullName, type === Node ? props.filter((p) => p != '%nodeValue') : props); -} - -function extractName(type: Function): string | null { - let name = type['name']; - - // The polyfill @webcomponents/custom-element/src/native-shim.js overrides the - // window.HTMLElement and does not have the name property. Check if this is the - // case and if so, set the name manually. - if (name === '' && type === HTMLElement) { - name = 'HTMLElement'; - } - - switch (name) { - // see https://www.w3.org/TR/html5/index.html - // TODO(vicb): generate this map from all the element types - case 'Element': - return ELEMENT_IF; - case 'HTMLElement': - return HTMLELEMENT_IF; - case 'HTMLImageElement': - return 'img'; - case 'HTMLAnchorElement': - return 'a'; - case 'HTMLDListElement': - return 'dl'; - case 'HTMLDirectoryElement': - return 'dir'; - case 'HTMLHeadingElement': - return 'h1,h2,h3,h4,h5,h6'; - case 'HTMLModElement': - return 'ins,del'; - case 'HTMLOListElement': - return 'ol'; - case 'HTMLParagraphElement': - return 'p'; - case 'HTMLQuoteElement': - return 'q,blockquote,cite'; - case 'HTMLTableCaptionElement': - return 'caption'; - case 'HTMLTableCellElement': - return 'th,td'; - case 'HTMLTableColElement': - return 'col,colgroup'; - case 'HTMLTableRowElement': - return 'tr'; - case 'HTMLTableSectionElement': - return 'tfoot,thead,tbody'; - case 'HTMLUListElement': - return 'ul'; - case 'SVGGraphicsElement': - return SVG_PREFIX + 'graphics'; - case 'SVGMPathElement': - return SVG_PREFIX + 'mpath'; - case 'SVGSVGElement': - return SVG_PREFIX + 'svg'; - case 'SVGTSpanElement': - return SVG_PREFIX + 'tspan'; - default: - const isSVG = name.startsWith('SVG'); - if (name.startsWith('HTML') || isSVG) { - name = name.replace('HTML', '').replace('SVG', '').replace('Element', ''); - if (isSVG && name.startsWith('FE')) { - name = 'fe' + name.substring(2); - } else if (name) { - name = name.charAt(0).toLowerCase() + name.substring(1); - } - return isSVG ? SVG_PREFIX + name : name.toLowerCase(); - } - } - - return null; -} - -const _TYPE_MNEMONICS: {[type: string]: string} = { - 'string': '', - 'number': '#', - 'boolean': '!', - 'object': '%', -}; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index e06e4aa89541..81de706ab0d9 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -96,7 +96,7 @@ export * from './core_private_export'; export * from './core_render3_private_export'; export * from './core_reactivity_export'; export * from './resource'; -export {SecurityContext} from './sanitization/security'; +export {SecurityContext} from './sanitization/dom_security_schema'; export {Sanitizer} from './sanitization/sanitizer'; export { createNgModule, diff --git a/packages/core/src/render3/i18n/i18n_apply.ts b/packages/core/src/render3/i18n/i18n_apply.ts index da5df8f35504..d7409c64f4a9 100644 --- a/packages/core/src/render3/i18n/i18n_apply.ts +++ b/packages/core/src/render3/i18n/i18n_apply.ts @@ -49,8 +49,10 @@ import { } from '../dom_node_manipulation'; import { getBindingIndex, + getSelectedIndex, isInSkipHydrationBlock, lastNodeWasCreated, + setSelectedIndex, wasLastNodeCreated, } from '../state'; import {renderStringify} from '../util/stringify_utils'; @@ -439,14 +441,20 @@ export function applyUpdateOpCodes( sanitizeFn, ); } else { - setPropertyAndInputs( - tNodeOrTagName, - lView, - propName, - value, - lView[RENDERER], - sanitizeFn, - ); + const prevSelectedIndex = getSelectedIndex(); + setSelectedIndex(nodeIndex); + try { + setPropertyAndInputs( + tNodeOrTagName, + lView, + propName, + value, + lView[RENDERER], + sanitizeFn, + ); + } finally { + setSelectedIndex(prevSelectedIndex); + } } break; case I18nUpdateOpCode.Text: diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index c2abc63c219e..6c40319c663c 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -9,18 +9,17 @@ import '../../util/ng_dev_mode'; import '../../util/ng_i18n_closure_mode'; import {XSS_SECURITY_URL} from '../../error_details_base_url'; -import { - getTemplateContent, - SENSITIVE_ATTRS, - VALID_ATTRS, - VALID_ELEMENTS, -} from '../../sanitization/html_sanitizer'; +import {getTemplateContent, VALID_ATTRS, VALID_ELEMENTS} from '../../sanitization/html_sanitizer'; import {getInertBodyHelper} from '../../sanitization/inert_body'; import {_sanitizeUrl} from '../../sanitization/url_sanitizer'; import { + ɵɵsanitizeHtml as _sanitizeHtml, + ɵɵsanitizeStyle as _sanitizeStyle, + ɵɵsanitizeScript as _sanitizeScript, + ɵɵsanitizeResourceUrl as _sanitizeResourceUrl, ɵɵvalidateAttribute as _validateAttribute, - SECURITY_SENSITIVE_ELEMENTS, } from '../../sanitization/sanitization'; +import {SECURITY_SCHEMA, SecurityContext} from '../../sanitization/dom_security_schema'; import { assertDefined, assertEqual, @@ -74,6 +73,7 @@ import { } from './i18n_util'; import {createTNodeAtIndex} from '../tnode_manipulation'; import {allocExpando} from '../view/construction'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../namespaces'; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+:?\d*�\s*,\s*\S{6}\s*,[\s\S]*})/gi; @@ -386,13 +386,16 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str // the compiler treats static i18n attributes as regular attribute bindings. // Since this may not be the first i18n attribute on this element we need to pass in how // many previous bindings there have already been. + const tagName = previousElement.namespace + ? `:${previousElement.namespace}:${previousElement.value}` + : previousElement.value; generateBindingUpdateOpCodes( updateOpCodes, message, previousElementIndex, attrName, countBindings(updateOpCodes), - i18nSanitizeAttribute(attrName), + i18nResolveSanitizer(attrName, tagName), ); } } @@ -812,6 +815,13 @@ 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; if (hasBinding) { if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { generateBindingUpdateOpCodes( @@ -820,7 +830,7 @@ function walkIcuTree( newIndex, attr.name, 0, - i18nSanitizeAttribute(lowerAttrName), + i18nResolveSanitizer(lowerAttrName, tagNameWithNamespace), ); } else { ngDevMode && @@ -831,9 +841,9 @@ function walkIcuTree( ); } } else if (VALID_ATTRS[lowerAttrName]) { - if (SENSITIVE_ATTRS[lowerAttrName]) { - // Don't sanitize, because no value is acceptable in sensitive attributes. - // Translators are not allowed to create URIs. + let val = attr.value; + const sanitizer = i18nResolveSanitizer(lowerAttrName, tagNameWithNamespace); + if (sanitizer) { if (typeof ngDevMode !== 'undefined' && ngDevMode) { console.warn( `WARNING: ignoring unsafe attribute ` + @@ -841,9 +851,10 @@ function walkIcuTree( `(see ${XSS_SECURITY_URL})`, ); } + addCreateAttribute(create, newIndex, attr.name, 'unsafe:blocked'); } else { - addCreateAttribute(create, newIndex, attr.name, attr.value); + addCreateAttribute(create, newIndex, attr.name, val); } } else { if (typeof ngDevMode !== 'undefined' && ngDevMode) { @@ -974,30 +985,54 @@ function addCreateAttribute( create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue); } -/** - * Caches all keys of `SECURITY_SENSITIVE_ELEMENTS` in a Set to avoid recomputing - * or scanning them on every invocation. - */ -const SECURITY_SENSITIVE_ATTRS: ReadonlySet = /* @__PURE__ */ (() => - new Set( - Object.values(SECURITY_SENSITIVE_ELEMENTS).flatMap((attrs) => (attrs ? [...attrs.keys()] : [])), - ))(); - -/** - * Returns a sanitizer for the given attribute name or null if the attribute is not security sensitive. - * - * @param attrName The name of the attribute to sanitize. - * @returns The sanitizer for the given attribute name. - */ -function i18nSanitizeAttribute(attrName: string): SanitizerFn | null { - const lowerAttrName = attrName.toLowerCase(); - if (SENSITIVE_ATTRS[lowerAttrName]) { - return _sanitizeUrl; +function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] { + if (elementName[0] != ':') { + return [null, elementName]; } - if (SECURITY_SENSITIVE_ATTRS.has(lowerAttrName)) { - return _validateAttribute; + const colonIndex = elementName.indexOf(':', 1); + + if (colonIndex === -1) { + if (fatal) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } else { + return [null, elementName]; + } } - return null; + return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; +} + +function normalizeTagName(tagName: string): string { + const tagNameLower = tagName.toLowerCase(); + const [ns, name] = splitNsName(tagNameLower, false); + + return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name; +} + +function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null { + const lowerAttrName = attrName.toLowerCase(); + const lowerTagName = tagName ? normalizeTagName(tagName) : '*'; + const schema = SECURITY_SCHEMA(); + const schemaContext = + schema[`${lowerTagName}|${lowerAttrName}`] || + schema[`*|${lowerAttrName}`] || + SecurityContext.NONE; + + switch (schemaContext) { + case SecurityContext.HTML: + return _sanitizeHtml; + case SecurityContext.STYLE: + return _sanitizeStyle; + case SecurityContext.SCRIPT: + return _sanitizeScript; + case SecurityContext.URL: + return _sanitizeUrl; + case SecurityContext.RESOURCE_URL: + return _sanitizeResourceUrl; + case SecurityContext.ATTRIBUTE_NO_BINDING: + return _validateAttribute as any; + default: + return null; + } } diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 6e611a62717e..a2b9268e495f 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -21,6 +21,7 @@ import {stringify} from '../../util/stringify'; import {assertFirstCreatePass, assertHasParent, assertLView} from '../assert'; import {attachPatchData} from '../context_discovery'; import {getNodeInjectable, getOrCreateNodeInjectorForNode} from '../di'; +import {RuntimeError, RuntimeErrorCode} from '../../errors'; import {throwMultipleComponentError} from '../errors'; import {ComponentDef, ComponentTemplate, DirectiveDef, RenderFlags} from '../interfaces/definition'; import { @@ -177,6 +178,12 @@ export function locateHostElement( // projection. const preserveContent = preserveHostContent || encapsulation === ViewEncapsulation.ShadowDom; const rootElement = renderer.selectRootElement(elementOrSelector, preserveContent); + if (rootElement.tagName.toLowerCase() === 'script') { + throw new RuntimeError( + RuntimeErrorCode.UNSAFE_VALUE_IN_SCRIPT, + ngDevMode && `"`, + changeDetection: ChangeDetectionStrategy.Default, + }) + class TestCmp {} + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('script')).toBeFalsy(); + }); +}); + +describe('SVG link sanitization', () => { + it('should sanitize dynamic `href` bindings on ', () => { + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.Default, + }) + class TestCmp { + url = 'javascript:alert(1)'; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)'); + }); + + it('should sanitize dynamic `xlink:href` bindings on ', () => { + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.Default, + }) + class TestCmp { + url = 'javascript:alert(1)'; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('xlink:href')).toEqual('unsafe:javascript:alert(1)'); + }); + + it('should allow static unsafe `href` and `xlink:href` on ', () => { + @Component({ + template: ` + + + + + `, + changeDetection: ChangeDetectionStrategy.Default, + }) + class TestCmp {} + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const links = fixture.nativeElement.querySelectorAll('a'); + expect(links[0].getAttribute('href')).toEqual('javascript:alert(1)'); + expect(links[1].getAttribute('xlink:href')).toEqual('javascript:alert(2)'); + }); +}); diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 0e672a6c8350..4c455073c980 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -115,7 +115,6 @@ "HOST", "HOST_ATTR", "HOST_TAG_NAME", - "HREF_RESOURCE_TAGS", "HYDRATION", "HistoryStateManager", "HostAttributeToken", @@ -238,6 +237,7 @@ "REMOVE_STYLES_ON_COMPONENT_DESTROY_DEFAULT", "RENDERER", "REQUIRED_UNSET_VALUE", + "RESOURCE_MAP", "ROUTER_CONFIGURATION", "ROUTER_OUTLET_DATA", "ROUTER_PRELOADER", @@ -275,7 +275,6 @@ "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", - "SRC_RESOURCE_TAGS", "SVG_NAMESPACE", "SafeSubscriber", "SafeValueImpl", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 23078924315b..48aa7202a51c 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -385,6 +385,7 @@ "getInsertInFrontOfRNodeWithNoI18n", "getLView", "getLViewParent", + "getNamespace", "getNativeByTNode", "getNearestLContainer", "getNextLContainer", diff --git a/packages/core/test/linker/security_integration_spec.ts b/packages/core/test/linker/security_integration_spec.ts index 05f931672770..e26076b679a2 100644 --- a/packages/core/test/linker/security_integration_spec.ts +++ b/packages/core/test/linker/security_integration_spec.ts @@ -7,6 +7,8 @@ */ import {Component, Directive, HostBinding, Input, NO_ERRORS_SCHEMA} from '../../src/core'; +import {clearTranslations, loadTranslations} from '@angular/localize'; +import {computeMsgId} from '@angular/compiler'; import {ComponentFixture, getTestBed, TestBed} from '../../testing'; import {DomSanitizer} from '@angular/platform-browser'; @@ -199,6 +201,22 @@ describe('security integration tests', function () { checkEscapeOfHrefProperty(fixture); }); + it('should escape unsafe attributes on custom namespaced elements', () => { + const template = `Link Title`; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + const fixture = TestBed.createComponent(SecuredComponent); + + checkEscapeOfHrefProperty(fixture); + }); + + it('should escape unsafe properties on custom namespaced elements', () => { + const template = `Link Title`; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + const fixture = TestBed.createComponent(SecuredComponent); + + checkEscapeOfHrefProperty(fixture); + }); + it('should escape unsafe properties if they are used in host bindings', () => { @Directive({ selector: '[dirHref]', @@ -274,6 +292,82 @@ describe('security integration tests', function () { }); describe('translation', () => { + afterEach(() => { + clearTranslations(); + }); + + it('should throw error on SVG animation retargeting attributes', () => { + const template = ` + + + + + + + `; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + expect(() => { + const fixture = TestBed.createComponent(SecuredComponent); + fixture.detectChanges(); + }).toThrowError( + /For security reasons, the `attributeName` can be set on the element as a static attribute only/i, + ); + }); + + it('should allow non-security sensitive attributes', () => { + loadTranslations({[computeMsgId('foo')]: 'bar'}); + const template = ``; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + const fixture = TestBed.createComponent(SecuredComponent); + fixture.detectChanges(); + const element = fixture.nativeElement.querySelector('iframe'); + expect(element.getAttribute('title')).toEqual('bar'); + }); + + it('should sanitize translations of static iframe attributes', () => { + const template = ``; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + expect(() => { + const fixture = TestBed.createComponent(SecuredComponent); + fixture.detectChanges(); + }).toThrowError( + /For security reasons, the `sandbox` can be set on the `; TestBed.overrideComponent(SecuredComponent, {set: {template}}); diff --git a/packages/core/test/render3/instructions_spec.ts b/packages/core/test/render3/instructions_spec.ts index 63dc5bd5b008..20993f78e073 100644 --- a/packages/core/test/render3/instructions_spec.ts +++ b/packages/core/test/render3/instructions_spec.ts @@ -37,7 +37,7 @@ import { ɵɵsanitizeUrl, } from '../../src/sanitization/sanitization'; import {Sanitizer} from '../../src/sanitization/sanitizer'; -import {SecurityContext} from '../../src/sanitization/security'; +import {SecurityContext} from '../../src/sanitization/dom_security_schema'; import {ViewFixture} from './view_fixture'; diff --git a/packages/core/test/render3/integration_spec.ts b/packages/core/test/render3/integration_spec.ts index f040e4d57a5d..0dff1cea833e 100644 --- a/packages/core/test/render3/integration_spec.ts +++ b/packages/core/test/render3/integration_spec.ts @@ -13,7 +13,7 @@ import {TestBed} from '../../testing'; import {getLContext, readPatchedData} from '../../src/render3/context_discovery'; import {CONTEXT, HEADER_OFFSET} from '../../src/render3/interfaces/view'; import {Sanitizer} from '../../src/sanitization/sanitizer'; -import {SecurityContext} from '../../src/sanitization/security'; +import {SecurityContext} from '../../src/sanitization/dom_security_schema'; describe('element discovery', () => { it('should only monkey-patch immediate child nodes in a component', () => { @@ -612,7 +612,7 @@ describe('sanitization', () => { selector: '[unsafeUrlHostBindingDir]', }) class UnsafeUrlHostBindingDir { - @HostBinding() cite: any = 'http://cite-dir-value'; + @HostBinding('href') href: any = 'http://href-dir-value'; constructor() { hostBindingDir = this; @@ -623,7 +623,7 @@ describe('sanitization', () => { selector: 'sanitize-this', imports: [UnsafeUrlHostBindingDir], template: ` -
+
`, }) class SimpleComp {} @@ -640,16 +640,16 @@ describe('sanitization', () => { ], }); const fixture = TestBed.createComponent(SimpleComp); - hostBindingDir!.cite = 'http://foo'; + hostBindingDir!.href = 'http://foo'; fixture.detectChanges(); - const anchor = fixture.nativeElement.querySelector('blockquote')!; - expect(anchor.getAttribute('cite')).toEqual('http://bar'); + const anchor = fixture.nativeElement.querySelector('a')!; + expect(anchor.getAttribute('href')).toEqual('http://bar'); - hostBindingDir!.cite = sanitizer.bypassSecurityTrustUrl('http://foo'); + hostBindingDir!.href = sanitizer.bypassSecurityTrustUrl('http://foo'); fixture.detectChanges(); - expect(anchor.getAttribute('cite')).toEqual('http://foo'); + expect(anchor.getAttribute('href')).toEqual('http://foo'); }); }); diff --git a/packages/core/test/render3/is_shape_of.ts b/packages/core/test/render3/is_shape_of.ts index f0207782f672..741ffb211f3a 100644 --- a/packages/core/test/render3/is_shape_of.ts +++ b/packages/core/test/render3/is_shape_of.ts @@ -158,6 +158,7 @@ const ShapeOfTNode: ShapeOf = { flags: true, providerIndexes: true, value: true, + namespace: true, attrs: true, mergedAttrs: true, localNames: true, diff --git a/packages/core/test/sanitization/sanitization_spec.ts b/packages/core/test/sanitization/sanitization_spec.ts index bdfbc3882666..4ba8a49e4df0 100644 --- a/packages/core/test/sanitization/sanitization_spec.ts +++ b/packages/core/test/sanitization/sanitization_spec.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import {SECURITY_SCHEMA} from '@angular/compiler'; import {ENVIRONMENT, LView} from '../../src/render3/interfaces/view'; import {enterView, leaveView} from '../../src/render3/state'; @@ -28,7 +27,7 @@ import { ɵɵtrustConstantHtml, ɵɵtrustConstantResourceUrl, } from '../../src/sanitization/sanitization'; -import {SecurityContext} from '../../src/sanitization/security'; +import {SECURITY_SCHEMA, SecurityContext} from '../../src/sanitization/dom_security_schema'; function fakeLView(): LView { const fake = [null, {}] as LView; @@ -118,19 +117,46 @@ describe('sanitization', () => { [SecurityContext.RESOURCE_URL, ɵɵsanitizeResourceUrl], ]); Object.entries(schema).forEach(([key, context]) => { - if (context === SecurityContext.URL || SecurityContext.RESOURCE_URL) { + if (context === SecurityContext.URL || context === SecurityContext.RESOURCE_URL) { const [tag, prop] = key.split('|'); const contexts = contextsByProp.get(prop) || new Set(); 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)).toEqual(sanitizerNameByContext.get(context)!); + expect(getUrlSanitizer(tag, prop)) + .withContext(`key: ${key}, context: ${context}`) + .toEqual(sanitizerNameByContext.get(context)!); } } }); }); + it('should select URL sanitizer case-insensitively', () => { + 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('A', 'HREF')).toEqual(ɵɵsanitizeUrl); + }); + + it('should sanitize URL or ResourceURL case-insensitively', () => { + const ERROR = /NG0904: unsafe value used in a resource URL context.*/; + + expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'IFRAME', 'SRC')).toThrowError(ERROR); + + expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'IFRAME', 'src')).toThrowError(ERROR); + + 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', + ); + }); it('should sanitize resourceUrls via sanitizeUrlOrResourceUrl', () => { const ERROR = /NG0904: unsafe value used in a resource URL context.*/; expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'iframe', 'src')).toThrowError(ERROR);