Skip to content

Commit ab9154a

Browse files
alan-agius4alxhub
authored andcommitted
fix(compiler): normalize tag names with custom namespaces in DomElementSchemaRegistry (#68868)
Custom XML/XHTML namespaced elements (e.g., <xhtml:a>) fall back to the standard HTML namespace during element creation at compile-time/runtime. However, their property and security context lookups inside the schema registry were incorrectly performed using the full namespaced tag name (e.g., :xhtml:a), which bypassed the default a|href sanitization registry and incorrectly returned SecurityContext.NONE instead of SecurityContext.URL. This commit introduces tag name normalization inside DomElementSchemaRegistry for custom namespaces (other than the built-in svg and math namespaces). Custom namespaced tag names are now normalized to their simple HTML element counterparts for all registry queries, ensuring that correct property schema validation and dynamic security sanitization rules (such as URL sanitization) are enforced at runtime. PR Close #68868
1 parent 6ff620a commit ab9154a

3 files changed

Lines changed: 85 additions & 13 deletions

File tree

packages/compiler/src/schema/dom_element_schema_registry.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata, SecurityContext} from '../core';
10-
import {isNgContainer, isNgContent} from '../ml_parser/tags';
10+
import {isNgContainer, isNgContent, splitNsName} from '../ml_parser/tags';
11+
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces';
1112
import {dashCaseToCamelCase} from '../util';
1213
import {SECURITY_SCHEMA} from './dom_security_schema';
1314
import {ElementSchemaRegistry} from './element_schema_registry';
@@ -17,6 +18,24 @@ const NUMBER = 'number';
1718
const STRING = 'string';
1819
const OBJECT = 'object';
1920

21+
function normalizeTagName(tagName: string): string {
22+
tagName = tagName.toLowerCase();
23+
if (tagName[0] === ':') {
24+
const [ns, name] = splitNsName(tagName, false);
25+
26+
return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name;
27+
}
28+
29+
const colonIdx = tagName.indexOf(':');
30+
if (colonIdx > 0) {
31+
const ns = tagName.substring(0, colonIdx);
32+
const name = tagName.substring(colonIdx + 1);
33+
34+
return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name;
35+
}
36+
return tagName;
37+
}
38+
2039
/**
2140
* This array represents the DOM schema. It encodes inheritance, properties, and events.
2241
*
@@ -388,8 +407,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
388407
return true;
389408
}
390409

391-
if (tagName.indexOf('-') > -1) {
392-
if (isNgContainer(tagName) || isNgContent(tagName)) {
410+
const normalizedTag = normalizeTagName(tagName);
411+
if (normalizedTag.includes('-')) {
412+
if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) {
393413
return false;
394414
}
395415

@@ -400,8 +420,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
400420
}
401421
}
402422

403-
const elementProperties =
404-
this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!;
423+
const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!;
405424
return elementProperties.has(propName);
406425
}
407426

@@ -410,8 +429,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
410429
return true;
411430
}
412431

413-
if (tagName.indexOf('-') > -1) {
414-
if (isNgContainer(tagName) || isNgContent(tagName)) {
432+
const normalizedTag = normalizeTagName(tagName);
433+
if (normalizedTag.includes('-')) {
434+
if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) {
415435
return true;
416436
}
417437

@@ -421,7 +441,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
421441
}
422442
}
423443

424-
return this._schema.has(tagName.toLowerCase());
444+
return this._schema.has(normalizedTag);
425445
}
426446

427447
/**
@@ -444,12 +464,12 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
444464
propName = this.getMappedPropName(propName);
445465
}
446466

447-
tagName = tagName.toLowerCase();
467+
const normalizedTag = normalizeTagName(tagName);
448468
propName = propName.toLowerCase();
449469

450470
const securitySchema = SECURITY_SCHEMA();
451471
const ctx =
452-
securitySchema[tagName + '|' + propName] ??
472+
securitySchema[normalizedTag + '|' + propName] ??
453473
securitySchema['*|' + propName] ??
454474
SecurityContext.NONE;
455475

@@ -493,14 +513,15 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
493513
}
494514

495515
allKnownAttributesOfElement(tagName: string): string[] {
496-
const elementProperties =
497-
this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!;
516+
const normalizedTag = normalizeTagName(tagName);
517+
const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!;
498518
// Convert properties to attributes.
499519
return Array.from(elementProperties.keys()).map((prop) => _PROP_TO_ATTR.get(prop) ?? prop);
500520
}
501521

502522
allKnownEventsOfElement(tagName: string): string[] {
503-
return Array.from(this._eventSchema.get(tagName.toLowerCase()) ?? []);
523+
const normalizedTag = normalizeTagName(tagName);
524+
return Array.from(this._eventSchema.get(normalizedTag) ?? []);
504525
}
505526

506527
override normalizeAnimationStyleProperty(propName: string): string {

packages/compiler/test/schema/dom_element_schema_registry_spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,29 @@ If 'onAnything' is a directive input, make sure the directive is imported by the
204204
});
205205
});
206206

207+
describe('Custom XML / XHTML namespaces', () => {
208+
it('should support elements with custom namespaces', () => {
209+
expect(registry.hasElement(':xhtml:a', [])).toBeTruthy();
210+
expect(registry.hasElement('xhtml:a', [])).toBeTruthy();
211+
expect(registry.hasElement(':foo:div', [])).toBeTruthy();
212+
expect(registry.hasElement('foo:div', [])).toBeTruthy();
213+
});
214+
215+
it('should support properties on custom namespaced elements', () => {
216+
expect(registry.hasProperty(':xhtml:a', 'href', [])).toBeTruthy();
217+
expect(registry.hasProperty('xhtml:a', 'href', [])).toBeTruthy();
218+
expect(registry.hasProperty(':foo:div', 'id', [])).toBeTruthy();
219+
expect(registry.hasProperty('foo:div', 'id', [])).toBeTruthy();
220+
});
221+
222+
it('should return correct security contexts for custom namespaced elements', () => {
223+
expect(registry.securityContext(':xhtml:a', 'href', false)).toBe(SecurityContext.URL);
224+
expect(registry.securityContext('xhtml:a', 'href', false)).toBe(SecurityContext.URL);
225+
expect(registry.securityContext(':foo:div', 'innerHTML', false)).toBe(SecurityContext.HTML);
226+
expect(registry.securityContext('foo:div', 'innerHTML', false)).toBe(SecurityContext.HTML);
227+
});
228+
});
229+
207230
// Uncomment to see the generated schema which can then be pasted to the DomElementSchemaRegistry
208231
// if (!isNode) {
209232
// it('generate a new schema', () => {

packages/core/test/linker/security_integration_spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,22 @@ describe('security integration tests', function () {
209209
checkEscapeOfHrefProperty(fixture);
210210
});
211211

212+
it('should escape unsafe attributes on custom namespaced elements', () => {
213+
const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" [attr.href]="ctxProp">Link Title</xhtml:a>`;
214+
TestBed.overrideComponent(SecuredComponent, {set: {template}});
215+
const fixture = TestBed.createComponent(SecuredComponent);
216+
217+
checkEscapeOfHrefProperty(fixture);
218+
});
219+
220+
it('should escape unsafe properties on custom namespaced elements', () => {
221+
const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" [href]="ctxProp">Link Title</xhtml:a>`;
222+
TestBed.overrideComponent(SecuredComponent, {set: {template}});
223+
const fixture = TestBed.createComponent(SecuredComponent);
224+
225+
checkEscapeOfHrefProperty(fixture);
226+
});
227+
212228
it('should escape unsafe properties if they are used in host bindings', () => {
213229
@Directive({
214230
selector: '[dirHref]',
@@ -348,6 +364,18 @@ describe('security integration tests', function () {
348364
expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)');
349365
});
350366

367+
it('should sanitize translated static href attributes on custom namespaced elements', () => {
368+
loadTranslations({[computeMsgId('/safe')]: 'javascript:alert(1)'});
369+
const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" href="/safe" i18n-href>Link</xhtml:a>`;
370+
TestBed.overrideComponent(SecuredComponent, {set: {template}});
371+
372+
const fixture = TestBed.createComponent(SecuredComponent);
373+
fixture.detectChanges();
374+
375+
const link = fixture.nativeElement.querySelector('a');
376+
expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)');
377+
});
378+
351379
it('should throw error on security-sensitive attributes with constant values', () => {
352380
const template = `<iframe srcdoc="foo" i18n-srcdoc></iframe>`;
353381
TestBed.overrideComponent(SecuredComponent, {set: {template}});

0 commit comments

Comments
 (0)