Which @angular/* package(s) are the source of the bug?
Don't known / other
Is this a regression?
No
Description
Report description
Angular production mode allows [attr.onclick] and host attr.onclick bindings to become click-triggered XSS
The problem
Please describe the technical details of the vulnerability
1. technical details
Angular already has explicit validation that rejects on* attribute and property bindings:
override validateProperty(name: string): {error: boolean; msg?: string} {
if (name.toLowerCase().startsWith('on')) {
const msg =
`Binding to event property '${name}' is disallowed for security reasons, ` +
`please use (${name.slice(2)})=...` +
`\nIf '${name}' is a directive input, make sure the directive is imported by the` +
` current module.`;
return {error: true, msg: msg};
} else {
return {error: false};
}
}
override validateAttribute(name: string): {error: boolean; msg?: string} {
if (name.toLowerCase().startsWith('on')) {
const msg =
`Binding to event attribute '${name}' is disallowed for security reasons, ` +
`please use (${name.slice(2)})=...`;
return {error: true, msg: msg};
} else {
return {error: false};
}
}
However, the main R3 template path explicitly bypasses that validation for bound element properties and attributes:
const bep = this.bindingParser.createBoundElementProperty(
elementName,
prop,
/* skipValidation */ true,
/* mapPropertyName */ false,
);
Host bindings have a similar gap. createBoundHostProperties() parses host properties, but does not reuse the same validation path:
createBoundHostProperties(
properties: HostProperties,
sourceSpan: ParseSourceSpan,
): ParsedProperty[] | null {
const boundProps: ParsedProperty[] = [];
for (const propName of Object.keys(properties)) {
const expression = properties[propName];
if (typeof expression === 'string') {
this.parsePropertyBinding(
propName,
expression,
true,
false,
sourceSpan,
sourceSpan.start.offset,
undefined,
[],
boundProps,
sourceSpan,
);
}
}
return boundProps;
}
The remaining runtime protection is only enforced in ngDevMode. In production mode, Angular writes the attribute directly into the DOM:
export function elementAttributeInternal(
tNode: TNode,
lView: LView,
name: string,
value: any,
sanitizer: SanitizerFn | null | undefined,
namespace: string | null | undefined,
) {
if (ngDevMode) {
assertNotSame(value, NO_CHANGE as any, 'Incoming value should never be NO_CHANGE.');
validateAgainstEventAttributes(name);
assertTNodeType(
tNode,
TNodeType.Element,
`Attempted to set attribute \`${name}\` on a container node. ` +
`Host bindings are not valid on ng-container or ng-template.`,
);
}
const element = getNativeByTNode(tNode, lView) as RElement;
setElementAttribute(lView[RENDERER], element, namespace, tNode.value, name, value, sanitizer);
}
export function setElementAttribute(
renderer: Renderer,
element: RElement,
namespace: string | null | undefined,
tagName: string | null,
name: string,
value: any,
sanitizer: SanitizerFn | null | undefined,
) {
if (value == null) {
renderer.removeAttribute(element, name, namespace);
} else {
const strValue =
sanitizer == null ? renderStringify(value) : sanitizer(value, tagName || '', name);
renderer.setAttribute(element, name, strValue as string, namespace);
}
}
As a result, if an application binds attacker-controlled data to [attr.onclick] in a template, or to @HostBinding('attr.onclick') / host: {'[attr.onclick]': ...}, production builds can emit a real inline event handler attribute. When the victim clicks the element, the browser executes attacker-controlled JavaScript in the page origin.
2. vulnerability reproduction
The attached browser PoC uses a real Angular template with a realistic application pattern: campaign data from a CMS is rendered into a marketing CTA button.
@Component({
selector: 'marketing-cta-page',
standalone: false,
changeDetection: ChangeDetectionStrategy.Eager,
template: `
<section>
<h1>{{ campaign.title }}</h1>
<p id="campaign-origin">{{ campaign.origin }}</p>
<button id="buy-now" type="button" [attr.onclick]="campaign.clickAction">
{{ campaign.ctaLabel }}
</button>
</section>
`,
})
class MarketingCtaPageComponent {
campaign = {
title: 'Spring Sale',
origin: 'trusted CMS entry',
ctaLabel: 'Buy now',
clickAction: '',
};
}
The PoC verifies that development mode blocks the binding:
it('blocks the binding in development mode', () => {
expect(() => {
const fixture = TestBed.createComponent(MarketingCtaPageComponent);
fixture.componentInstance.campaign.clickAction =
"document.documentElement.setAttribute('data-realistic-xss','fired')";
fixture.componentInstance.campaign.origin = 'attacker-controlled CMS entry';
fixture.detectChanges();
}).toThrowError(
/Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
);
});
The same PoC then switches to production mode and shows that Angular renders the dangerous attribute and the browser executes it on click:
it('turns attacker-controlled CMS data into browser-executed XSS in production mode', () => {
globalRef.ngDevMode = false;
const fixture = TestBed.createComponent(MarketingCtaPageComponent);
fixture.componentInstance.campaign.origin = 'attacker-controlled CMS entry';
fixture.componentInstance.campaign.clickAction =
"document.documentElement.setAttribute('data-realistic-xss','fired')";
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('#buy-now') as HTMLButtonElement;
expect(button.getAttribute('onclick')).toBe(
"document.documentElement.setAttribute('data-realistic-xss','fired')",
);
button.click();
expect(document.documentElement.getAttribute('data-realistic-xss')).toBe('fired');
});
The same test suite also models a benign trusted CMS handler string to show that the affected code path is a realistic application pattern:
it('renders a trusted CMS handler string in production mode and executes it on click', () => {
globalRef.ngDevMode = false;
globalRef.demoSafeHandler = (campaignId: string) => {
document.documentElement.setAttribute('data-safe-click', campaignId);
};
const fixture = TestBed.createComponent(MarketingCtaPageComponent);
fixture.componentInstance.campaign.origin = 'trusted CMS entry';
fixture.componentInstance.campaign.clickAction = `demoSafeHandler('spring-sale')`;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('#buy-now') as HTMLButtonElement;
expect(button.getAttribute('onclick')).toBe(`demoSafeHandler('spring-sale')`);
button.click();
expect(document.documentElement.getAttribute('data-safe-click')).toBe('spring-sale');
});
The browser PoC is available in ./work/angular/r3_template_transform_xss/realistic_attr_onclick_spec.ts and was executed with:
bazelisk test //r3_template_transform_xss:test_web --test_output=errors
Observed result:
//r3_template_transform_xss:test_web_chromium PASSED
//r3_template_transform_xss:test_web_firefox PASSED
This demonstrates a practical path from attacker-controlled application data to a real inline onclick attribute and browser-executed JavaScript when the user clicks the element.
3. patch
diff --git a/packages/compiler/src/render3/r3_template_transform.ts b/packages/compiler/src/render3/r3_template_transform.ts
index d031c50053..c3f9e72b94 100644
--- a/packages/compiler/src/render3/r3_template_transform.ts
+++ b/packages/compiler/src/render3/r3_template_transform.ts
@@ -594,7 +594,7 @@ class HtmlAstToIvyAst implements html.Visitor {
const bep = this.bindingParser.createBoundElementProperty(
elementName,
prop,
- /* skipValidation */ true,
+ /* skipValidation */ !prop.name.startsWith('attr.'),
/* mapPropertyName */ false,
);
bound.push(t.BoundAttribute.fromBoundElementProperty(bep, i18n));
diff --git a/packages/compiler/src/template_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts
index db7d694166..cb4b589ed5 100644
--- a/packages/compiler/src/template_parser/binding_parser.ts
+++ b/packages/compiler/src/template_parser/binding_parser.ts
@@ -72,6 +72,7 @@ export class BindingParser {
for (const propName of Object.keys(properties)) {
const expression = properties[propName];
if (typeof expression === 'string') {
+ const initialPropCount = boundProps.length;
this.parsePropertyBinding(
propName,
expression,
@@ -90,6 +91,14 @@ export class BindingParser {
boundProps,
sourceSpan,
);
+ for (let i = initialPropCount; i < boundProps.length; i++) {
+ this.createBoundElementProperty(
+ null,
+ boundProps[i],
+ /* skipValidation */ false,
+ /* mapPropertyName */ false,
+ );
+ }
} else {
this._reportError(
`Value of the host property binding "${propName}" needs to be a string representing an expression but got "${expression}" (${typeof expression})`,
diff --git a/packages/core/test/linker/security_integration_spec.ts b/packages/core/test/linker/security_integration_spec.ts
index 860ab92d9b..f82d3f4e5c 100644
--- a/packages/core/test/linker/security_integration_spec.ts
+++ b/packages/core/test/linker/security_integration_spec.ts
@@ -97,6 +97,27 @@ describe('security integration tests', function () {
expect(div.nativeElement.onclick).not.toBe(value);
expect(div.nativeElement.hasAttribute('onclick')).toEqual(false);
});
+
+ it('should disallow binding to attr.on* in host bindings', () => {
+ @Directive({
+ selector: '[dirOnclick]',
+ standalone: false,
+ })
+ class HostOnclickDirective {
+ @HostBinding('attr.onclick') @Input() dirOnclick: string | undefined;
+ }
+
+ const template = `<button [dirOnclick]="ctxProp"></button>`;
+ TestBed.configureTestingModule({declarations: [HostOnclickDirective]});
+ TestBed.overrideComponent(SecuredComponent, {set: {template}});
+
+ expect(() => {
+ const cmp = TestBed.createComponent(SecuredComponent);
+ cmp.detectChanges();
+ }).toThrowError(
+ /Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
+ );
+ });
});
describe('safe HTML values', function () {
Impact analysis – Please briefly explain who can exploit the vulnerability, and what they gain when doing so
An attacker who can influence application data that is bound to [attr.onclick] or to host attr.onclick bindings can exploit this issue against users of a production Angular application.
Successful exploitation causes the browser to execute attacker-controlled JavaScript when the victim clicks the affected element, giving the attacker same-origin DOM XSS in the application page.
r3_template_transform_xss.tar.gz
Please provide a link to a minimal reproduction of the bug
No response
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run ng version)
Anything else?
No response
Which @angular/* package(s) are the source of the bug?
Don't known / other
Is this a regression?
No
Description
Report description
Angular production mode allows
[attr.onclick]and hostattr.onclickbindings to become click-triggered XSSThe problem
Please describe the technical details of the vulnerability
1. technical details
Angular already has explicit validation that rejects
on*attribute and property bindings:However, the main R3 template path explicitly bypasses that validation for bound element properties and attributes:
Host bindings have a similar gap.
createBoundHostProperties()parses host properties, but does not reuse the same validation path:The remaining runtime protection is only enforced in
ngDevMode. In production mode, Angular writes the attribute directly into the DOM:As a result, if an application binds attacker-controlled data to
[attr.onclick]in a template, or to@HostBinding('attr.onclick')/host: {'[attr.onclick]': ...}, production builds can emit a real inline event handler attribute. When the victim clicks the element, the browser executes attacker-controlled JavaScript in the page origin.2. vulnerability reproduction
The attached browser PoC uses a real Angular template with a realistic application pattern: campaign data from a CMS is rendered into a marketing CTA button.
The PoC verifies that development mode blocks the binding:
The same PoC then switches to production mode and shows that Angular renders the dangerous attribute and the browser executes it on click:
The same test suite also models a benign trusted CMS handler string to show that the affected code path is a realistic application pattern:
The browser PoC is available in
./work/angular/r3_template_transform_xss/realistic_attr_onclick_spec.tsand was executed with:bazelisk test //r3_template_transform_xss:test_web --test_output=errorsObserved result:
This demonstrates a practical path from attacker-controlled application data to a real inline
onclickattribute and browser-executed JavaScript when the user clicks the element.3. patch
Impact analysis – Please briefly explain who can exploit the vulnerability, and what they gain when doing so
An attacker who can influence application data that is bound to
[attr.onclick]or to hostattr.onclickbindings can exploit this issue against users of a production Angular application.Successful exploitation causes the browser to execute attacker-controlled JavaScript when the victim clicks the affected element, giving the attacker same-origin DOM XSS in the application page.
r3_template_transform_xss.tar.gz
Please provide a link to a minimal reproduction of the bug
No response
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)Anything else?
No response