Skip to content

Commit 1c6553e

Browse files
alan-agius4mattrbeck
authored andcommitted
fix(core): disallow event attribute bindings in host bindings unconditionally
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. (cherry picked from commit 5b421c6)
1 parent c39f770 commit 1c6553e

8 files changed

Lines changed: 133 additions & 29 deletions

File tree

packages/compiler-cli/src/ngtsc/typecheck/src/dom.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,21 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker {
156156
schemas: SchemaMetadata[],
157157
hostIsStandalone: boolean,
158158
): void {
159+
const report = REGISTRY.validateProperty(name);
160+
if (report.error) {
161+
const mapping = this.resolver.getTemplateSourceMapping(id);
162+
const diag = makeTemplateDiagnostic(
163+
id,
164+
mapping,
165+
span,
166+
ts.DiagnosticCategory.Error,
167+
ngErrorCode(ErrorCode.SCHEMA_INVALID_ATTRIBUTE),
168+
report.msg!,
169+
);
170+
this._diagnostics.push(diag);
171+
return;
172+
}
173+
159174
if (!REGISTRY.hasProperty(tagName, name, schemas)) {
160175
const mapping = this.resolver.getTemplateSourceMapping(id);
161176

@@ -198,6 +213,21 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker {
198213
span: ParseSourceSpan,
199214
schemas: SchemaMetadata[],
200215
): void {
216+
const report = REGISTRY.validateProperty(name);
217+
if (report.error) {
218+
const mapping = this.resolver.getHostBindingsMapping(id);
219+
const diag = makeTemplateDiagnostic(
220+
id,
221+
mapping,
222+
span,
223+
ts.DiagnosticCategory.Error,
224+
ngErrorCode(ErrorCode.SCHEMA_INVALID_ATTRIBUTE),
225+
report.msg!,
226+
);
227+
this._diagnostics.push(diag);
228+
return;
229+
}
230+
201231
for (const tagName of element.tagNames) {
202232
if (REGISTRY.hasProperty(tagName, name, schemas)) {
203233
continue;

packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,21 @@ runInEachFileSystem(() => {
187187
]);
188188
});
189189

190+
it('should disallow binding to event properties starting with on', () => {
191+
const messages = diagnose(
192+
`<div [onclick]="handler"></div>`,
193+
`
194+
class TestComponent {
195+
handler: any;
196+
}`,
197+
);
198+
199+
expect(messages).toEqual([
200+
`TestComponent.html(1, 6): Binding to event property 'onclick' is disallowed for security reasons, please use (click)=...
201+
If 'onclick' is a directive input, make sure the directive is imported by the current module.`,
202+
]);
203+
});
204+
190205
it('checks text attributes that are consumed by bindings with literal string types', () => {
191206
const messages = diagnose(
192207
`<div dir mode="drak"></div><div dir mode="light"></div>`,

packages/compiler/src/jit_compiler_facade.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -864,12 +864,6 @@ function extractHostBindings(
864864
// First parse the declarations from the metadata.
865865
const bindings = parseHostBindings(host || {});
866866

867-
// After that check host bindings for errors
868-
const errors = verifyHostBindings(bindings, sourceSpan);
869-
if (errors.length) {
870-
throw new Error(errors.map((error: ParseError) => error.msg).join('\n'));
871-
}
872-
873867
// Next, loop over the properties of the object, looking for @HostBinding and @HostListener.
874868
for (const field in propMetadata) {
875869
if (propMetadata.hasOwnProperty(field)) {
@@ -889,6 +883,12 @@ function extractHostBindings(
889883
}
890884
}
891885

886+
// After that check host bindings for errors
887+
const errors = verifyHostBindings(bindings, sourceSpan);
888+
if (errors.length) {
889+
throw new Error(errors.map((error: ParseError) => error.msg).join('\n'));
890+
}
891+
892892
return bindings;
893893
}
894894

packages/compiler/src/render3/r3_template_transform.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,10 +591,11 @@ class HtmlAstToIvyAst implements html.Visitor {
591591
// Note that validation is skipped and property mapping is disabled
592592
// due to the fact that we need to make sure a given prop is not an
593593
// input of a directive and directive matching happens at runtime.
594+
const isAttrOn = prop.name.toLowerCase().startsWith('attr.on');
594595
const bep = this.bindingParser.createBoundElementProperty(
595596
elementName,
596597
prop,
597-
/* skipValidation */ true,
598+
/* skipValidation */ !isAttrOn,
598599
/* mapPropertyName */ false,
599600
);
600601
bound.push(t.BoundAttribute.fromBoundElementProperty(bep, i18n));

packages/compiler/src/render3/view/compiler.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,9 +618,41 @@ export function verifyHostBindings(
618618
const bindingParser = makeBindingParser();
619619
bindingParser.createDirectiveHostEventAsts(bindings.listeners, sourceSpan);
620620
bindingParser.createBoundHostProperties(bindings.properties, sourceSpan);
621+
622+
validateNoEventBindings(bindings, bindingParser, sourceSpan);
623+
621624
return bindingParser.errors;
622625
}
623626

627+
/**
628+
* Validates that there are no event attribute bindings in the host bindings.
629+
* @param bindings - Map of host bindings for the component.
630+
* @param bindingParser - Binding parser used to create the binding expression.
631+
* @param sourceSpan - Source span where the host bindings were defined.
632+
*/
633+
function validateNoEventBindings(
634+
bindings: ParsedHostBindings,
635+
bindingParser: BindingParser,
636+
sourceSpan: ParseSourceSpan,
637+
): void {
638+
for (const prop in bindings.properties) {
639+
const isAttr = prop.startsWith('attr.');
640+
const boundName = isAttr ? prop.slice(5) : prop;
641+
642+
if (boundName.toLowerCase().startsWith('on')) {
643+
const errorType = isAttr ? 'attribute' : 'property';
644+
const suggestion = `(${boundName.slice(2)})=...`;
645+
646+
let msg = `Binding to event ${errorType} '${boundName}' is disallowed for security reasons, please use ${suggestion}`;
647+
if (!isAttr) {
648+
msg += `\nIf '${prop}' is a directive input, make sure the directive is imported by the current module.`;
649+
}
650+
651+
bindingParser.errors.push(new ParseError(sourceSpan, msg));
652+
}
653+
}
654+
}
655+
624656
function compileStyles(styles: string[], selector: string, hostSelector: string): string[] {
625657
const shadowCss = new ShadowCss();
626658
return styles.map((style) => {

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ import {hasSkipHydrationAttrOnRElement} from '../../hydration/skip_hydration';
1212
import {PRESERVE_HOST_CONTENT, PRESERVE_HOST_CONTENT_DEFAULT} from '../../hydration/tokens';
1313
import {processTextNodeMarkersBeforeHydration} from '../../hydration/utils';
1414
import {ViewEncapsulation} from '../../metadata/view';
15-
import {
16-
validateAgainstEventAttributes,
17-
validateAgainstEventProperties,
18-
} from '../../sanitization/sanitization';
15+
import {validateAgainstEventProperties} from '../../sanitization/sanitization';
16+
1917
import {assertIndexInRange, assertNotSame} from '../../util/assert';
2018
import {escapeCommentText} from '../../util/dom';
2119
import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../ng_reflect';
@@ -296,7 +294,9 @@ export function setDomProperty<T>(
296294
const element = getNativeByTNode(tNode, lView) as RElement | RComment;
297295

298296
if (ngDevMode) {
299-
validateAgainstEventProperties(propName);
297+
if (lView[TVIEW].firstUpdatePass) {
298+
validateAgainstEventProperties(propName);
299+
}
300300
if (!isPropertyValid(element, propName, tNode.value, lView[TVIEW].schemas)) {
301301
handleUnknownPropertyError(propName, tNode.value, tNode.type, lView);
302302
}
@@ -506,14 +506,14 @@ export function elementAttributeInternal(
506506
) {
507507
if (ngDevMode) {
508508
assertNotSame(value, NO_CHANGE as any, 'Incoming value should never be NO_CHANGE.');
509-
validateAgainstEventAttributes(name);
510509
assertTNodeType(
511510
tNode,
512511
TNodeType.Element,
513512
`Attempted to set attribute \`${name}\` on a container node. ` +
514513
`Host bindings are not valid on ng-container or ng-template.`,
515514
);
516515
}
516+
517517
const element = getNativeByTNode(tNode, lView) as RElement;
518518
setElementAttribute(lView[RENDERER], element, namespace, tNode.value, name, value, sanitizer);
519519
}

packages/core/src/sanitization/sanitization.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -268,15 +268,6 @@ export function validateAgainstEventProperties(name: string) {
268268
}
269269
}
270270

271-
export function validateAgainstEventAttributes(name: string) {
272-
if (name.toLowerCase().startsWith('on')) {
273-
const errorMessage =
274-
`Binding to event attribute '${name}' is disallowed for security reasons, ` +
275-
`please use (${name.slice(2)})=...`;
276-
throw new RuntimeError(RuntimeErrorCode.INVALID_EVENT_BINDING, errorMessage);
277-
}
278-
}
279-
280271
function getSanitizer(): Sanitizer | null {
281272
const lView = getLView();
282273
return lView && lView[ENVIRONMENT].sanitizer;

packages/core/test/linker/security_integration_spec.ts

Lines changed: 42 additions & 7 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
@@ -48,8 +46,7 @@ describe('security integration tests', function () {
4846
TestBed.overrideComponent(SecuredComponent, {set: {template}});
4947

5048
expect(() => {
51-
const cmp = TestBed.createComponent(SecuredComponent);
52-
cmp.detectChanges();
49+
TestBed.createComponent(SecuredComponent);
5350
}).toThrowError(
5451
/Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
5552
);
@@ -89,6 +86,44 @@ describe('security integration tests', function () {
8986
expect(div.nativeElement.onclick).not.toBe(value);
9087
expect(div.nativeElement.hasAttribute('onclick')).toEqual(false);
9188
});
89+
90+
for (const ngDevModeValue of [true, false]) {
91+
it(`should disallow binding to attr.on* in host bindings with ngDevMode=${ngDevModeValue}`, () => {
92+
const originalNgDevMode = (globalThis as any).ngDevMode;
93+
(globalThis as any).ngDevMode = ngDevModeValue;
94+
95+
@Directive({
96+
selector: '[dirOnclick]',
97+
standalone: false,
98+
})
99+
class LocalHostOnclickDirective {
100+
@HostBinding('attr.onclick') @Input() dirOnclick: string | undefined;
101+
}
102+
103+
@Component({
104+
selector: 'local-comp',
105+
template: `<button [dirOnclick]="ctxProp"></button>`,
106+
standalone: false,
107+
})
108+
class LocalSecuredComponent {
109+
ctxProp: any = 'some value';
110+
}
111+
112+
try {
113+
TestBed.configureTestingModule({
114+
declarations: [LocalSecuredComponent, LocalHostOnclickDirective],
115+
});
116+
117+
expect(() => {
118+
TestBed.createComponent(LocalSecuredComponent);
119+
}).toThrowError(
120+
/Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
121+
);
122+
} finally {
123+
(globalThis as any).ngDevMode = originalNgDevMode;
124+
}
125+
});
126+
}
92127
});
93128

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

0 commit comments

Comments
 (0)