fix(core): validate SVG animation attributes outside the SVG namespace

Make the SVG animation security context depend on the tag name instead of the namespace the element was created in. An animation element declared outside an `<svg>` is created in the HTML namespace, but still animates once it ends up inside an SVG subtree, so to and `attributeName` bindings were reaching the DOM unvalidated.

Fixes  #70490
This commit is contained in:
SkyZeroZx
2026-08-31 14:08:39 -05:00
committed by Matthew Beck
parent 7137a41223
commit 7168bed663
5 changed files with 206 additions and 1 deletions
@@ -210,5 +210,14 @@ export function checkSecurityContext(
}
}
// An SVG animation element declared outside of an `<svg>` has no explicit namespace at compile
// time, but can still animate once it is projected into an SVG subtree.
if (context === undefined && (!namespace || namespace === NO_NAMESPACE)) {
const svgSchema = attrSchema[SVG_NAMESPACE];
if (svgSchema) {
context = svgSchema[tagLower];
}
}
return context ?? SecurityContext.NONE;
}
@@ -172,6 +172,33 @@ If 'onAnything' is a directive input, make sure the directive is imported by the
SecurityContext.ATTRIBUTE_NO_BINDING,
);
// SVG animation elements are sensitive when their namespace is omitted at compile time.
expect(registry.securityContext('set', 'to', true)).toBe(SecurityContext.ATTRIBUTE_NO_BINDING);
expect(registry.securityContext('set', 'attributeName', true)).toBe(
SecurityContext.ATTRIBUTE_NO_BINDING,
);
expect(registry.securityContext('animate', 'to', true)).toBe(
SecurityContext.ATTRIBUTE_NO_BINDING,
);
expect(registry.securityContext('animate', 'from', true)).toBe(
SecurityContext.ATTRIBUTE_NO_BINDING,
);
expect(registry.securityContext('animate', 'values', true)).toBe(
SecurityContext.ATTRIBUTE_NO_BINDING,
);
expect(registry.securityContext('animate', 'attributeName', true)).toBe(
SecurityContext.ATTRIBUTE_NO_BINDING,
);
expect(registry.securityContext('animateMotion', 'attributeName', true)).toBe(
SecurityContext.ATTRIBUTE_NO_BINDING,
);
expect(registry.securityContext('animateTransform', 'attributeName', true)).toBe(
SecurityContext.ATTRIBUTE_NO_BINDING,
);
// The defensive SVG lookup must not apply to elements in an explicit foreign namespace.
expect(registry.securityContext(':math:set', 'to', true)).toBe(SecurityContext.NONE);
// SVG link attributes
expect(registry.securityContext(':svg:a', 'href', false)).toBe(SecurityContext.URL);
expect(registry.securityContext(':svg:a', 'xlink:href', false)).toBe(SecurityContext.URL);
@@ -210,5 +210,14 @@ export function checkSecurityContext(
}
}
// An SVG animation element declared outside of an `<svg>` has no explicit namespace at compile
// time, but can still animate once it is projected into an SVG subtree.
if (context === undefined && (!namespace || namespace === NO_NAMESPACE)) {
const svgSchema = attrSchema[SVG_NAMESPACE];
if (svgSchema) {
context = svgSchema[tagLower];
}
}
return context ?? SecurityContext.NONE;
}
@@ -358,7 +358,7 @@ export function ɵɵvalidateAttribute<T = any>(value: T, tagName: string, attrib
if (resolvedTagName === 'iframe') {
const element = getNativeByTNode(tNode, lView) as RElement;
enforceIframeSecurity(element as HTMLIFrameElement);
} else if (namespace === SVG_NAMESPACE) {
} else if (namespace === SVG_NAMESPACE || !namespace) {
const config =
SVG_ANIMATION_SENSITIVE_STATIC_VALUES[resolvedTagName]?.[attributeName.toLowerCase()];
if (config) {
@@ -10,9 +10,13 @@ import {CommonModule} from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
createComponent,
Directive,
EnvironmentInjector,
HostBinding,
inject,
provideZoneChangeDetection,
ViewContainerRef,
} from '../../src/core';
import {TestBed} from '../../testing';
@@ -688,6 +692,162 @@ describe('sanitization', () => {
});
}
// One carrier per attribute: directive metadata has to be statically analyzable, so the bound
// attribute name cannot be interpolated into `host`.
const UNSAFE_VALUE = 'javascript:alert(1)';
@Directive({selector: '[bind-to]', host: {'[attr.to]': 'value'}})
class BindTo {
value = UNSAFE_VALUE;
}
@Directive({selector: '[bind-from]', host: {'[attr.from]': 'value'}})
class BindFrom {
value = UNSAFE_VALUE;
}
@Directive({selector: '[bind-values]', host: {'[attr.values]': 'value'}})
class BindValues {
value = UNSAFE_VALUE;
}
@Directive({selector: '[bind-attribute-name]', host: {'[attr.attributeName]': 'value'}})
class BindAttributeName {
value = UNSAFE_VALUE;
}
const attrCarriers = {
'to': BindTo,
'from': BindFrom,
'values': BindValues,
'attributeName': BindAttributeName,
};
// Binds one attribute on a host element created in the HTML namespace. Going through a concrete
// `hostElement` keeps the tag name out of the template, which lets the cases below be table
// driven. Returns the change detection call so the caller can assert on it.
function bindAttributeOutsideSvg(
tagName: string,
attrName: keyof typeof attrCarriers,
staticAttributeName: string,
): () => void {
@Component({
template: '',
changeDetection: ChangeDetectionStrategy.Eager,
})
class DynamicHost {}
const hostElement = document.createElement(tagName);
hostElement.setAttribute('attributeName', staticAttributeName);
const componentRef = createComponent(DynamicHost, {
hostElement,
environmentInjector: TestBed.inject(EnvironmentInjector),
directives: [attrCarriers[attrName]],
});
return () => {
try {
componentRef.changeDetectorRef.detectChanges();
} finally {
componentRef.destroy();
}
};
}
// An animation element declared outside of an `<svg>` is created in the HTML namespace, but still
// animates once it ends up inside an `<svg>` subtree - for example when it is projected into one
// and the server-rendered markup is re-parsed by the browser.
const svgAnimationAttrs: Record<string, (keyof typeof attrCarriers)[]> = {
'set': ['to', 'attributeName'],
'animate': ['to', 'from', 'values', 'attributeName'],
'animateMotion': ['attributeName'],
'animateTransform': ['attributeName'],
};
for (const [tagName, attrNames] of Object.entries(svgAnimationAttrs)) {
for (const attrName of attrNames) {
it(`should throw when binding to \`${attrName}\` on a <${tagName}> outside of an svg element`, () => {
const detectChanges = bindAttributeOutsideSvg(tagName, attrName, 'href');
// The tag name is reported as the security check lower-cased it.
expect(detectChanges).toThrowError(
new RegExp(
`Angular has detected that the \`${attrName}\` was applied as a binding ` +
`to the <${tagName.toLowerCase()}> element`,
),
);
});
}
}
it('should throw when binding to a set element projected into an svg element', () => {
@Component({
selector: 'svg-wrapper',
template: `
<svg>
<a>
<g>
<ng-content></ng-content>
</g>
</a>
</svg>
`,
})
class SvgWrapper {}
@Component({
selector: 'test-comp',
imports: [SvgWrapper],
template: `
<svg-wrapper>
<set attributeName="href" [attr.to]="'javascript:alert(1)'"></set>
</svg-wrapper>
`,
changeDetection: ChangeDetectionStrategy.Eager,
})
class TestComp {}
const fixture = TestBed.createComponent(TestComp);
expect(() => fixture.detectChanges()).toThrowError(
/Angular has detected that the `to` was applied/,
);
});
it('should throw when binding to a dynamically created set component host', () => {
@Component({
selector: 'set[drilldown]',
template: '',
host: {
'attributeName': 'href',
'[attr.to]': 'target',
},
changeDetection: ChangeDetectionStrategy.Eager,
})
class DynamicSetComp {
target = 'javascript:alert(1)';
}
@Component({
selector: 'test-comp',
template: '',
})
class TestComp {
readonly viewContainerRef = inject(ViewContainerRef);
}
const fixture = TestBed.createComponent(TestComp);
fixture.componentInstance.viewContainerRef.createComponent(DynamicSetComp);
expect(() => fixture.detectChanges()).toThrowError(
/Angular has detected that the `to` was applied/,
);
});
it('should not throw when binding to a set element outside of an svg element when attributeName is not href', () => {
expect(bindAttributeOutsideSvg('set', 'to', 'display')).not.toThrow();
});
it('should not throw when binding to animate element when attributeName is not href', () => {
@Component({
selector: 'test-comp',