From 3ac5a9ebfc6d93fb385c22f0d0cef2d7c70b1697 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 18 May 2026 22:09:39 +0200 Subject: [PATCH 01/16] refactor(core): align namespaced attribute validation and security schema contexts Refactors the element security schema lookups and runtime attribute validation to consistently account for SVG and MathML namespaces. This improves the modularity and accuracy of security context mapping during template compilation and runtime constant evaluation, eliminating redundant or false-positive lifecycle checks. --- .../src/schema/dom_element_schema_registry.ts | 14 +- .../src/schema/dom_security_schema.ts | 230 ++++++++---------- .../src/template/pipeline/src/ingest.ts | 20 +- .../src/template/pipeline/src/namespaces.ts | 10 + .../src/template_parser/binding_parser.ts | 41 +++- .../dom_element_schema_registry_spec.ts | 13 + packages/core/src/render3/interfaces/node.ts | 5 + .../core/src/render3/tnode_manipulation.ts | 2 + .../core/src/sanitization/sanitization.ts | 106 +++++--- .../bundle.golden_symbols.json | 1 + packages/core/test/render3/is_shape_of.ts | 1 + .../test/sanitization/sanitization_spec.ts | 4 +- 12 files changed, 273 insertions(+), 174 deletions(-) create mode 100644 packages/compiler/src/template/pipeline/src/namespaces.ts diff --git a/packages/compiler/src/schema/dom_element_schema_registry.ts b/packages/compiler/src/schema/dom_element_schema_registry.ts index 8d3bdd62a10d..845627994ae6 100644 --- a/packages/compiler/src/schema/dom_element_schema_registry.ts +++ b/packages/compiler/src/schema/dom_element_schema_registry.ts @@ -437,12 +437,14 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry { // property names do not have a security impact. tagName = tagName.toLowerCase(); 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[tagName + '|' + propName] ?? + securitySchema['*|' + propName] ?? + SecurityContext.NONE; + + return ctx; } override getMappedPropName(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..5d3c635d2d0d 100644 --- a/packages/compiler/src/schema/dom_security_schema.ts +++ b/packages/compiler/src/schema/dom_security_schema.ts @@ -20,158 +20,130 @@ import {SecurityContext} from '../core'; /** 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'; 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']], + ]); + + // The below are for Script SVG + // See: https://developer.mozilla.org/en-US/docs/Web/API/SVGScriptElement/href + registerContext(SecurityContext.RESOURCE_URL, SVG_NAMESPACE, [ + ['script', ['src', '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/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_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/test/schema/dom_element_schema_registry_spec.ts b/packages/compiler/test/schema/dom_element_schema_registry_spec.ts index 678c19078de9..dd73761d56df 100644 --- a/packages/compiler/test/schema/dom_element_schema_registry_spec.ts +++ b/packages/compiler/test/schema/dom_element_schema_registry_spec.ts @@ -158,6 +158,19 @@ If 'onAnything' is a directive input, make sure the directive is imported by the 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, + ); }); it('should detect properties on namespaced elements', () => { diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index a639f385f9d5..c120bacedc98 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -378,6 +378,11 @@ export interface TNode { */ value: any; + /** + * The namespace associated with this node. + */ + namespace: string | null; + /** * Attributes associated with an element. We need to store attributes to support various * use-cases (attribute injection, content projection with selectors, directives matching). diff --git a/packages/core/src/render3/tnode_manipulation.ts b/packages/core/src/render3/tnode_manipulation.ts index cebc3f4295ba..249f9e2be1f0 100644 --- a/packages/core/src/render3/tnode_manipulation.ts +++ b/packages/core/src/render3/tnode_manipulation.ts @@ -26,6 +26,7 @@ import {assertPureTNodeType} from './node_assert'; import { getCurrentParentTNode, getCurrentTNodePlaceholderOk, + getNamespace, isCurrentTNodeParent, isInI18nBlock, isInSkipHydrationBlock, @@ -302,6 +303,7 @@ export function createTNode( flags, providerIndexes: 0, value: value, + namespace: getNamespace(), attrs: attrs, mergedAttrs: null, localNames: null, diff --git a/packages/core/src/sanitization/sanitization.ts b/packages/core/src/sanitization/sanitization.ts index c01df5ad4bb8..a11dfbe0616b 100644 --- a/packages/core/src/sanitization/sanitization.ts +++ b/packages/core/src/sanitization/sanitization.ts @@ -214,8 +214,17 @@ export function ɵɵtrustConstantResourceUrl(url: TemplateStringsArray): Trusted } // Define sets outside the function for O(1) lookups and memory efficiency -const SRC_RESOURCE_TAGS = new Set(['embed', 'frame', 'iframe', 'media', 'script']); -const HREF_RESOURCE_TAGS = new Set(['base', 'link', 'script']); +const RESOURCE_MAP: Record | undefined> = { + 'embed': {'src': true}, + 'frame': {'src': true}, + 'iframe': {'src': true}, + 'media': {'src': true}, + 'script': {'src': true, 'href': true, 'xlink:href': true}, + ':svg:script': {'src': true, 'href': true, 'xlink:href': 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. @@ -225,10 +234,7 @@ const HREF_RESOURCE_TAGS = new Set(['base', 'link', 'script']); * If tag and prop names don't match Resource URL schema, use URL sanitizer. */ export function getUrlSanitizer(tag: string, prop: string) { - const isResource = - (prop === 'src' && SRC_RESOURCE_TAGS.has(tag)) || - (prop === 'href' && HREF_RESOURCE_TAGS.has(tag)) || - (prop === 'xlink:href' && tag === 'script'); + const isResource = RESOURCE_MAP[tag.toLowerCase()]?.[prop.toLowerCase()] === true; return isResource ? ɵɵsanitizeResourceUrl : ɵɵsanitizeUrl; } @@ -274,19 +280,32 @@ const attributeName: ReadonlySet = new Set(['attributename']); * @remarks Keep this in sync with DOM Security Schema. * @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts) */ -export const SECURITY_SENSITIVE_ELEMENTS: Readonly>> = { - 'iframe': new Set([ - 'sandbox', - 'allow', - 'allowfullscreen', - 'referrerpolicy', - 'csp', - 'fetchpriority', - ]), - 'animate': attributeName, - 'set': attributeName, - 'animatemotion': attributeName, - 'animatetransform': attributeName, +/** + * Set of attributes that are sensitive and should be sanitized. + */ +const SECURITY_SENSITIVE_ATTRIBUTE_NAMES: ReadonlySet = new Set(['href', 'xlink:href']); + +export const SECURITY_SENSITIVE_ELEMENTS: Record< + string, + Record> | undefined +> = { + 'iframe': { + 'sandbox': true, + 'allow': true, + 'allowfullscreen': true, + 'referrerpolicy': true, + 'csp': true, + 'fetchpriority': true, + }, + ':svg:animate': { + 'attributename': true, + '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}, }; /** @@ -299,26 +318,57 @@ export const SECURITY_SENSITIVE_ELEMENTS: Readonly(value: T, tagName: string, attributeName: string): T { const lowerCaseTagName = tagName.toLowerCase(); const lowerCaseAttrName = attributeName.toLowerCase(); - if (!SECURITY_SENSITIVE_ELEMENTS[lowerCaseTagName]?.has(lowerCaseAttrName)) { - return value; - } - const tNode = getSelectedTNode()!; - if (tNode.type !== TNodeType.Element) { + + // Leverage tNode.namespace if active, otherwise check both namespaced and base variants. + const tNode = getSelectedTNode(); + const fullTagName = + lowerCaseTagName[0] !== ':' && tNode?.namespace + ? `:${tNode.namespace}:${lowerCaseTagName}` + : lowerCaseTagName; + + const validationConfig = SECURITY_SENSITIVE_ELEMENTS[fullTagName]?.[lowerCaseAttrName]; + + if (!validationConfig) { return value; } const lView = getLView(); if (lowerCaseTagName === 'iframe') { - const element = getNativeByTNode(tNode, lView) as RElement; - enforceIframeSecurity(element as HTMLIFrameElement); + if (tNode?.type === TNodeType.Element) { + 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?.type === TNodeType.Element) { + const element = getNativeByTNode(tNode, lView) as SVGAnimateElement; + const attributeNameValue = element.getAttribute('attributeName'); + + if (attributeNameValue && validationConfig.has(attributeNameValue.toLowerCase())) { + 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); + } + } + + return value; + } const errorMessage = ngDevMode && `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${tagName}> element${getTemplateLocationDetails(lView)}. ` + - `For security reasons, the \`${attributeName}\` can be set on the <${tagName}> element ` + + `as a binding to the <${displayTagName}> element${getTemplateLocationDetails(lView)}. ` + + `For security reasons, the \`${attributeName}\` can be set on the <${displayTagName}> 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.`; 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/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..5128ef284a0f 100644 --- a/packages/core/test/sanitization/sanitization_spec.ts +++ b/packages/core/test/sanitization/sanitization_spec.ts @@ -125,7 +125,9 @@ describe('sanitization', () => { 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)!); } } }); From c939775bdbb3d8b09f33c8536efb1adee5290b79 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 19 May 2026 22:00:32 +0200 Subject: [PATCH 02/16] fix(core): support prefix-insensitive DOM schema lookups and compile-time i18n attribute validation Updates `DomElementSchemaRegistry` to strip `:svg:` and `:math:` namespace prefixes from tag names before querying `SECURITY_SCHEMA` at compile-time. This allows SVG and MathML attributes to correctly match their security contexts during compilation. --- goldens/public-api/core/index.api.md | 2 + .../cli-hello-world-ivy-i18n/size.json | 2 +- packages/compiler/src/compiler.ts | 1 - packages/compiler/src/core.ts | 12 +- .../src/schema/dom_element_schema_registry.ts | 2 - .../src/schema/dom_security_schema.ts | 27 +- .../src/template/pipeline/src/emit.ts | 2 + .../pipeline/src/phases/const_collection.ts | 5 +- .../phases/resolve_i18n_attr_sanitizers.ts | 75 ++++ .../compiler/test/schema/schema_extractor.ts | 370 ------------------ packages/core/BUILD.bazel | 8 + packages/core/src/core.ts | 2 +- packages/core/src/render3/i18n/i18n_apply.ts | 24 +- packages/core/src/render3/i18n/i18n_parse.ts | 83 ++-- .../src/sanitization/dom_security_schema.ts | 176 +++++++++ .../core/src/sanitization/html_sanitizer.ts | 10 - .../core/src/sanitization/sanitization.ts | 71 ++-- packages/core/src/sanitization/sanitizer.ts | 2 +- packages/core/src/sanitization/security.ts | 25 -- .../test/linker/security_integration_spec.ts | 79 ++++ .../core/test/render3/instructions_spec.ts | 2 +- .../core/test/render3/integration_spec.ts | 2 +- .../test/sanitization/sanitization_spec.ts | 3 +- 23 files changed, 483 insertions(+), 502 deletions(-) create mode 100644 packages/compiler/src/template/pipeline/src/phases/resolve_i18n_attr_sanitizers.ts delete mode 100644 packages/compiler/test/schema/schema_extractor.ts create mode 100644 packages/core/src/sanitization/dom_security_schema.ts delete mode 100644 packages/core/src/sanitization/security.ts 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/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 845627994ae6..9220b5c0f234 100644 --- a/packages/compiler/src/schema/dom_element_schema_registry.ts +++ b/packages/compiler/src/schema/dom_element_schema_registry.ts @@ -433,8 +433,6 @@ 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(); propName = propName.toLowerCase(); diff --git a/packages/compiler/src/schema/dom_security_schema.ts b/packages/compiler/src/schema/dom_security_schema.ts index 5d3c635d2d0d..11c7a305fc06 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,11 +35,17 @@ 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 = {}; 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/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/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/BUILD.bazel b/packages/core/BUILD.bazel index e7f45d0a0be3..fd6bc00786fb 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -1,3 +1,4 @@ +load("@bazel_lib//lib:write_source_files.bzl", "write_source_file") load("//adev/shared-docs/pipeline/api-gen:generate_api_docs.bzl", "generate_api_docs") load("//packages/common/locales:index.bzl", "generate_base_locale_file") load("//tools:defaults.bzl", "api_golden_test", "api_golden_test_npm_package", "ng_package", "ng_project", "ts_config", "tsec_test") @@ -153,3 +154,10 @@ genrule( outs = ["event-dispatch-contract.min.js"], cmd = "cp $< $@", ) + +write_source_file( + name = "dom_security_schema", + check_that_out_file_exists = False, + in_file = "//packages/compiler:src/schema/dom_security_schema.ts", + out_file = ":src/sanitization/dom_security_schema.ts", +) 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..c4003a399342 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, @@ -386,13 +385,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 +814,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 +829,7 @@ function walkIcuTree( newIndex, attr.name, 0, - i18nSanitizeAttribute(lowerAttrName), + i18nResolveSanitizer(lowerAttrName, tagNameWithNamespace), ); } else { ngDevMode && @@ -831,9 +840,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 +850,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 +984,29 @@ 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 { +function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null { const lowerAttrName = attrName.toLowerCase(); - if (SENSITIVE_ATTRS[lowerAttrName]) { - return _sanitizeUrl; + const lowerTagName = tagName ? tagName.toLowerCase() : '*'; + 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; } - - if (SECURITY_SENSITIVE_ATTRS.has(lowerAttrName)) { - return _validateAttribute; - } - - return null; } diff --git a/packages/core/src/sanitization/dom_security_schema.ts b/packages/core/src/sanitization/dom_security_schema.ts new file mode 100644 index 000000000000..179752e79f79 --- /dev/null +++ b/packages/core/src/sanitization/dom_security_schema.ts @@ -0,0 +1,176 @@ +/** + * @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 + */ + +/** + * 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, +} + +// ================================================================================================= +// ================================================================================================= +// =========== S T O P - S T O P - S T O P - S T O P - S T O P - S T O P =========== +// ================================================================================================= +// ================================================================================================= +// +// DO NOT EDIT THIS LIST OF SECURITY SENSITIVE PROPERTIES WITHOUT A SECURITY REVIEW! +// +// ================================================================================================= + +/** + * 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, /** 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 + ['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, /** Namespace */ undefined, [ + ['base', ['href']], + ['embed', ['src']], + ['frame', ['src']], + ['iframe', ['src']], + ['link', ['href']], + ['object', ['codebase', 'data']], + ]); + + // The below are for Script SVG + // See: https://developer.mozilla.org/en-US/docs/Web/API/SVGScriptElement/href + registerContext(SecurityContext.RESOURCE_URL, SVG_NAMESPACE, [ + ['script', ['src', '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', + [ + 'attributeName', + 'values', + 'to', + 'from', + 'sandbox', + 'allow', + 'allowFullscreen', + 'referrerPolicy', + 'csp', + 'fetchPriority', + ], + ], + ['iframe', ['sandbox', 'allow', 'allowFullscreen', 'referrerPolicy', 'csp', 'fetchPriority']], + ]); + } + + return _SECURITY_SCHEMA; +} + +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/core/src/sanitization/html_sanitizer.ts b/packages/core/src/sanitization/html_sanitizer.ts index 1238ea8bf531..8605775e9275 100644 --- a/packages/core/src/sanitization/html_sanitizer.ts +++ b/packages/core/src/sanitization/html_sanitizer.ts @@ -116,16 +116,6 @@ export const VALID_ATTRS: BooleanRecord = merge(URI_ATTRS, HTML_ATTRS, ARIA_ATTR // don't want to preserve the content, if the elements themselves are going to be removed. const SKIP_TRAVERSING_CONTENT_IF_INVALID_ELEMENTS = tagSet('script,style,template'); -/** - * Attributes that are potential attach vectors and may need to be sanitized. - */ -export const SENSITIVE_ATTRS: BooleanRecord = merge( - URI_ATTRS, - // Note: we don't include these attributes in `URI_ATTRS`, because `URI_ATTRS` also - // determines whether an attribute should be dropped when sanitizing an HTML string. - tagSet('action,formaction,data,codebase'), -); - /** * SanitizingHtmlSerializer serializes a DOM fragment, stripping out any unsafe elements and unsafe * attributes. diff --git a/packages/core/src/sanitization/sanitization.ts b/packages/core/src/sanitization/sanitization.ts index a11dfbe0616b..750941e79ad8 100644 --- a/packages/core/src/sanitization/sanitization.ts +++ b/packages/core/src/sanitization/sanitization.ts @@ -10,10 +10,10 @@ import {XSS_SECURITY_URL} from '../error_details_base_url'; import {RuntimeError, RuntimeErrorCode} from '../errors'; import {getTemplateLocationDetails} from '../render3/instructions/element_validation'; import {getDocument} from '../render3/interfaces/document'; -import {TNodeType} from '../render3/interfaces/node'; +import {TNode, TNodeType} from '../render3/interfaces/node'; import {RElement} from '../render3/interfaces/renderer_dom'; import {ENVIRONMENT} from '../render3/interfaces/view'; -import {getLView, getSelectedTNode} from '../render3/state'; +import {getLView, getSelectedIndex, getSelectedTNode} from '../render3/state'; import {renderStringify} from '../render3/util/stringify_utils'; import {getNativeByTNode} from '../render3/util/view_utils'; import {TrustedHTML, TrustedScript, TrustedScriptURL} from '../util/security/trusted_type_defs'; @@ -25,11 +25,11 @@ import { } from '../util/security/trusted_types_bypass'; import {allowSanitizationBypassAndThrow, BypassType, unwrapSafeValue} from './bypass'; -import {_sanitizeHtml as _sanitizeHtml} from './html_sanitizer'; +import {_sanitizeHtml} from './html_sanitizer'; import {enforceIframeSecurity} from './iframe_attrs_validation'; import {Sanitizer} from './sanitizer'; -import {SecurityContext} from './security'; -import {_sanitizeUrl as _sanitizeUrl} from './url_sanitizer'; +import {SecurityContext} from './dom_security_schema'; +import {_sanitizeUrl} from './url_sanitizer'; /** * An `html` sanitizer which converts untrusted `html` **string** into trusted string by removing @@ -280,12 +280,16 @@ const attributeName: ReadonlySet = new Set(['attributename']); * @remarks Keep this in sync with DOM Security Schema. * @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts) */ +<<<<<<< HEAD /** * Set of attributes that are sensitive and should be sanitized. */ const SECURITY_SENSITIVE_ATTRIBUTE_NAMES: ReadonlySet = new Set(['href', 'xlink:href']); export const SECURITY_SENSITIVE_ELEMENTS: Record< +======= +const SECURITY_SENSITIVE_ELEMENTS: Record< +>>>>>>> 61a97f22e8 (fix(core): support prefix-insensitive DOM schema lookups and compile-time i18n attribute validation) string, Record> | undefined > = { @@ -319,9 +323,16 @@ export function ɵɵvalidateAttribute(value: T, tagName: string, attrib const lowerCaseTagName = tagName.toLowerCase(); const lowerCaseAttrName = attributeName.toLowerCase(); +<<<<<<< HEAD +======= + const index = getSelectedIndex(); + const tNode: TNode | null = index === -1 ? null : getSelectedTNode(); + if (tNode && tNode.type !== TNodeType.Element) { + return value; + } +>>>>>>> 61a97f22e8 (fix(core): support prefix-insensitive DOM schema lookups and compile-time i18n attribute validation) // Leverage tNode.namespace if active, otherwise check both namespaced and base variants. - const tNode = getSelectedTNode(); const fullTagName = lowerCaseTagName[0] !== ':' && tNode?.namespace ? `:${tNode.namespace}:${lowerCaseTagName}` @@ -334,32 +345,40 @@ export function ɵɵvalidateAttribute(value: T, tagName: string, attrib } const lView = getLView(); - if (lowerCaseTagName === 'iframe') { - if (tNode?.type === TNodeType.Element) { - const element = getNativeByTNode(tNode, lView) as RElement; - enforceIframeSecurity(element as HTMLIFrameElement); - } + 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?.type === TNodeType.Element) { - const element = getNativeByTNode(tNode, lView) as SVGAnimateElement; - const attributeNameValue = element.getAttribute('attributeName'); + 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 = element.getAttribute('attributeName'); - if (attributeNameValue && validationConfig.has(attributeNameValue.toLowerCase())) { - 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.`; + if (attributeNameValue && validationConfig.has(attributeNameValue.toLowerCase())) { + 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); - } + throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); } return value; @@ -367,7 +386,7 @@ export function ɵɵvalidateAttribute(value: T, tagName: string, attrib const errorMessage = ngDevMode && `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${displayTagName}> element${getTemplateLocationDetails(lView)}. ` + + `as a binding to the <${displayTagName}> element${tNode ? getTemplateLocationDetails(lView) : ''}. ` + `For security reasons, the \`${attributeName}\` can be set on the <${displayTagName}> element ` + `as a static attribute only. \n` + `To fix this, switch the \`${attributeName}\` binding to a static attribute ` + diff --git a/packages/core/src/sanitization/sanitizer.ts b/packages/core/src/sanitization/sanitizer.ts index 10711994a406..ed59bd7c0afd 100644 --- a/packages/core/src/sanitization/sanitizer.ts +++ b/packages/core/src/sanitization/sanitizer.ts @@ -7,7 +7,7 @@ */ import {ɵɵdefineInjectable} from '../di/interface/defs'; -import {SecurityContext} from './security'; +import {SecurityContext} from './dom_security_schema'; /** * Sanitizer is used by the views to sanitize potentially dangerous values. diff --git a/packages/core/src/sanitization/security.ts b/packages/core/src/sanitization/security.ts deleted file mode 100644 index 87e72bcd532f..000000000000 --- a/packages/core/src/sanitization/security.ts +++ /dev/null @@ -1,25 +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 - */ - -/** - * 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, -} diff --git a/packages/core/test/linker/security_integration_spec.ts b/packages/core/test/linker/security_integration_spec.ts index 05f931672770..b05e9b138a7a 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'; @@ -274,6 +276,83 @@ describe('security integration tests', function () { }); describe('translation', () => { + afterEach(() => { + clearTranslations(); + }); + + it('should throw error on translated SVG script ResourceURL attributes', () => { + const template = ` + + + + `; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + expect(() => TestBed.createComponent(SecuredComponent)).toThrowError( + /unsafe value used in a resource URL context/i, + ); + }); + + 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..783f796f4bd5 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', () => { diff --git a/packages/core/test/sanitization/sanitization_spec.ts b/packages/core/test/sanitization/sanitization_spec.ts index 5128ef284a0f..e0fb907c4a2b 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; From 3b2a9f0abf66a58a629b758b044e1e8855bf5fa3 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 19 May 2026 22:06:00 +0200 Subject: [PATCH 03/16] fix(compiler): strip namespaced SVG script elements during template compilation Ensures that namespaced - - \`, - }) - 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/schema/dom_security_schema.ts b/packages/compiler/src/schema/dom_security_schema.ts index 11c7a305fc06..21f389854b31 100644 --- a/packages/compiler/src/schema/dom_security_schema.ts +++ b/packages/compiler/src/schema/dom_security_schema.ts @@ -115,12 +115,6 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { ['object', ['codebase', 'data']], ]); - // The below are for Script SVG - // See: https://developer.mozilla.org/en-US/docs/Web/API/SVGScriptElement/href - registerContext(SecurityContext.RESOURCE_URL, SVG_NAMESPACE, [ - ['script', ['src', '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` 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/core/src/sanitization/sanitization.ts b/packages/core/src/sanitization/sanitization.ts index 750941e79ad8..874dfc51175c 100644 --- a/packages/core/src/sanitization/sanitization.ts +++ b/packages/core/src/sanitization/sanitization.ts @@ -219,8 +219,7 @@ const RESOURCE_MAP: Record | undefined> 'frame': {'src': true}, 'iframe': {'src': true}, 'media': {'src': true}, - 'script': {'src': true, 'href': true, 'xlink:href': true}, - ':svg:script': {'src': true, 'href': true, 'xlink:href': true}, + 'base': {'href': true}, 'link': {'href': true}, 'object': {'data': true, 'codebase': true}, diff --git a/packages/core/test/acceptance/security_spec.ts b/packages/core/test/acceptance/security_spec.ts index 635aa786a8aa..dc768c59fb8d 100644 --- a/packages/core/test/acceptance/security_spec.ts +++ b/packages/core/test/acceptance/security_spec.ts @@ -8,8 +8,11 @@ import {NgIf} from '@angular/common'; import { + ChangeDetectionStrategy, Component, + createComponent, Directive, + EnvironmentInjector, inject, provideZoneChangeDetection, TemplateRef, @@ -803,3 +806,64 @@ describe('innerHTML processing', () => { expect(fixture.nativeElement.innerHTML).not.toContain('action'); }); }); +describe('Component host element validation', () => { + it('should throw an error when dynamically mounting a component onto a script tag', () => { + @Component({ + selector: 'my-sink', + template: '', + }) + class MySink {} + + const scriptHost = document.createElement('script'); + document.head.appendChild(scriptHost); + + try { + const environmentInjector = TestBed.inject(EnvironmentInjector); + expect(() => { + createComponent(MySink, { + environmentInjector, + hostElement: scriptHost, + }); + }).toThrowError(/"`, + changeDetection: ChangeDetectionStrategy.Default, + }) + class TestCmp {} + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('script')).toBeFalsy(); + }); +}); diff --git a/packages/core/test/sanitization/sanitization_spec.ts b/packages/core/test/sanitization/sanitization_spec.ts index e0fb907c4a2b..4ba8a49e4df0 100644 --- a/packages/core/test/sanitization/sanitization_spec.ts +++ b/packages/core/test/sanitization/sanitization_spec.ts @@ -117,7 +117,7 @@ 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); @@ -132,6 +132,31 @@ describe('sanitization', () => { }); }); + 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); From 547e5f8aed6280c39580e9656d9310e0c819d823 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 19 May 2026 17:37:47 -0700 Subject: [PATCH 04/16] test(core): remove obsolete SVG script sanitization translation test Removes the `should throw error on translated SVG script ResourceURL attributes` integration test from `security_integration_spec.ts`. This test is now obsolete because SVG ` - - `; - TestBed.overrideComponent(SecuredComponent, {set: {template}}); - - expect(() => TestBed.createComponent(SecuredComponent)).toThrowError( - /unsafe value used in a resource URL context/i, - ); - }); - it('should throw error on SVG animation retargeting attributes', () => { const template = ` From 04466547ee13e1828e3d30c934267b0c571e624a Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 21 May 2026 18:49:56 +0000 Subject: [PATCH 05/16] fix(compiler): sanitize dynamic href and xlink:href bindings on SVG a elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic bindings to `href` and `xlink:href` attributes on SVG `` elements (``) were previously unmapped in the DOM security schema. As a result, they bypassed sanitization completely, creating a potential XSS vulnerability if bound to untrusted user inputs (e.g., `javascript:` URLs). This fix mitigates this risk by: 1. Registering `href` and `xlink:href` on `` elements under the `SecurityContext.URL` context in both the compiler and core DOM security schemas. 2. Enabling template compilation to output runtime URL sanitization checks (`ɵɵsanitizeUrl`) on these attributes. 3. Adding regression and verification test cases to ensure dynamic SVG link bindings are safely sanitized at runtime while static values are correctly allowed. --- .../src/schema/dom_security_schema.ts | 2 + .../dom_element_schema_registry_spec.ts | 6 +++ .../src/sanitization/dom_security_schema.ts | 2 + .../core/test/acceptance/security_spec.ts | 54 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/packages/compiler/src/schema/dom_security_schema.ts b/packages/compiler/src/schema/dom_security_schema.ts index 21f389854b31..c478ffba5a8d 100644 --- a/packages/compiler/src/schema/dom_security_schema.ts +++ b/packages/compiler/src/schema/dom_security_schema.ts @@ -115,6 +115,8 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { ['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` 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 dd73761d56df..eee5b221461d 100644 --- a/packages/compiler/test/schema/dom_element_schema_registry_spec.ts +++ b/packages/compiler/test/schema/dom_element_schema_registry_spec.ts @@ -171,6 +171,12 @@ If 'onAnything' is a directive input, make sure the directive is imported by the 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', () => { diff --git a/packages/core/src/sanitization/dom_security_schema.ts b/packages/core/src/sanitization/dom_security_schema.ts index 179752e79f79..e600d816625f 100644 --- a/packages/core/src/sanitization/dom_security_schema.ts +++ b/packages/core/src/sanitization/dom_security_schema.ts @@ -121,6 +121,8 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} { ['script', ['src', 'href', 'xlink:href']], ]); + 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 diff --git a/packages/core/test/acceptance/security_spec.ts b/packages/core/test/acceptance/security_spec.ts index dc768c59fb8d..a84263597eb8 100644 --- a/packages/core/test/acceptance/security_spec.ts +++ b/packages/core/test/acceptance/security_spec.ts @@ -867,3 +867,57 @@ describe('SVG `, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, }) class TestCmp {} From d96a32286bc1234c1849434bf88f11f492301bfb Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 26 May 2026 07:32:14 +0000 Subject: [PATCH 13/16] test(core): remove obsolete blockquote cite host binding tests Removes the obsolete blockquote[cite] sanitization expectations in host_binding_spec.ts. --- .../core/test/acceptance/host_binding_spec.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/core/test/acceptance/host_binding_spec.ts b/packages/core/test/acceptance/host_binding_spec.ts index ca06e4fa69dc..920c7b57de5f 100644 --- a/packages/core/test/acceptance/host_binding_spec.ts +++ b/packages/core/test/acceptance/host_binding_spec.ts @@ -1607,23 +1607,6 @@ describe('host bindings', () => { true, true, ); - verify( - 'blockquote', - 'cite', - 'javascript:alert(2)', - 'unsafe:javascript:alert(2)', - bypassSanitizationTrustUrl, - ); - verify('blockquote', 'cite', 'javascript:alert(2.1)', 'unsafe:javascript:alert(2.1)', identity); - verify( - 'blockquote', - 'cite', - 'javascript:alert(2.2)', - 'unsafe:javascript:alert(2.2)', - bypassSanitizationTrustHtml, - true, - true, - ); verify( 'b', 'innerHTML', From 117b0eb11b9b8d7dbe9276f0ec4ba51508692af3 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 13 May 2026 10:12:28 +0000 Subject: [PATCH 14/16] fix(core): reject script element as a dynamic component host To enhance application security and prevent accidental or malicious script execution, this change ensures that dynamically mounting a component via createComponent directly onto a