Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions packages/compiler/src/schema/dom_element_schema_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import {ElementSchemaRegistry} from './element_schema_registry';
Expand All @@ -17,6 +18,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.
*
Expand Down Expand Up @@ -388,8 +396,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;
}

Expand All @@ -400,8 +409,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);
}

Expand All @@ -410,8 +418,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;
}

Expand All @@ -421,7 +430,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
}
}

return this._schema.has(tagName.toLowerCase());
return this._schema.has(normalizedTag);
}

/**
Expand All @@ -444,12 +453,12 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
propName = this.getMappedPropName(propName);
}

tagName = tagName.toLowerCase();
const normalizedTag = normalizeTagName(tagName);
propName = propName.toLowerCase();

const securitySchema = SECURITY_SCHEMA();
const ctx =
securitySchema[tagName + '|' + propName] ??
securitySchema[normalizedTag + '|' + propName] ??
securitySchema['*|' + propName] ??
SecurityContext.NONE;

Expand Down Expand Up @@ -493,14 +502,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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/schema/dom_security_schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// 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
Expand Down
23 changes: 23 additions & 0 deletions packages/compiler/test/schema/dom_element_schema_registry_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,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', () => {
Expand Down Expand Up @@ -198,6 +204,23 @@ If 'onAnything' is a directive input, make sure the directive is imported by the
});
});

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', () => {
Expand Down
28 changes: 27 additions & 1 deletion packages/core/src/render3/i18n/i18n_parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,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;
Expand Down Expand Up @@ -984,9 +985,34 @@ function addCreateAttribute(
create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue);
}

function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] {
if (elementName[0] != ':') {
return [null, elementName];
}

const colonIndex = elementName.indexOf(':', 1);

if (colonIndex === -1) {
if (fatal) {
throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`);
} else {
return [null, elementName];
}
}

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 ? tagName.toLowerCase() : '*';
const lowerTagName = tagName ? normalizeTagName(tagName) : '*';
const schema = SECURITY_SCHEMA();
const schemaContext =
schema[`${lowerTagName}|${lowerAttrName}`] ||
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/sanitization/dom_security_schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// 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
Expand Down
54 changes: 54 additions & 0 deletions packages/core/test/acceptance/security_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,3 +916,57 @@ describe('SVG <script> bindings', () => {
expect(fixture.nativeElement.querySelector('script')).toBeFalsy();
});
});

describe('SVG <a> link sanitization', () => {
it('should sanitize dynamic `href` bindings on <svg:a>', () => {
@Component({
template: '<svg><a [attr.href]="url"></a></svg>',
changeDetection: ChangeDetectionStrategy.Eager,
})
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 <svg:a>', () => {
@Component({
template: '<svg><a [attr.xlink:href]="url"></a></svg>',
changeDetection: ChangeDetectionStrategy.Eager,
})
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 <svg:a>', () => {
@Component({
template: `
<svg>
<a href="javascript:alert(1)"></a>
<a xlink:href="javascript:alert(2)"></a>
</svg>
`,
changeDetection: ChangeDetectionStrategy.Eager,
})
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)');
});
});
28 changes: 28 additions & 0 deletions packages/core/test/linker/security_integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ describe('security integration tests', function () {
checkEscapeOfHrefProperty(fixture);
});

it('should escape unsafe attributes on custom namespaced elements', () => {
const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" [attr.href]="ctxProp">Link Title</xhtml:a>`;
TestBed.overrideComponent(SecuredComponent, {set: {template}});
const fixture = TestBed.createComponent(SecuredComponent);

checkEscapeOfHrefProperty(fixture);
});

it('should escape unsafe properties on custom namespaced elements', () => {
const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" [href]="ctxProp">Link Title</xhtml:a>`;
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]',
Expand Down Expand Up @@ -348,6 +364,18 @@ describe('security integration tests', function () {
expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)');
});

it('should sanitize translated static href attributes on custom namespaced elements', () => {
loadTranslations({[computeMsgId('/safe')]: 'javascript:alert(1)'});
const template = `<xhtml:a xmlns:xhtml="http://www.w3.org/1999/xhtml" href="/safe" i18n-href>Link</xhtml:a>`;
TestBed.overrideComponent(SecuredComponent, {set: {template}});

const fixture = TestBed.createComponent(SecuredComponent);
fixture.detectChanges();

const link = fixture.nativeElement.querySelector('a');
expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)');
});

it('should throw error on security-sensitive attributes with constant values', () => {
const template = `<iframe srcdoc="foo" i18n-srcdoc></iframe>`;
TestBed.overrideComponent(SecuredComponent, {set: {template}});
Expand Down
Loading