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