From 39e76a2572fe669d558a4b770db13497f291c3ea Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 16 Mar 2023 13:59:13 +0100 Subject: [PATCH] feat(core): add API to provide CSP nonce for inline stylesheets Angular uses inline styles to insert the styles associated with a component. This violates the strict styles [Content Security Policy](https://web.dev/strict-csp/) which doesn't allow inline styles by default. One way to allow the styles to be applied is to set a `nonce` attribute on them, but because the code for inserting the stylesheets is deep inside the framework, users weren't able to provide it without accessing private APIs. These changes add a new `CSP_NONCE` injection token that will allow users to provide a nonce, if their app is using CSP. If the token isn't provided, the framework will look for an `ngCspNonce` attribute on the app's root node instead. The latter approach is provided as a convenience for apps that render the `index.html` through a server, e.g. ``. This PR addresses adding the nonce to framework-generated styles. There will be follow-up PRs that add support for it in critical CSS tags in the CLI, and in Angular Material. Fixes #6361. --- goldens/public-api/core/index.md | 3 + packages/core/src/application_tokens.ts | 33 ++++ packages/core/src/core.ts | 2 +- packages/core/test/acceptance/csp_spec.ts | 150 ++++++++++++++++++ .../animations/bundle.golden_symbols.json | 6 + .../cyclic_import/bundle.golden_symbols.json | 6 + .../forms_reactive/bundle.golden_symbols.json | 6 + .../bundle.golden_symbols.json | 6 + .../router/bundle.golden_symbols.json | 6 + .../bundle.golden_symbols.json | 6 + .../bundling/todo/bundle.golden_symbols.json | 6 + .../platform-browser/src/dom/dom_renderer.ts | 13 +- .../src/dom/shared_styles_host.ts | 10 +- packages/platform-server/src/styles_host.ts | 19 ++- 14 files changed, 261 insertions(+), 11 deletions(-) create mode 100644 packages/core/test/acceptance/csp_spec.ts diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index f9a7133b987db3..5c5bc8a89c8935 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -345,6 +345,9 @@ export function createPlatform(injector: Injector): PlatformRef; // @public export function createPlatformFactory(parentPlatformFactory: ((extraProviders?: StaticProvider[]) => PlatformRef) | null, name: string, providers?: StaticProvider[]): (extraProviders?: StaticProvider[]) => PlatformRef; +// @public +export const CSP_NONCE: InjectionToken; + // @public export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata; diff --git a/packages/core/src/application_tokens.ts b/packages/core/src/application_tokens.ts index 3ca99210cb5625..a430c45fd6fa86 100644 --- a/packages/core/src/application_tokens.ts +++ b/packages/core/src/application_tokens.ts @@ -7,6 +7,7 @@ */ import {InjectionToken} from './di/injection_token'; +import {getDocument} from './render3/interfaces/document'; /** * A [DI token](guide/glossary#di-token "DI token definition") representing a string ID, used @@ -79,3 +80,35 @@ export const PACKAGE_ROOT_URL = new InjectionToken('Application Packages */ export const ANIMATION_MODULE_TYPE = new InjectionToken<'NoopAnimations'|'BrowserAnimations'>('AnimationModuleType'); + +// TODO(crisbeto): link to CSP guide here. +/** + * Token used to configure the [Content Security Policy](https://web.dev/strict-csp/) nonce that + * Angular will apply when inserting inline styles. If not provided, Angular will look up its value + * from the `ngCspNonce` attribute of the application root node. + * + * @publicApi + */ +export const CSP_NONCE = new InjectionToken('CSP nonce', { + providedIn: 'root', + factory: () => { + // Ideally we wouldn't have to use `querySelector` here since we know that the nonce will be on + // the root node, but because the token value is used in renderers, it has to be available + // *very* early in the bootstrapping process. This should be a fairly shallow search, because + // the app won't have been added to the DOM yet. Some approaches that were considered: + // 1. Find the root node through `ApplicationRef.components[i].location` - normally this would + // be enough for our purposes, but the token is injected very early so the `components` array + // isn't populated yet. + // 2. Find the root `LView` through the current `LView` - renderers are a prerequisite to + // creating the `LView`. This means that no `LView` will have been entered when this factory is + // invoked for the root component. + // 3. Have the token factory return `() => string` which is invoked when a nonce is requested - + // the slightly later execution does allow us to get an `LView` reference, but the fact that + // it is a function means that it could be executed at *any* time (including immediately) which + // may lead to weird bugs. + // 4. Have the `ComponentFactory` read the attribute and provide it to the injector under the + // hood - has the same problem as #1 and #2 in that the renderer is used to query for the root + // node and the nonce value needs to be available when the renderer is created. + return getDocument().body.querySelector('[ngCspNonce]')?.getAttribute('ngCspNonce') || null; + }, +}); diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 097b373fdb3222..4337edecd654fa 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -17,7 +17,7 @@ export {TypeDecorator} from './util/decorators'; export * from './di'; export {createPlatform, assertPlatform, destroyPlatform, getPlatform, BootstrapOptions, PlatformRef, ApplicationRef, createPlatformFactory, NgProbeToken, APP_BOOTSTRAP_LISTENER} from './application_ref'; export {enableProdMode, isDevMode} from './util/is_dev_mode'; -export {APP_ID, PACKAGE_ROOT_URL, PLATFORM_INITIALIZER, PLATFORM_ID, ANIMATION_MODULE_TYPE} from './application_tokens'; +export {APP_ID, PACKAGE_ROOT_URL, PLATFORM_INITIALIZER, PLATFORM_ID, ANIMATION_MODULE_TYPE, CSP_NONCE} from './application_tokens'; export {APP_INITIALIZER, ApplicationInitStatus} from './application_init'; export * from './zone'; export * from './render'; diff --git a/packages/core/test/acceptance/csp_spec.ts b/packages/core/test/acceptance/csp_spec.ts new file mode 100644 index 00000000000000..31c764fc2f491f --- /dev/null +++ b/packages/core/test/acceptance/csp_spec.ts @@ -0,0 +1,150 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, CSP_NONCE, destroyPlatform, ElementRef, inject, ViewEncapsulation} from '@angular/core'; +import {bootstrapApplication} from '@angular/platform-browser'; +import {withBody} from '@angular/private/testing'; + +describe('CSP integration', () => { + beforeEach(destroyPlatform); + afterEach(destroyPlatform); + + const testStyles = '.a { color: var(--csp-test-var, hotpink); }'; + + function findTestNonces(rootNode: ParentNode): string[] { + const styles = rootNode.querySelectorAll('style'); + const nonces: string[] = []; + + for (let i = 0; i < styles.length; i++) { + const style = styles[i]; + if (style.textContent?.includes('--csp-test-var') && style.nonce) { + nonces.push(style.nonce); + } + } + + return nonces; + } + + it('should use the predefined ngCspNonce when inserting styles with emulated encapsulation', + withBody('', async () => { + @Component({ + selector: 'uses-styles', + template: '', + styles: [testStyles], + standalone: true, + encapsulation: ViewEncapsulation.Emulated + }) + class UsesStyles { + } + + @Component({ + selector: 'app', + standalone: true, + template: '', + imports: [UsesStyles] + }) + class App { + } + + const appRef = await bootstrapApplication(App); + + expect(findTestNonces(document)).toEqual(['emulated-nonce']); + + appRef.destroy(); + })); + + it('should use the predefined ngCspNonce when inserting styles with no encapsulation', + withBody('', async () => { + @Component({ + selector: 'uses-styles', + template: '', + styles: [testStyles], + standalone: true, + encapsulation: ViewEncapsulation.None + }) + class UsesStyles { + } + + @Component({ + selector: 'app', + standalone: true, + template: '', + imports: [UsesStyles] + }) + class App { + } + + const appRef = await bootstrapApplication(App); + + expect(findTestNonces(document)).toEqual(['disabled-nonce']); + + appRef.destroy(); + })); + + + it('should use the predefined ngCspNonce when inserting styles with shadow DOM encapsulation', + withBody('', async () => { + if (!document.body.attachShadow) { + return; + } + + let usesStylesRootNode!: HTMLElement; + + @Component({ + selector: 'uses-styles', + template: '', + styles: [testStyles], + standalone: true, + encapsulation: ViewEncapsulation.ShadowDom + }) + class UsesStyles { + constructor() { + usesStylesRootNode = inject(ElementRef).nativeElement; + } + } + + @Component({ + selector: 'app', + standalone: true, + template: '', + imports: [UsesStyles] + }) + class App { + } + + const appRef = await bootstrapApplication(App); + + expect(findTestNonces(usesStylesRootNode.shadowRoot!)).toEqual(['shadow-nonce']); + + appRef.destroy(); + })); + + it('should prefer nonce provided through DI over one provided in the DOM', + withBody('', async () => { + @Component({selector: 'uses-styles', template: '', styles: [testStyles], standalone: true}) + class UsesStyles { + } + + @Component({ + selector: 'app', + standalone: true, + template: '', + imports: [UsesStyles] + }) + class App { + } + + const appRef = await bootstrapApplication(App, { + providers: [{provide: CSP_NONCE, useValue: 'di-nonce'}], + }); + + expect(findTestNonces(document)).toEqual(['di-nonce']); + + appRef.destroy(); + })); +}); diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index ca20817b352861..fae7d852eeb899 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -125,6 +125,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CSP_NONCE" + }, { "name": "ChangeDetectionStrategy" }, @@ -173,6 +176,9 @@ { "name": "DI_DECORATOR_FLAG" }, + { + "name": "DOCUMENT" + }, { "name": "DOCUMENT2" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index d0371dcc81c4c7..4a99f26161598d 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -53,6 +53,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CSP_NONCE" + }, { "name": "ChangeDetectionStrategy" }, @@ -89,6 +92,9 @@ { "name": "DI_DECORATOR_FLAG" }, + { + "name": "DOCUMENT" + }, { "name": "DOCUMENT2" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index d090ade8a5df6f..d90f174113bb63 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -80,6 +80,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CSP_NONCE" + }, { "name": "ChangeDetectionStrategy" }, @@ -125,6 +128,9 @@ { "name": "DI_DECORATOR_FLAG" }, + { + "name": "DOCUMENT" + }, { "name": "DOCUMENT2" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 700ea6bb408697..9b4e64dc6d3800 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -83,6 +83,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CSP_NONCE" + }, { "name": "ChangeDetectionStrategy" }, @@ -131,6 +134,9 @@ { "name": "DI_DECORATOR_FLAG" }, + { + "name": "DOCUMENT" + }, { "name": "DOCUMENT2" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 9794f14a7bb4e4..fa5e31118c1b02 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -74,6 +74,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CSP_NONCE" + }, { "name": "CanActivate" }, @@ -152,6 +155,9 @@ { "name": "DI_DECORATOR_FLAG" }, + { + "name": "DOCUMENT" + }, { "name": "DOCUMENT2" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 762edcc78e87ec..5b939f86b16924 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -41,6 +41,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CSP_NONCE" + }, { "name": "ChangeDetectionStrategy" }, @@ -77,6 +80,9 @@ { "name": "DI_DECORATOR_FLAG" }, + { + "name": "DOCUMENT" + }, { "name": "DOCUMENT2" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index d64d0612af0151..479ed11afa4815 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -53,6 +53,9 @@ { "name": "CONTAINER_HEADER_OFFSET" }, + { + "name": "CSP_NONCE" + }, { "name": "ChangeDetectionStrategy" }, @@ -89,6 +92,9 @@ { "name": "DI_DECORATOR_FLAG" }, + { + "name": "DOCUMENT" + }, { "name": "DOCUMENT2" }, diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index 245f14ca554428..f918570c5f1fb0 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {APP_ID, Inject, Injectable, InjectionToken, OnDestroy, Renderer2, RendererFactory2, RendererStyleFlags2, RendererType2, ViewEncapsulation} from '@angular/core'; +import {APP_ID, CSP_NONCE, Inject, inject, Injectable, InjectionToken, OnDestroy, Renderer2, RendererFactory2, RendererStyleFlags2, RendererType2, ViewEncapsulation} from '@angular/core'; import {EventManager} from './events/event_manager'; import {DomSharedStylesHost} from './shared_styles_host'; @@ -87,6 +87,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { private rendererByCompId = new Map(); private defaultRenderer: Renderer2; + private nonce = inject(CSP_NONCE, {optional: true}); constructor( private eventManager: EventManager, private sharedStylesHost: DomSharedStylesHost, @@ -128,7 +129,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { eventManager, sharedStylesHost, type, this.appId, removeStylesOnCompDestory); break; case ViewEncapsulation.ShadowDom: - return new ShadowDomRenderer(eventManager, sharedStylesHost, element, type); + return new ShadowDomRenderer(eventManager, sharedStylesHost, element, type, this.nonce); default: renderer = new NoneEncapsulationDomRenderer( eventManager, sharedStylesHost, type, removeStylesOnCompDestory); @@ -316,7 +317,7 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { constructor( eventManager: EventManager, private sharedStylesHost: DomSharedStylesHost, - private hostEl: any, component: RendererType2) { + private hostEl: any, component: RendererType2, nonce: string|null) { super(eventManager); this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'}); @@ -325,6 +326,12 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { for (const style of styles) { const styleEl = document.createElement('style'); + + if (nonce) { + // Uses a keyed write to avoid issues with property minification. + styleEl['nonce'] = nonce; + } + styleEl.textContent = style; this.shadowRoot.appendChild(styleEl); } diff --git a/packages/platform-browser/src/dom/shared_styles_host.ts b/packages/platform-browser/src/dom/shared_styles_host.ts index cd827fb4f30f33..f9b75941364737 100644 --- a/packages/platform-browser/src/dom/shared_styles_host.ts +++ b/packages/platform-browser/src/dom/shared_styles_host.ts @@ -7,7 +7,7 @@ */ import {DOCUMENT} from '@angular/common'; -import {APP_ID, Inject, Injectable, OnDestroy} from '@angular/core'; +import {APP_ID, CSP_NONCE, Inject, Injectable, OnDestroy, Optional} from '@angular/core'; @Injectable() export class SharedStylesHost implements OnDestroy { @@ -72,7 +72,8 @@ export class DomSharedStylesHost extends SharedStylesHost implements OnDestroy { private styleNodesInDOM: Map|null; constructor( - @Inject(DOCUMENT) private readonly doc: Document, @Inject(APP_ID) private appId: string) { + @Inject(DOCUMENT) private readonly doc: Document, @Inject(APP_ID) private appId: string, + @Inject(CSP_NONCE) @Optional() private nonce?: string|null) { super(); this.styleNodesInDOM = this.collectServerRenderedStyles(); this.resetHostNodes(); @@ -159,6 +160,11 @@ export class DomSharedStylesHost extends SharedStylesHost implements OnDestroy { private addStyleToHost(host: Node, style: string): void { const styleEl = this.getStyleElement(host, style); + if (this.nonce) { + // Uses a keyed write to avoid issues with property minification. + styleEl['nonce'] = this.nonce; + } + host.appendChild(styleEl); const styleRef = this.styleRef; diff --git a/packages/platform-server/src/styles_host.ts b/packages/platform-server/src/styles_host.ts index c3404f89dbdf57..d517b9dd4c284f 100644 --- a/packages/platform-server/src/styles_host.ts +++ b/packages/platform-server/src/styles_host.ts @@ -7,7 +7,7 @@ */ import {DOCUMENT, ɵgetDOM as getDOM} from '@angular/common'; -import {APP_ID, Inject, Injectable} from '@angular/core'; +import {APP_ID, CSP_NONCE, Inject, Injectable, Optional} from '@angular/core'; import {ɵSharedStylesHost as SharedStylesHost} from '@angular/platform-browser'; @Injectable() @@ -15,18 +15,27 @@ export class ServerStylesHost extends SharedStylesHost { private head: any = null; private _styleNodes = new Set(); - constructor(@Inject(DOCUMENT) doc: any, @Inject(APP_ID) private appId: string) { + constructor( + @Inject(DOCUMENT) doc: any, @Inject(APP_ID) private appId: string, + @Inject(CSP_NONCE) @Optional() private nonce?: string|null) { super(); - this.head = doc.getElementsByTagName('head')[0]; + this.head = doc.head || doc.getElementsByTagName('head')[0]; } override onStyleAdded(style: string): void { const adapter = getDOM(); const el = adapter.createElement('style'); - el.textContent = style; - if (!!this.appId) { + + if (this.nonce) { + // Uses a keyed write to avoid issues with property minification. + el['nonce'] = this.nonce; + } + + if (this.appId) { el.setAttribute('ng-app', this.appId); } + + el.textContent = style; this.head.appendChild(el); this._styleNodes.add(el); }