Skip to content

Commit 92db19c

Browse files
committed
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. `<app ngCspNonce="{% randomNonceAddedByTheServer %}"></app>`. 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 angular#6361.
1 parent 92e41e9 commit 92db19c

File tree

15 files changed

+264
-14
lines changed

15 files changed

+264
-14
lines changed

goldens/public-api/core/index.md

+3
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,9 @@ export function createPlatform(injector: Injector): PlatformRef;
345345
// @public
346346
export function createPlatformFactory(parentPlatformFactory: ((extraProviders?: StaticProvider[]) => PlatformRef) | null, name: string, providers?: StaticProvider[]): (extraProviders?: StaticProvider[]) => PlatformRef;
347347

348+
// @public
349+
export const CSP_NONCE: InjectionToken<string | null>;
350+
348351
// @public
349352
export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata;
350353

packages/core/src/application_tokens.ts

+33
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {InjectionToken} from './di/injection_token';
10+
import {getDocument} from './render3/interfaces/document';
1011

1112
/**
1213
* 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<string>('Application Packages
7980
*/
8081
export const ANIMATION_MODULE_TYPE =
8182
new InjectionToken<'NoopAnimations'|'BrowserAnimations'>('AnimationModuleType');
83+
84+
// TODO(crisbeto): link to CSP guide here.
85+
/**
86+
* Token used to configure the [Content Security Policy](https://web.dev/strict-csp/) nonce that
87+
* Angular will apply when inserting inline styles. If not provided, Angular will look up its value
88+
* from the `ngCspNonce` attribute of the application root node.
89+
*
90+
* @publicApi
91+
*/
92+
export const CSP_NONCE = new InjectionToken<string|null>('CSP nonce', {
93+
providedIn: 'root',
94+
factory: () => {
95+
// Ideally we wouldn't have to use `querySelector` here since we know that the nonce will be on
96+
// the root node, but because the token value is used in renderers, it has to be available
97+
// *very* early in the bootstrapping process. This should be a fairly shallow search, because
98+
// the app won't have been added to the DOM yet. Some approaches that were considered:
99+
// 1. Find the root node through `ApplicationRef.components[i].location` - normally this would
100+
// be enough for our purposes, but the token is injected very early so the `components` array
101+
// isn't populated yet.
102+
// 2. Find the root `LView` through the current `LView` - renderers are a prerequisite to
103+
// creating the `LView`. This means that no `LView` will have been entered when this factory is
104+
// invoked for the root component.
105+
// 3. Have the token factory return `() => string` which is invoked when a nonce is requested -
106+
// the slightly later execution does allow us to get an `LView` reference, but the fact that
107+
// it is a function means that it could be executed at *any* time (including immediately) which
108+
// may lead to weird bugs.
109+
// 4. Have the `ComponentFactory` read the attribute and provide it to the injector under the
110+
// hood - has the same problem as #1 and #2 in that the renderer is used to query for the root
111+
// node and the nonce value needs to be available when the renderer is created.
112+
return getDocument().body.querySelector('[ngCspNonce]')?.getAttribute('ngCspNonce') || null;
113+
},
114+
});

packages/core/src/core.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export {TypeDecorator} from './util/decorators';
1717
export * from './di';
1818
export {createPlatform, assertPlatform, destroyPlatform, getPlatform, BootstrapOptions, PlatformRef, ApplicationRef, createPlatformFactory, NgProbeToken, APP_BOOTSTRAP_LISTENER} from './application_ref';
1919
export {enableProdMode, isDevMode} from './util/is_dev_mode';
20-
export {APP_ID, PACKAGE_ROOT_URL, PLATFORM_INITIALIZER, PLATFORM_ID, ANIMATION_MODULE_TYPE} from './application_tokens';
20+
export {APP_ID, PACKAGE_ROOT_URL, PLATFORM_INITIALIZER, PLATFORM_ID, ANIMATION_MODULE_TYPE, CSP_NONCE} from './application_tokens';
2121
export {APP_INITIALIZER, ApplicationInitStatus} from './application_init';
2222
export * from './zone';
2323
export * from './render';
+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*!
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Component, CSP_NONCE, destroyPlatform, ElementRef, inject, ViewEncapsulation} from '@angular/core';
10+
import {bootstrapApplication} from '@angular/platform-browser';
11+
import {withBody} from '@angular/private/testing';
12+
13+
describe('CSP integration', () => {
14+
beforeEach(destroyPlatform);
15+
afterEach(destroyPlatform);
16+
17+
const testStyles = '.a { color: var(--csp-test-var, hotpink); }';
18+
19+
function findTestNonces(rootNode: ParentNode): string[] {
20+
const styles = rootNode.querySelectorAll('style');
21+
const nonces: string[] = [];
22+
23+
for (let i = 0; i < styles.length; i++) {
24+
const style = styles[i];
25+
if (style.textContent?.includes('--csp-test-var') && style.nonce) {
26+
nonces.push(style.nonce);
27+
}
28+
}
29+
30+
return nonces;
31+
}
32+
33+
it('should use the predefined ngCspNonce when inserting styles with emulated encapsulation',
34+
withBody('<app ngCspNonce="emulated-nonce"></app>', async () => {
35+
@Component({
36+
selector: 'uses-styles',
37+
template: '',
38+
styles: [testStyles],
39+
standalone: true,
40+
encapsulation: ViewEncapsulation.Emulated
41+
})
42+
class UsesStyles {
43+
}
44+
45+
@Component({
46+
selector: 'app',
47+
standalone: true,
48+
template: '<uses-styles></uses-styles>',
49+
imports: [UsesStyles]
50+
})
51+
class App {
52+
}
53+
54+
const appRef = await bootstrapApplication(App);
55+
56+
expect(findTestNonces(document)).toEqual(['emulated-nonce']);
57+
58+
appRef.destroy();
59+
}));
60+
61+
it('should use the predefined ngCspNonce when inserting styles with no encapsulation',
62+
withBody('<app ngCspNonce="disabled-nonce"></app>', async () => {
63+
@Component({
64+
selector: 'uses-styles',
65+
template: '',
66+
styles: [testStyles],
67+
standalone: true,
68+
encapsulation: ViewEncapsulation.None
69+
})
70+
class UsesStyles {
71+
}
72+
73+
@Component({
74+
selector: 'app',
75+
standalone: true,
76+
template: '<uses-styles></uses-styles>',
77+
imports: [UsesStyles]
78+
})
79+
class App {
80+
}
81+
82+
const appRef = await bootstrapApplication(App);
83+
84+
expect(findTestNonces(document)).toEqual(['disabled-nonce']);
85+
86+
appRef.destroy();
87+
}));
88+
89+
90+
it('should use the predefined ngCspNonce when inserting styles with shadow DOM encapsulation',
91+
withBody('<app ngCspNonce="shadow-nonce"></app>', async () => {
92+
if (!document.body.attachShadow) {
93+
return;
94+
}
95+
96+
let usesStylesRootNode!: HTMLElement;
97+
98+
@Component({
99+
selector: 'uses-styles',
100+
template: '',
101+
styles: [testStyles],
102+
standalone: true,
103+
encapsulation: ViewEncapsulation.ShadowDom
104+
})
105+
class UsesStyles {
106+
constructor() {
107+
usesStylesRootNode = inject(ElementRef).nativeElement;
108+
}
109+
}
110+
111+
@Component({
112+
selector: 'app',
113+
standalone: true,
114+
template: '<uses-styles></uses-styles>',
115+
imports: [UsesStyles]
116+
})
117+
class App {
118+
}
119+
120+
const appRef = await bootstrapApplication(App);
121+
122+
expect(findTestNonces(usesStylesRootNode.shadowRoot!)).toEqual(['shadow-nonce']);
123+
124+
appRef.destroy();
125+
}));
126+
127+
it('should prefer nonce provided through DI over one provided in the DOM',
128+
withBody('<app ngCspNonce="dom-nonce"></app>', async () => {
129+
@Component({selector: 'uses-styles', template: '', styles: [testStyles], standalone: true})
130+
class UsesStyles {
131+
}
132+
133+
@Component({
134+
selector: 'app',
135+
standalone: true,
136+
template: '<uses-styles></uses-styles>',
137+
imports: [UsesStyles]
138+
})
139+
class App {
140+
}
141+
142+
const appRef = await bootstrapApplication(App, {
143+
providers: [{provide: CSP_NONCE, useValue: 'di-nonce'}],
144+
});
145+
146+
expect(findTestNonces(document)).toEqual(['di-nonce']);
147+
148+
appRef.destroy();
149+
}));
150+
});

packages/core/test/bundling/animations/bundle.golden_symbols.json

+6
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@
125125
{
126126
"name": "CONTAINER_HEADER_OFFSET"
127127
},
128+
{
129+
"name": "CSP_NONCE"
130+
},
128131
{
129132
"name": "ChangeDetectionStrategy"
130133
},
@@ -173,6 +176,9 @@
173176
{
174177
"name": "DI_DECORATOR_FLAG"
175178
},
179+
{
180+
"name": "DOCUMENT"
181+
},
176182
{
177183
"name": "DOCUMENT2"
178184
},

packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json

+6
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
{
5454
"name": "CONTAINER_HEADER_OFFSET"
5555
},
56+
{
57+
"name": "CSP_NONCE"
58+
},
5659
{
5760
"name": "ChangeDetectionStrategy"
5861
},
@@ -89,6 +92,9 @@
8992
{
9093
"name": "DI_DECORATOR_FLAG"
9194
},
95+
{
96+
"name": "DOCUMENT"
97+
},
9298
{
9399
"name": "DOCUMENT2"
94100
},

packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json

+6
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@
8080
{
8181
"name": "CONTAINER_HEADER_OFFSET"
8282
},
83+
{
84+
"name": "CSP_NONCE"
85+
},
8386
{
8487
"name": "ChangeDetectionStrategy"
8588
},
@@ -125,6 +128,9 @@
125128
{
126129
"name": "DI_DECORATOR_FLAG"
127130
},
131+
{
132+
"name": "DOCUMENT"
133+
},
128134
{
129135
"name": "DOCUMENT2"
130136
},

packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json

+6
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@
8383
{
8484
"name": "CONTAINER_HEADER_OFFSET"
8585
},
86+
{
87+
"name": "CSP_NONCE"
88+
},
8689
{
8790
"name": "ChangeDetectionStrategy"
8891
},
@@ -131,6 +134,9 @@
131134
{
132135
"name": "DI_DECORATOR_FLAG"
133136
},
137+
{
138+
"name": "DOCUMENT"
139+
},
134140
{
135141
"name": "DOCUMENT2"
136142
},

packages/core/test/bundling/router/bundle.golden_symbols.json

+6
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@
7474
{
7575
"name": "CONTAINER_HEADER_OFFSET"
7676
},
77+
{
78+
"name": "CSP_NONCE"
79+
},
7780
{
7881
"name": "CanActivate"
7982
},
@@ -152,6 +155,9 @@
152155
{
153156
"name": "DI_DECORATOR_FLAG"
154157
},
158+
{
159+
"name": "DOCUMENT"
160+
},
155161
{
156162
"name": "DOCUMENT2"
157163
},

packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json

+6
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
{
4242
"name": "CONTAINER_HEADER_OFFSET"
4343
},
44+
{
45+
"name": "CSP_NONCE"
46+
},
4447
{
4548
"name": "ChangeDetectionStrategy"
4649
},
@@ -77,6 +80,9 @@
7780
{
7881
"name": "DI_DECORATOR_FLAG"
7982
},
83+
{
84+
"name": "DOCUMENT"
85+
},
8086
{
8187
"name": "DOCUMENT2"
8288
},

packages/core/test/bundling/todo/bundle.golden_symbols.json

+6
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
{
5454
"name": "CONTAINER_HEADER_OFFSET"
5555
},
56+
{
57+
"name": "CSP_NONCE"
58+
},
5659
{
5760
"name": "ChangeDetectionStrategy"
5861
},
@@ -89,6 +92,9 @@
8992
{
9093
"name": "DI_DECORATOR_FLAG"
9194
},
95+
{
96+
"name": "DOCUMENT"
97+
},
9298
{
9399
"name": "DOCUMENT2"
94100
},

packages/platform-browser/src/browser.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {CommonModule, DOCUMENT, XhrFactory, ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common';
10-
import {APP_ID, ApplicationConfig as ApplicationConfigFromCore, ApplicationModule, ApplicationRef, createPlatformFactory, ErrorHandler, Inject, InjectionToken, ModuleWithProviders, NgModule, NgZone, Optional, PLATFORM_ID, PLATFORM_INITIALIZER, platformCore, PlatformRef, Provider, RendererFactory2, SkipSelf, StaticProvider, Testability, TestabilityRegistry, Type, ɵINJECTOR_SCOPE as INJECTOR_SCOPE, ɵinternalCreateApplication as internalCreateApplication, ɵsetDocument, ɵTESTABILITY as TESTABILITY, ɵTESTABILITY_GETTER as TESTABILITY_GETTER} from '@angular/core';
10+
import {APP_ID, ApplicationConfig as ApplicationConfigFromCore, ApplicationModule, ApplicationRef, createPlatformFactory, CSP_NONCE, ErrorHandler, Inject, InjectionToken, ModuleWithProviders, NgModule, NgZone, Optional, PLATFORM_ID, PLATFORM_INITIALIZER, platformCore, PlatformRef, Provider, RendererFactory2, SkipSelf, StaticProvider, Testability, TestabilityRegistry, Type, ɵINJECTOR_SCOPE as INJECTOR_SCOPE, ɵinternalCreateApplication as internalCreateApplication, ɵsetDocument, ɵTESTABILITY as TESTABILITY, ɵTESTABILITY_GETTER as TESTABILITY_GETTER} from '@angular/core';
1111

1212
import {BrowserDomAdapter} from './browser/browser_adapter';
1313
import {BrowserGetTestability} from './browser/testability';
@@ -207,7 +207,7 @@ const BROWSER_MODULE_PROVIDERS: Provider[] = [
207207
{provide: EVENT_MANAGER_PLUGINS, useClass: KeyEventsPlugin, multi: true, deps: [DOCUMENT]}, {
208208
provide: DomRendererFactory2,
209209
useClass: DomRendererFactory2,
210-
deps: [EventManager, DomSharedStylesHost, APP_ID, REMOVE_STYLES_ON_COMPONENT_DESTROY]
210+
deps: [EventManager, DomSharedStylesHost, APP_ID, REMOVE_STYLES_ON_COMPONENT_DESTROY, CSP_NONCE]
211211
},
212212
{provide: RendererFactory2, useExisting: DomRendererFactory2},
213213
{provide: SharedStylesHost, useExisting: DomSharedStylesHost}, DomSharedStylesHost, EventManager,

0 commit comments

Comments
 (0)