Skip to content

Commit 83a6405

Browse files
alan-agius4mattrbeck
authored andcommitted
fix(core): disallow event attribute bindings in host bindings unconditionally (#68469)
Moves the event attribute validation check outside of `ngDevMode` in the `elementAttributeInternal` instruction to ensure that bindings to event attributes like `on*` are always blocked at runtime. Previously, this check was only performed when `ngDevMode` was `true`, which could allow attacker-controlled CMS data to be bound to event attributes in production mode, causing browser-executed XSS. Fixes #68419 PR Close #68469
1 parent 24a0103 commit 83a6405

4 files changed

Lines changed: 55 additions & 17 deletions

File tree

packages/core/src/render3/i18n/i18n_parse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -996,7 +996,7 @@ function i18nSanitizeAttribute(attrName: string): SanitizerFn | null {
996996
}
997997

998998
if (SECURITY_SENSITIVE_ATTRS.has(lowerAttrName)) {
999-
return _validateAttribute;
999+
return <SanitizerFn>_validateAttribute;
10001000
}
10011001

10021002
return null;

packages/core/src/render3/instructions/shared.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
LViewFlags,
5050
RENDERER,
5151
TData,
52+
TVIEW,
5253
TView,
5354
} from '../interfaces/view';
5455
import {assertTNodeType} from '../node_assert';
@@ -469,14 +470,18 @@ export function elementAttributeInternal(
469470
) {
470471
if (ngDevMode) {
471472
assertNotSame(value, NO_CHANGE as any, 'Incoming value should never be NO_CHANGE.');
472-
validateAgainstEventAttributes(name);
473473
assertTNodeType(
474474
tNode,
475475
TNodeType.Element,
476476
`Attempted to set attribute \`${name}\` on a container node. ` +
477477
`Host bindings are not valid on ng-container or ng-template.`,
478478
);
479479
}
480+
481+
if (lView[TVIEW].firstUpdatePass) {
482+
validateAgainstEventAttributes(name);
483+
}
484+
480485
const element = getNativeByTNode(tNode, lView) as RElement;
481486
setElementAttribute(lView[RENDERER], element, namespace, tNode.value, name, value, sanitizer);
482487
}

packages/core/src/sanitization/sanitization.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,12 @@ export function validateAgainstEventProperties(name: string) {
265265

266266
export function validateAgainstEventAttributes(name: string) {
267267
if (name.toLowerCase().startsWith('on')) {
268-
const errorMessage =
269-
`Binding to event attribute '${name}' is disallowed for security reasons, ` +
270-
`please use (${name.slice(2)})=...`;
271-
throw new RuntimeError(RuntimeErrorCode.INVALID_EVENT_BINDING, errorMessage);
268+
throw new RuntimeError(
269+
RuntimeErrorCode.INVALID_EVENT_BINDING,
270+
ngDevMode &&
271+
`Binding to event attribute '${name}' is disallowed for security reasons, ` +
272+
`please use (${name.slice(2)})=...`,
273+
);
272274
}
273275
}
274276

@@ -283,7 +285,7 @@ const attributeName: ReadonlySet<string> = new Set(['attributename']);
283285
* @remarks Keep this in sync with DOM Security Schema.
284286
* @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts)
285287
*/
286-
const SECURITY_SENSITIVE_ELEMENTS: Readonly<Record<string, ReadonlySet<string>>> = {
288+
export const SECURITY_SENSITIVE_ELEMENTS: Readonly<Record<string, ReadonlySet<string>>> = {
287289
'iframe': new Set([
288290
'sandbox',
289291
'allow',
@@ -305,11 +307,7 @@ const SECURITY_SENSITIVE_ELEMENTS: Readonly<Record<string, ReadonlySet<string>>>
305307
* @param tagName The name of the tag.
306308
* @param attributeName The name of the attribute.
307309
*/
308-
export function ɵɵvalidateAttribute(
309-
value: unknown,
310-
tagName: string,
311-
attributeName: string,
312-
): unknown {
310+
export function ɵɵvalidateAttribute<T = any>(value: T, tagName: string, attributeName: string): T {
313311
const lowerCaseTagName = tagName.toLowerCase();
314312
const lowerCaseAttrName = attributeName.toLowerCase();
315313
if (!SECURITY_SENSITIVE_ELEMENTS[lowerCaseTagName]?.has(lowerCaseAttrName)) {

packages/core/test/linker/security_integration_spec.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,14 @@ class OnPrefixDir {
3030

3131
describe('security integration tests', function () {
3232
beforeEach(() => {
33+
// Disable logging for these tests.
34+
spyOn(console, 'log').and.callFake(() => {});
35+
3336
TestBed.configureTestingModule({
3437
declarations: [SecuredComponent, OnPrefixDir],
3538
});
3639
});
3740

38-
beforeEach(() => {
39-
// Disable logging for these tests.
40-
spyOn(console, 'log').and.callFake(() => {});
41-
});
42-
4341
describe('events', () => {
4442
// this test is similar to the previous one, but since on-prefixed attributes validation now
4543
// happens at runtime, we need to invoke change detection to trigger elementProperty call
@@ -89,6 +87,43 @@ describe('security integration tests', function () {
8987
expect(div.nativeElement.onclick).not.toBe(value);
9088
expect(div.nativeElement.hasAttribute('onclick')).toEqual(false);
9189
});
90+
91+
for (const ngDevModeValue of [true, false]) {
92+
it(`should disallow binding to attr.on* in host bindings with ngDevMode=${ngDevModeValue}`, () => {
93+
const originalNgDevMode = (globalThis as any).ngDevMode;
94+
(globalThis as any).ngDevMode = ngDevModeValue;
95+
96+
@Directive({
97+
selector: '[dirOnclick]',
98+
standalone: false,
99+
})
100+
class LocalHostOnclickDirective {
101+
@HostBinding('attr.onclick') @Input() dirOnclick: string | undefined;
102+
}
103+
104+
@Component({
105+
selector: 'local-comp',
106+
template: `<button [dirOnclick]="ctxProp"></button>`,
107+
standalone: false,
108+
})
109+
class LocalSecuredComponent {
110+
ctxProp: any = 'some value';
111+
}
112+
113+
try {
114+
TestBed.configureTestingModule({
115+
declarations: [LocalSecuredComponent, LocalHostOnclickDirective],
116+
});
117+
118+
expect(() => {
119+
const cmp = TestBed.createComponent(LocalSecuredComponent);
120+
cmp.detectChanges();
121+
}).toThrowError(/NG0306/);
122+
} finally {
123+
(globalThis as any).ngDevMode = originalNgDevMode;
124+
}
125+
});
126+
}
92127
});
93128

94129
describe('safe HTML values', function () {

0 commit comments

Comments
 (0)