Skip to content

Commit 6f52524

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 aa18596 commit 6f52524

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
@@ -86,6 +86,21 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker<TemplateDiagno
8686
schemas: SchemaMetadata[],
8787
hostIsStandalone: boolean,
8888
): void {
89+
const report = REGISTRY.validateProperty(name);
90+
if (report.error) {
91+
const mapping = this.resolver.getTemplateSourceMapping(id);
92+
const diag = makeTemplateDiagnostic(
93+
id,
94+
mapping,
95+
span,
96+
ts.DiagnosticCategory.Error,
97+
ngErrorCode(ErrorCode.SCHEMA_INVALID_ATTRIBUTE),
98+
report.msg!,
99+
);
100+
this._diagnostics.push(diag);
101+
return;
102+
}
103+
89104
if (!REGISTRY.hasProperty(tagName, name, schemas)) {
90105
const mapping = this.resolver.getTemplateSourceMapping(id);
91106

@@ -128,6 +143,21 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker<TemplateDiagno
128143
span: ParseSourceSpan,
129144
schemas: SchemaMetadata[],
130145
): void {
146+
const report = REGISTRY.validateProperty(name);
147+
if (report.error) {
148+
const mapping = this.resolver.getHostBindingsMapping(id);
149+
const diag = makeTemplateDiagnostic(
150+
id,
151+
mapping,
152+
span,
153+
ts.DiagnosticCategory.Error,
154+
ngErrorCode(ErrorCode.SCHEMA_INVALID_ATTRIBUTE),
155+
report.msg!,
156+
);
157+
this._diagnostics.push(diag);
158+
return;
159+
}
160+
131161
for (const tagName of element.tagNames) {
132162
if (REGISTRY.hasProperty(tagName, name, schemas)) {
133163
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
@@ -908,12 +908,6 @@ function extractHostBindings(
908908
// First parse the declarations from the metadata.
909909
const bindings = parseHostBindings(host || {});
910910

911-
// After that check host bindings for errors
912-
const errors = verifyHostBindings(bindings, sourceSpan);
913-
if (errors.length) {
914-
throw new Error(errors.map((error: ParseError) => error.msg).join('\n'));
915-
}
916-
917911
// Next, loop over the properties of the object, looking for @HostBinding and @HostListener.
918912
for (const field in propMetadata) {
919913
if (propMetadata.hasOwnProperty(field)) {
@@ -933,6 +927,12 @@ function extractHostBindings(
933927
}
934928
}
935929

930+
// After that check host bindings for errors
931+
const errors = verifyHostBindings(bindings, sourceSpan);
932+
if (errors.length) {
933+
throw new Error(errors.map((error: ParseError) => error.msg).join('\n'));
934+
}
935+
936936
return bindings;
937937
}
938938

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
@@ -588,9 +588,41 @@ export function verifyHostBindings(
588588
const bindingParser = makeBindingParser();
589589
bindingParser.createDirectiveHostEventAsts(bindings.listeners, sourceSpan);
590590
bindingParser.createBoundHostProperties(bindings.properties, sourceSpan);
591+
592+
validateNoEventBindings(bindings, bindingParser, sourceSpan);
593+
591594
return bindingParser.errors;
592595
}
593596

597+
/**
598+
* Validates that there are no event attribute bindings in the host bindings.
599+
* @param bindings - Map of host bindings for the component.
600+
* @param bindingParser - Binding parser used to create the binding expression.
601+
* @param sourceSpan - Source span where the host bindings were defined.
602+
*/
603+
function validateNoEventBindings(
604+
bindings: ParsedHostBindings,
605+
bindingParser: BindingParser,
606+
sourceSpan: ParseSourceSpan,
607+
): void {
608+
for (const prop in bindings.properties) {
609+
const isAttr = prop.startsWith('attr.');
610+
const boundName = isAttr ? prop.slice(5) : prop;
611+
612+
if (boundName.toLowerCase().startsWith('on')) {
613+
const errorType = isAttr ? 'attribute' : 'property';
614+
const suggestion = `(${boundName.slice(2)})=...`;
615+
616+
let msg = `Binding to event ${errorType} '${boundName}' is disallowed for security reasons, please use ${suggestion}`;
617+
if (!isAttr) {
618+
msg += `\nIf '${prop}' is a directive input, make sure the directive is imported by the current module.`;
619+
}
620+
621+
bindingParser.errors.push(new ParseError(sourceSpan, msg));
622+
}
623+
}
624+
}
625+
594626
function compileStyles(styles: string[], selector: string, hostSelector: string): string[] {
595627
const shadowCss = new ShadowCss();
596628
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
@@ -38,16 +38,14 @@ class OnPrefixDir {
3838

3939
describe('security integration tests', function () {
4040
beforeEach(() => {
41+
// Disable logging for these tests.
42+
spyOn(console, 'log').and.callFake(() => {});
43+
4144
TestBed.configureTestingModule({
4245
declarations: [SecuredComponent, OnPrefixDir],
4346
});
4447
});
4548

46-
beforeEach(() => {
47-
// Disable logging for these tests.
48-
spyOn(console, 'log').and.callFake(() => {});
49-
});
50-
5149
describe('events', () => {
5250
// this test is similar to the previous one, but since on-prefixed attributes validation now
5351
// happens at runtime, we need to invoke change detection to trigger elementProperty call
@@ -56,8 +54,7 @@ describe('security integration tests', function () {
5654
TestBed.overrideComponent(SecuredComponent, {set: {template}});
5755

5856
expect(() => {
59-
const cmp = TestBed.createComponent(SecuredComponent);
60-
cmp.detectChanges();
57+
TestBed.createComponent(SecuredComponent);
6158
}).toThrowError(
6259
/Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
6360
);
@@ -97,6 +94,44 @@ describe('security integration tests', function () {
9794
expect(div.nativeElement.onclick).not.toBe(value);
9895
expect(div.nativeElement.hasAttribute('onclick')).toEqual(false);
9996
});
97+
98+
for (const ngDevModeValue of [true, false]) {
99+
it(`should disallow binding to attr.on* in host bindings with ngDevMode=${ngDevModeValue}`, () => {
100+
const originalNgDevMode = (globalThis as any).ngDevMode;
101+
(globalThis as any).ngDevMode = ngDevModeValue;
102+
103+
@Directive({
104+
selector: '[dirOnclick]',
105+
standalone: false,
106+
})
107+
class LocalHostOnclickDirective {
108+
@HostBinding('attr.onclick') @Input() dirOnclick: string | undefined;
109+
}
110+
111+
@Component({
112+
selector: 'local-comp',
113+
template: `<button [dirOnclick]="ctxProp"></button>`,
114+
standalone: false,
115+
})
116+
class LocalSecuredComponent {
117+
ctxProp: any = 'some value';
118+
}
119+
120+
try {
121+
TestBed.configureTestingModule({
122+
declarations: [LocalSecuredComponent, LocalHostOnclickDirective],
123+
});
124+
125+
expect(() => {
126+
TestBed.createComponent(LocalSecuredComponent);
127+
}).toThrowError(
128+
/Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
129+
);
130+
} finally {
131+
(globalThis as any).ngDevMode = originalNgDevMode;
132+
}
133+
});
134+
}
100135
});
101136

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

0 commit comments

Comments
 (0)