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:
Alan Agius 2026-05-19 22:06:00 +02:00 committed by GitHub
parent dbceef6221
commit 90494cd909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 30 additions and 54 deletions

View file

@ -8648,34 +8648,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`,

View file

@ -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
// 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

View file

@ -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;
}

View file

@ -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},

View file

@ -902,3 +902,17 @@ describe('Component host element validation', () => {
}
});
});
describe('SVG <script> bindings', () => {
it(`should remove svg <script> element`, () => {
@Component({
template: `<svg><script src="https://bad.com/script.js"></script></svg>`,
changeDetection: ChangeDetectionStrategy.Eager,
})
class TestCmp {}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('script')).toBeFalsy();
});
});

View file

@ -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);
@ -136,7 +136,7 @@ describe('sanitization', () => {
expect(getUrlSanitizer('IFRAME', 'SRC')).toEqual(ɵɵsanitizeResourceUrl);
expect(getUrlSanitizer('IFRAME', 'src')).toEqual(ɵɵsanitizeResourceUrl);
expect(getUrlSanitizer('iframe', 'SRC')).toEqual(ɵɵsanitizeResourceUrl);
expect(getUrlSanitizer('ScRiPt', 'xLiNk:HrEf')).toEqual(ɵɵsanitizeResourceUrl);
expect(getUrlSanitizer('ScRiPt', 'xLiNk:HrEf')).toEqual(ɵɵsanitizeUrl);
expect(getUrlSanitizer('A', 'HREF')).toEqual(ɵɵsanitizeUrl);
});
@ -149,8 +149,8 @@ describe('sanitization', () => {
expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'iframe', 'SRC')).toThrowError(ERROR);
expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'ScRiPt', 'xLiNk:HrEf')).toThrowError(
ERROR,
expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'ScRiPt', 'xLiNk:HrEf')).toEqual(
'unsafe:javascript:true',
);
expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'A', 'HREF')).toEqual(