Skip to content

Commit

Permalink
feat(core): add API to provide CSP nonce for inline stylesheets
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto committed Mar 16, 2023
1 parent 92e41e9 commit 39e76a2
Show file tree
Hide file tree
Showing 14 changed files with 261 additions and 11 deletions.
3 changes: 3 additions & 0 deletions goldens/public-api/core/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>;

// @public
export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata;

Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/application_tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,3 +80,35 @@ export const PACKAGE_ROOT_URL = new InjectionToken<string>('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<string|null>('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;
},
});
2 changes: 1 addition & 1 deletion packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
150 changes: 150 additions & 0 deletions packages/core/test/acceptance/csp_spec.ts
Original file line number Diff line number Diff line change
@@ -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('<app ngCspNonce="emulated-nonce"></app>', async () => {
@Component({
selector: 'uses-styles',
template: '',
styles: [testStyles],
standalone: true,
encapsulation: ViewEncapsulation.Emulated
})
class UsesStyles {
}

@Component({
selector: 'app',
standalone: true,
template: '<uses-styles></uses-styles>',
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('<app ngCspNonce="disabled-nonce"></app>', async () => {
@Component({
selector: 'uses-styles',
template: '',
styles: [testStyles],
standalone: true,
encapsulation: ViewEncapsulation.None
})
class UsesStyles {
}

@Component({
selector: 'app',
standalone: true,
template: '<uses-styles></uses-styles>',
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('<app ngCspNonce="shadow-nonce"></app>', 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: '<uses-styles></uses-styles>',
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('<app ngCspNonce="dom-nonce"></app>', async () => {
@Component({selector: 'uses-styles', template: '', styles: [testStyles], standalone: true})
class UsesStyles {
}

@Component({
selector: 'app',
standalone: true,
template: '<uses-styles></uses-styles>',
imports: [UsesStyles]
})
class App {
}

const appRef = await bootstrapApplication(App, {
providers: [{provide: CSP_NONCE, useValue: 'di-nonce'}],
});

expect(findTestNonces(document)).toEqual(['di-nonce']);

appRef.destroy();
}));
});
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CSP_NONCE"
},
{
"name": "ChangeDetectionStrategy"
},
Expand Down Expand Up @@ -173,6 +176,9 @@
{
"name": "DI_DECORATOR_FLAG"
},
{
"name": "DOCUMENT"
},
{
"name": "DOCUMENT2"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CSP_NONCE"
},
{
"name": "ChangeDetectionStrategy"
},
Expand Down Expand Up @@ -89,6 +92,9 @@
{
"name": "DI_DECORATOR_FLAG"
},
{
"name": "DOCUMENT"
},
{
"name": "DOCUMENT2"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CSP_NONCE"
},
{
"name": "ChangeDetectionStrategy"
},
Expand Down Expand Up @@ -125,6 +128,9 @@
{
"name": "DI_DECORATOR_FLAG"
},
{
"name": "DOCUMENT"
},
{
"name": "DOCUMENT2"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CSP_NONCE"
},
{
"name": "ChangeDetectionStrategy"
},
Expand Down Expand Up @@ -131,6 +134,9 @@
{
"name": "DI_DECORATOR_FLAG"
},
{
"name": "DOCUMENT"
},
{
"name": "DOCUMENT2"
},
Expand Down
6 changes: 6 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CSP_NONCE"
},
{
"name": "CanActivate"
},
Expand Down Expand Up @@ -152,6 +155,9 @@
{
"name": "DI_DECORATOR_FLAG"
},
{
"name": "DOCUMENT"
},
{
"name": "DOCUMENT2"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CSP_NONCE"
},
{
"name": "ChangeDetectionStrategy"
},
Expand Down Expand Up @@ -77,6 +80,9 @@
{
"name": "DI_DECORATOR_FLAG"
},
{
"name": "DOCUMENT"
},
{
"name": "DOCUMENT2"
},
Expand Down
6 changes: 6 additions & 0 deletions packages/core/test/bundling/todo/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
{
"name": "CONTAINER_HEADER_OFFSET"
},
{
"name": "CSP_NONCE"
},
{
"name": "ChangeDetectionStrategy"
},
Expand Down Expand Up @@ -89,6 +92,9 @@
{
"name": "DI_DECORATOR_FLAG"
},
{
"name": "DOCUMENT"
},
{
"name": "DOCUMENT2"
},
Expand Down
Loading

0 comments on commit 39e76a2

Please sign in to comment.