Sitelet https://github.com/angular/angular/pull/68468/files
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker {
schemas: SchemaMetadata[],
hostIsStandalone: boolean,
): void {
const report = REGISTRY.validateProperty(name);
if (report.error) {
const mapping = this.resolver.getTemplateSourceMapping(id);
const diag = makeTemplateDiagnostic(
id,
mapping,
span,
ts.DiagnosticCategory.Error,
ngErrorCode(ErrorCode.SCHEMA_INVALID_ATTRIBUTE),
report.msg!,
);
this._diagnostics.push(diag);
return;
}

if (!REGISTRY.hasProperty(tagName, name, schemas)) {
const mapping = this.resolver.getTemplateSourceMapping(id);

Expand Down Expand Up @@ -198,6 +213,21 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker {
span: ParseSourceSpan,
schemas: SchemaMetadata[],
): void {
const report = REGISTRY.validateProperty(name);
if (report.error) {
const mapping = this.resolver.getHostBindingsMapping(id);
const diag = makeTemplateDiagnostic(
id,
mapping,
span,
ts.DiagnosticCategory.Error,
ngErrorCode(ErrorCode.SCHEMA_INVALID_ATTRIBUTE),
report.msg!,
);
this._diagnostics.push(diag);
return;
}

for (const tagName of element.tagNames) {
if (REGISTRY.hasProperty(tagName, name, schemas)) {
continue;
Expand Down
15 changes: 15 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,21 @@ runInEachFileSystem(() => {
]);
});

it('should disallow binding to event properties starting with on', () => {
const messages = diagnose(
`<div [onclick]="handler"></div>`,
`
class TestComponent {
handler: any;
}`,
);

expect(messages).toEqual([
`TestComponent.html(1, 6): Binding to event property 'onclick' is disallowed for security reasons, please use (click)=...
If 'onclick' is a directive input, make sure the directive is imported by the current module.`,
]);
});

it('checks text attributes that are consumed by bindings with literal string types', () => {
const messages = diagnose(
`<div dir mode="drak"></div><div dir mode="light"></div>`,
Expand Down
12 changes: 6 additions & 6 deletions packages/compiler/src/jit_compiler_facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,12 +878,6 @@ function extractHostBindings(
// First parse the declarations from the metadata.
const bindings = parseHostBindings(host || {});

// After that check host bindings for errors
const errors = verifyHostBindings(bindings, sourceSpan);
if (errors.length) {
throw new Error(errors.map((error: ParseError) => error.msg).join('\n'));
}

// Next, loop over the properties of the object, looking for @HostBinding and @HostListener.
for (const field in propMetadata) {
if (propMetadata.hasOwnProperty(field)) {
Expand All @@ -903,6 +897,12 @@ function extractHostBindings(
}
}

// After that check host bindings for errors
const errors = verifyHostBindings(bindings, sourceSpan);
if (errors.length) {
throw new Error(errors.map((error: ParseError) => error.msg).join('\n'));
}

return bindings;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/compiler/src/render3/r3_template_transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,10 +591,11 @@ class HtmlAstToIvyAst implements html.Visitor {
// Note that validation is skipped and property mapping is disabled
// due to the fact that we need to make sure a given prop is not an
// input of a directive and directive matching happens at runtime.
const isAttrOn = prop.name.toLowerCase().startsWith('attr.on');
const bep = this.bindingParser.createBoundElementProperty(
elementName,
prop,
/* skipValidation */ true,
/* skipValidation */ !isAttrOn,
/* mapPropertyName */ false,
);
bound.push(t.BoundAttribute.fromBoundElementProperty(bep, i18n));
Expand Down
32 changes: 32 additions & 0 deletions packages/compiler/src/render3/view/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,9 +627,41 @@ export function verifyHostBindings(
const bindingParser = makeBindingParser();
bindingParser.createDirectiveHostEventAsts(bindings.listeners, sourceSpan);
bindingParser.createBoundHostProperties(bindings.properties, sourceSpan);

validateNoEventBindings(bindings, bindingParser, sourceSpan);

return bindingParser.errors;
}

/**
* Validates that there are no event attribute bindings in the host bindings.
* @param bindings - Map of host bindings for the component.
* @param bindingParser - Binding parser used to create the binding expression.
* @param sourceSpan - Source span where the host bindings were defined.
*/
function validateNoEventBindings(
bindings: ParsedHostBindings,
bindingParser: BindingParser,
sourceSpan: ParseSourceSpan,
): void {
for (const prop in bindings.properties) {
const isAttr = prop.startsWith('attr.');
const boundName = isAttr ? prop.slice(5) : prop;

if (boundName.toLowerCase().startsWith('on')) {
const errorType = isAttr ? 'attribute' : 'property';
const suggestion = `(${boundName.slice(2)})=...`;

let msg = `Binding to event ${errorType} '${boundName}' is disallowed for security reasons, please use ${suggestion}`;
if (!isAttr) {
msg += `\nIf '${prop}' is a directive input, make sure the directive is imported by the current module.`;
}

bindingParser.errors.push(new ParseError(sourceSpan, msg));
}
}
}

function compileStyles(styles: string[], selector: string, hostSelector: string): string[] {
const shadowCss = new ShadowCss();
return styles.map((style) => {
Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/render3/i18n/i18n_parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import {
} from '../../sanitization/html_sanitizer';
import {getInertBodyHelper} from '../../sanitization/inert_body';
import {_sanitizeUrl} from '../../sanitization/url_sanitizer';
import {
ɵɵvalidateAttribute as _validateAttribute,
SECURITY_SENSITIVE_ELEMENTS,
} from '../../sanitization/sanitization';
import {
assertDefined,
assertEqual,
Expand Down Expand Up @@ -388,7 +392,7 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str
previousElementIndex,
attrName,
countBindings(updateOpCodes),
SENSITIVE_ATTRS[attrName.toLowerCase()] ? _sanitizeUrl : null,
i18nSanitizeAttribute(attrName),
);
}
}
Expand Down Expand Up @@ -816,7 +820,7 @@ function walkIcuTree(
newIndex,
attr.name,
0,
SENSITIVE_ATTRS[lowerAttrName] ? _sanitizeUrl : null,
i18nSanitizeAttribute(lowerAttrName),
);
} else {
ngDevMode &&
Expand Down Expand Up @@ -969,3 +973,31 @@ function addCreateAttribute(
) {
create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue);
}

/**
* Caches all keys of `SECURITY_SENSITIVE_ELEMENTS` in a Set to avoid recomputing
* or scanning them on every invocation.
*/
const SECURITY_SENSITIVE_ATTRS: ReadonlySet<string> = /* @__PURE__ */ (() =>
new Set(
Object.values(SECURITY_SENSITIVE_ELEMENTS).flatMap((attrs) => (attrs ? [...attrs.keys()] : [])),
))();

/**
* Returns a sanitizer for the given attribute name or null if the attribute is not security sensitive.
*
* @param attrName The name of the attribute to sanitize.
* @returns The sanitizer for the given attribute name.
*/
function i18nSanitizeAttribute(attrName: string): SanitizerFn | null {
const lowerAttrName = attrName.toLowerCase();
if (SENSITIVE_ATTRS[lowerAttrName]) {
return _sanitizeUrl;
}

if (SECURITY_SENSITIVE_ATTRS.has(lowerAttrName)) {
return <SanitizerFn>_validateAttribute;
}

return null;
}
12 changes: 6 additions & 6 deletions packages/core/src/render3/instructions/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ import {hasSkipHydrationAttrOnRElement} from '../../hydration/skip_hydration';
import {PRESERVE_HOST_CONTENT, PRESERVE_HOST_CONTENT_DEFAULT} from '../../hydration/tokens';
import {processTextNodeMarkersBeforeHydration} from '../../hydration/utils';
import {ViewEncapsulation} from '../../metadata/view';
import {
validateAgainstEventAttributes,
validateAgainstEventProperties,
} from '../../sanitization/sanitization';
import {validateAgainstEventProperties} from '../../sanitization/sanitization';

import {assertIndexInRange, assertNotSame} from '../../util/assert';
import {escapeCommentText} from '../../util/dom';
import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../ng_reflect';
Expand Down Expand Up @@ -293,7 +291,9 @@ export function setDomProperty<T>(
const element = getNativeByTNode(tNode, lView) as RElement | RComment;

if (ngDevMode) {
validateAgainstEventProperties(propName);
if (lView[TVIEW].firstUpdatePass) {
validateAgainstEventProperties(propName);
}
if (!isPropertyValid(element, propName, tNode.value, lView[TVIEW].schemas)) {
handleUnknownPropertyError(propName, tNode.value, tNode.type, lView);
}
Expand Down Expand Up @@ -503,14 +503,14 @@ export function elementAttributeInternal(
) {
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);
}
Expand Down
17 changes: 2 additions & 15 deletions packages/core/src/sanitization/sanitization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,6 @@ export function validateAgainstEventProperties(name: string) {
}
}

export function validateAgainstEventAttributes(name: string) {
if (name.toLowerCase().startsWith('on')) {
const errorMessage =
`Binding to event attribute '${name}' is disallowed for security reasons, ` +
`please use (${name.slice(2)})=...`;
throw new RuntimeError(RuntimeErrorCode.INVALID_EVENT_BINDING, errorMessage);
}
}

function getSanitizer(): Sanitizer | null {
const lView = getLView();
return lView && lView[ENVIRONMENT].sanitizer;
Expand All @@ -283,7 +274,7 @@ const attributeName: ReadonlySet<string> = new Set(['attributename']);
* @remarks Keep this in sync with DOM Security Schema.
* @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts)
*/
const SECURITY_SENSITIVE_ELEMENTS: Readonly<Record<string, ReadonlySet<string>>> = {
export const SECURITY_SENSITIVE_ELEMENTS: Readonly<Record<string, ReadonlySet<string>>> = {
'iframe': new Set([
'sandbox',
'allow',
Expand All @@ -305,11 +296,7 @@ const SECURITY_SENSITIVE_ELEMENTS: Readonly<Record<string, ReadonlySet<string>>>
* @param tagName The name of the tag.
* @param attributeName The name of the attribute.
*/
export function ɵɵvalidateAttribute(
value: unknown,
tagName: string,
attributeName: string,
): unknown {
export function ɵɵvalidateAttribute<T = any>(value: T, tagName: string, attributeName: string): T {
const lowerCaseTagName = tagName.toLowerCase();
const lowerCaseAttrName = attributeName.toLowerCase();
if (!SECURITY_SENSITIVE_ELEMENTS[lowerCaseTagName]?.has(lowerCaseAttrName)) {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/test/acceptance/security_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,25 @@ describe('iframe processing', () => {
});
});

it('should error when a translated security-sensitive attribute contains bindings', () => {
@Component({
selector: 'my-comp',
template: `
<iframe
src="${TEST_IFRAME_URL}"
i18n-sandbox
sandbox="allow-forms {{ extraPrivileges }}"
>
</iframe>
`,
})
class IframeComp {
extraPrivileges = 'allow-scripts allow-same-origin';
}

expectIframeCreationToFail(IframeComp);
});

it('should work when a directive sets a security-sensitive attribute as a static attribute', () => {
@Directive({
selector: '[dir]',
Expand Down
49 changes: 42 additions & 7 deletions packages/core/test/linker/security_integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,14 @@ class OnPrefixDir {

describe('security integration tests', function () {
beforeEach(() => {
// Disable logging for these tests.
spyOn(console, 'log').and.callFake(() => {});

TestBed.configureTestingModule({
declarations: [SecuredComponent, OnPrefixDir],
});
});

beforeEach(() => {
// Disable logging for these tests.
spyOn(console, 'log').and.callFake(() => {});
});

describe('events', () => {
// this test is similar to the previous one, but since on-prefixed attributes validation now
// happens at runtime, we need to invoke change detection to trigger elementProperty call
Expand All @@ -48,8 +46,7 @@ describe('security integration tests', function () {
TestBed.overrideComponent(SecuredComponent, {set: {template}});

expect(() => {
const cmp = TestBed.createComponent(SecuredComponent);
cmp.detectChanges();
TestBed.createComponent(SecuredComponent);
}).toThrowError(
/Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
);
Expand Down Expand Up @@ -89,6 +86,44 @@ describe('security integration tests', function () {
expect(div.nativeElement.onclick).not.toBe(value);
expect(div.nativeElement.hasAttribute('onclick')).toEqual(false);
});

for (const ngDevModeValue of [true, false]) {
it(`should disallow binding to attr.on* in host bindings with ngDevMode=${ngDevModeValue}`, () => {
const originalNgDevMode = (globalThis as any).ngDevMode;
(globalThis as any).ngDevMode = ngDevModeValue;

@Directive({
selector: '[dirOnclick]',
standalone: false,
})
class LocalHostOnclickDirective {
@HostBinding('attr.onclick') @Input() dirOnclick: string | undefined;
}

@Component({
selector: 'local-comp',
template: `<button [dirOnclick]="ctxProp"></button>`,
standalone: false,
})
class LocalSecuredComponent {
ctxProp: any = 'some value';
}

try {
TestBed.configureTestingModule({
declarations: [LocalSecuredComponent, LocalHostOnclickDirective],
});

expect(() => {
TestBed.createComponent(LocalSecuredComponent);
}).toThrowError(
/Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../,
);
} finally {
(globalThis as any).ngDevMode = originalNgDevMode;
}
});
}
});

describe('safe HTML values', function () {
Expand Down
Loading
Loading