From c380dac918855335f8ff34a4538b1008503709db Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:58:39 +0000 Subject: [PATCH] refactor: optimize dom security schema lookups Restructure the security schema map to index by property name instead of tag name, improving lookup efficiency. --- .../src/schema/dom_element_schema_registry.ts | 2 +- .../src/schema/dom_security_schema.ts | 197 ++++++++++-------- packages/core/src/render3/i18n/i18n_parse.ts | 2 +- .../src/sanitization/dom_security_schema.ts | 197 ++++++++++-------- .../test/sanitization/sanitization_spec.ts | 31 +-- 5 files changed, 231 insertions(+), 198 deletions(-) diff --git a/packages/compiler/src/schema/dom_element_schema_registry.ts b/packages/compiler/src/schema/dom_element_schema_registry.ts index a327cfb8e371..c72c7cc4d920 100644 --- a/packages/compiler/src/schema/dom_element_schema_registry.ts +++ b/packages/compiler/src/schema/dom_element_schema_registry.ts @@ -10,7 +10,7 @@ import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata, SecurityContex import {isNgContainer, isNgContent, splitNsName} from '../ml_parser/tags'; import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces'; import {dashCaseToCamelCase} from '../util'; -import {checkSecurityContext, SECURITY_SCHEMA} from './dom_security_schema'; +import {checkSecurityContext} from './dom_security_schema'; import {ElementSchemaRegistry} from './element_schema_registry'; const BOOLEAN = 'boolean'; diff --git a/packages/compiler/src/schema/dom_security_schema.ts b/packages/compiler/src/schema/dom_security_schema.ts index be4424ebf060..6356b8dc1f23 100644 --- a/packages/compiler/src/schema/dom_security_schema.ts +++ b/packages/compiler/src/schema/dom_security_schema.ts @@ -36,89 +36,96 @@ export enum SecurityContext { // ================================================================================================= /** - * Map from tagName|propertyName to SecurityContext. Properties applying to all tags use '*'. + * Map from property name to namespace and tag name to SecurityContext. + * Properties applying to all tags use '*'. + * Properties applying to all namespaces use ''. */ -let _SECURITY_SCHEMA!: {[k: string]: SecurityContext}; +let _SECURITY_SCHEMA!: Record>>; const SVG_NAMESPACE = 'svg'; const MATH_ML_NAMESPACE = 'math'; +const NO_NAMESPACE = ''; +const MATCH_ALL_ELEMENTS = '*'; /** * @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, /** 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, /** 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 - ['*', ['href', '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 - // The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings) - // and the directive can be applied to multiple different elements (with different tag names). In this case we generate - // a special instruction that an attribute might potentially be security-sensitive and defer the actual security check - // to runtime, when we apply that directive to a concrete elements, thus we can check the combination of tag+attribute - // against the set that requires sanitization. - // 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, /** Namespace */ undefined, [ +export function SECURITY_SCHEMA(): Record>> { + if (_SECURITY_SCHEMA) { + return _SECURITY_SCHEMA; + } + + _SECURITY_SCHEMA = {}; + + // Case is insignificant below, all element and attribute names are lower-cased for lookup. + + 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, /** 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 + ['*', ['href', '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 + // The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings) + // and the directive can be applied to multiple different elements (with different tag names). In this case we generate + // a special instruction that an attribute might potentially be security-sensitive and defer the actual security check + // to runtime, when we apply that directive to a concrete elements, thus we can check the combination of tag+attribute + // against the set that requires sanitization. + // 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, /** Namespace */ undefined, [ + [ + 'unknown', [ - 'unknown', - [ - 'attributeName', - 'values', - 'to', - 'from', - 'sandbox', - 'allow', - 'allowFullscreen', - 'referrerPolicy', - 'csp', - 'fetchPriority', - ], + 'attributeName', + 'values', + 'to', + 'from', + 'sandbox', + 'allow', + 'allowFullscreen', + 'referrerPolicy', + 'csp', + 'fetchPriority', ], - ['iframe', ['sandbox', 'allow', 'allowFullscreen', 'referrerPolicy', 'csp', 'fetchPriority']], - ]); - } + ], + ['iframe', ['sandbox', 'allow', 'allowFullscreen', 'referrerPolicy', 'csp', 'fetchPriority']], + ]); return _SECURITY_SCHEMA; } @@ -128,15 +135,15 @@ function registerContext( namespace: string | undefined, specs: readonly [tagName: string, attributeNames: readonly string[]][], ): void { + const nsKey = namespace ?? NO_NAMESPACE; for (const [element, attributeNames] of specs) { - let tagName = element; - if (namespace && element !== 'unknown') { - tagName = `:${namespace}:${element}`; - } - tagName = tagName.toLowerCase(); + const tagName = element.toLowerCase(); for (const attr of attributeNames) { - _SECURITY_SCHEMA[`${tagName}|${attr.toLowerCase()}`] = ctx; + const attrLower = attr.toLowerCase(); + const attrSchema = (_SECURITY_SCHEMA[attrLower] ??= {}); + const nsSchema = (attrSchema[nsKey] ??= {}); + nsSchema[tagName] = ctx; } } } @@ -153,21 +160,27 @@ export function checkSecurityContext( namespace?: string | null, ): SecurityContext { const securitySchema = SECURITY_SCHEMA(); - propName = propName.toLowerCase(); - tagName = tagName.toLowerCase(); + const attrSchema = securitySchema[propName.toLowerCase()]; + if (!attrSchema) { + return SecurityContext.NONE; + } - let namespacedTag = tagName; - let nsWildcardTag: string | undefined; + const tagLower = tagName.toLowerCase(); + let context: SecurityContext | undefined; - if (namespace === SVG_NAMESPACE || namespace === MATH_ML_NAMESPACE) { - namespacedTag = `:${namespace}:${tagName}`; - nsWildcardTag = `:${namespace}:*`; + if (namespace) { + const nsSchema = attrSchema[namespace]; + if (nsSchema) { + context = nsSchema[tagLower] ?? nsSchema[MATCH_ALL_ELEMENTS]; + } + } + + if (context === undefined) { + const defaultSchema = attrSchema[NO_NAMESPACE]; + if (defaultSchema) { + context = defaultSchema[tagLower] ?? defaultSchema[MATCH_ALL_ELEMENTS]; + } } - return ( - securitySchema[namespacedTag + '|' + propName] ?? - (nsWildcardTag !== undefined ? securitySchema[nsWildcardTag + '|' + propName] : undefined) ?? - securitySchema['*|' + propName] ?? - SecurityContext.NONE - ); + return context ?? SecurityContext.NONE; } diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index a3b816158201..b2a9bae32d4c 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -1003,7 +1003,7 @@ function splitNsName(elementName: string, fatal: boolean = true): [string | null } function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null { - let schemaContext = SecurityContext.NONE; + let schemaContext: SecurityContext; if (tagName) { const [ns, name] = splitNsName(tagName, false); diff --git a/packages/core/src/sanitization/dom_security_schema.ts b/packages/core/src/sanitization/dom_security_schema.ts index be4424ebf060..6356b8dc1f23 100644 --- a/packages/core/src/sanitization/dom_security_schema.ts +++ b/packages/core/src/sanitization/dom_security_schema.ts @@ -36,89 +36,96 @@ export enum SecurityContext { // ================================================================================================= /** - * Map from tagName|propertyName to SecurityContext. Properties applying to all tags use '*'. + * Map from property name to namespace and tag name to SecurityContext. + * Properties applying to all tags use '*'. + * Properties applying to all namespaces use ''. */ -let _SECURITY_SCHEMA!: {[k: string]: SecurityContext}; +let _SECURITY_SCHEMA!: Record>>; const SVG_NAMESPACE = 'svg'; const MATH_ML_NAMESPACE = 'math'; +const NO_NAMESPACE = ''; +const MATCH_ALL_ELEMENTS = '*'; /** * @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, /** 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, /** 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 - ['*', ['href', '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 - // The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings) - // and the directive can be applied to multiple different elements (with different tag names). In this case we generate - // a special instruction that an attribute might potentially be security-sensitive and defer the actual security check - // to runtime, when we apply that directive to a concrete elements, thus we can check the combination of tag+attribute - // against the set that requires sanitization. - // 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, /** Namespace */ undefined, [ +export function SECURITY_SCHEMA(): Record>> { + if (_SECURITY_SCHEMA) { + return _SECURITY_SCHEMA; + } + + _SECURITY_SCHEMA = {}; + + // Case is insignificant below, all element and attribute names are lower-cased for lookup. + + 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, /** 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 + ['*', ['href', '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 + // The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings) + // and the directive can be applied to multiple different elements (with different tag names). In this case we generate + // a special instruction that an attribute might potentially be security-sensitive and defer the actual security check + // to runtime, when we apply that directive to a concrete elements, thus we can check the combination of tag+attribute + // against the set that requires sanitization. + // 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, /** Namespace */ undefined, [ + [ + 'unknown', [ - 'unknown', - [ - 'attributeName', - 'values', - 'to', - 'from', - 'sandbox', - 'allow', - 'allowFullscreen', - 'referrerPolicy', - 'csp', - 'fetchPriority', - ], + 'attributeName', + 'values', + 'to', + 'from', + 'sandbox', + 'allow', + 'allowFullscreen', + 'referrerPolicy', + 'csp', + 'fetchPriority', ], - ['iframe', ['sandbox', 'allow', 'allowFullscreen', 'referrerPolicy', 'csp', 'fetchPriority']], - ]); - } + ], + ['iframe', ['sandbox', 'allow', 'allowFullscreen', 'referrerPolicy', 'csp', 'fetchPriority']], + ]); return _SECURITY_SCHEMA; } @@ -128,15 +135,15 @@ function registerContext( namespace: string | undefined, specs: readonly [tagName: string, attributeNames: readonly string[]][], ): void { + const nsKey = namespace ?? NO_NAMESPACE; for (const [element, attributeNames] of specs) { - let tagName = element; - if (namespace && element !== 'unknown') { - tagName = `:${namespace}:${element}`; - } - tagName = tagName.toLowerCase(); + const tagName = element.toLowerCase(); for (const attr of attributeNames) { - _SECURITY_SCHEMA[`${tagName}|${attr.toLowerCase()}`] = ctx; + const attrLower = attr.toLowerCase(); + const attrSchema = (_SECURITY_SCHEMA[attrLower] ??= {}); + const nsSchema = (attrSchema[nsKey] ??= {}); + nsSchema[tagName] = ctx; } } } @@ -153,21 +160,27 @@ export function checkSecurityContext( namespace?: string | null, ): SecurityContext { const securitySchema = SECURITY_SCHEMA(); - propName = propName.toLowerCase(); - tagName = tagName.toLowerCase(); + const attrSchema = securitySchema[propName.toLowerCase()]; + if (!attrSchema) { + return SecurityContext.NONE; + } - let namespacedTag = tagName; - let nsWildcardTag: string | undefined; + const tagLower = tagName.toLowerCase(); + let context: SecurityContext | undefined; - if (namespace === SVG_NAMESPACE || namespace === MATH_ML_NAMESPACE) { - namespacedTag = `:${namespace}:${tagName}`; - nsWildcardTag = `:${namespace}:*`; + if (namespace) { + const nsSchema = attrSchema[namespace]; + if (nsSchema) { + context = nsSchema[tagLower] ?? nsSchema[MATCH_ALL_ELEMENTS]; + } + } + + if (context === undefined) { + const defaultSchema = attrSchema[NO_NAMESPACE]; + if (defaultSchema) { + context = defaultSchema[tagLower] ?? defaultSchema[MATCH_ALL_ELEMENTS]; + } } - return ( - securitySchema[namespacedTag + '|' + propName] ?? - (nsWildcardTag !== undefined ? securitySchema[nsWildcardTag + '|' + propName] : undefined) ?? - securitySchema['*|' + propName] ?? - SecurityContext.NONE - ); + return context ?? SecurityContext.NONE; } diff --git a/packages/core/test/sanitization/sanitization_spec.ts b/packages/core/test/sanitization/sanitization_spec.ts index 867b6dccb1bf..3afed7e65c97 100644 --- a/packages/core/test/sanitization/sanitization_spec.ts +++ b/packages/core/test/sanitization/sanitization_spec.ts @@ -116,20 +116,27 @@ describe('sanitization', () => { [SecurityContext.URL, ɵɵsanitizeUrl], [SecurityContext.RESOURCE_URL, ɵɵsanitizeResourceUrl], ]); - Object.entries(schema).forEach(([key, context]) => { - 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)) - .withContext(`key: ${key}, context: ${context}`) - .toEqual(sanitizerNameByContext.get(context)!); + + for (const [prop, nsSchema] of Object.entries(schema)) { + for (const [ns, tagSchema] of Object.entries(nsSchema)) { + 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)!); + } } } - }); + } }); it('should select URL sanitizer case-insensitively', () => {