mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(compiler): strip namespaced SVG script elements during template compilation
Ensures that namespaced <script> elements (such as :svg:script) are correctly classified as PreparsedElementType.SCRIPT by the template preparser and stripped during compilation to prevent potential XSS vulnerabilities. Consequently, obsolete security schema mappings and runtime sanitization checks for <script> attributes have been removed since these elements are never present in compiled template outputs.
This commit is contained in:
@@ -8278,34 +8278,6 @@ runInEachFileSystem((os: string) => {
|
||||
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: \`
|
||||
<svg>
|
||||
<script [attr.xlink:href]="attr" [attr.href]="attr"></script>
|
||||
</svg>
|
||||
\`,
|
||||
})
|
||||
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`,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<string> = new Set([':svg:style', 'style']);
|
||||
const SCRIPT_ELEMENTS: ReadonlySet<string> = 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;
|
||||
}
|
||||
|
||||
@@ -219,8 +219,7 @@ const RESOURCE_MAP: Record<string, Record<string, true | undefined> | 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},
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
import {NgIf} from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
createComponent,
|
||||
Directive,
|
||||
EnvironmentInjector,
|
||||
inject,
|
||||
TemplateRef,
|
||||
Type,
|
||||
@@ -838,3 +841,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(/"<script>" tag is not allowed as a component host element/);
|
||||
} finally {
|
||||
scriptHost.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw an error when dynamically mounting a component onto an SVG script tag', () => {
|
||||
@Component({
|
||||
selector: 'my-svg-sink',
|
||||
template: '',
|
||||
})
|
||||
class MySvgSink {}
|
||||
|
||||
const svgScriptHost = document.createElementNS('http://www.w3.org/2000/svg', 'script');
|
||||
document.head.appendChild(svgScriptHost);
|
||||
|
||||
try {
|
||||
const environmentInjector = TestBed.inject(EnvironmentInjector);
|
||||
expect(() => {
|
||||
createComponent(MySvgSink, {
|
||||
environmentInjector,
|
||||
hostElement: svgScriptHost,
|
||||
});
|
||||
}).toThrowError(/"<script>" tag is not allowed as a component host element/);
|
||||
} finally {
|
||||
svgScriptHost.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG <script> bindings', () => {
|
||||
it(`should remove svg <script> element`, () => {
|
||||
@Component({
|
||||
template: `<svg><script src="https://bad.com/script.js"></script></svg>`,
|
||||
changeDetection: ChangeDetectionStrategy.Default,
|
||||
})
|
||||
class TestCmp {}
|
||||
|
||||
const fixture = TestBed.createComponent(TestCmp);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('script')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number>();
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user