Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add CSP support for inline styles
Browse files Browse the repository at this point in the history
Companion change to angular/angular#49444. Adds an HTML processor that finds the `ngCspNonce` attribute and copies its value to any inline `style` tags in the HTML. The processor runs late in the processing pipeline in order to pick up any `style` tag that might've been added by other processors (e.g. critical CSS).
  • Loading branch information
crisbeto committed Mar 17, 2023
1 parent 3e8bdf7 commit bbf0179
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { stripBom } from '../strip-bom';
import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html';
import { InlineCriticalCssProcessor } from './inline-critical-css';
import { InlineFontsProcessor } from './inline-fonts';
import { addStyleNonce } from './style-nonce';

type IndexHtmlGeneratorPlugin = (
html: string,
Expand Down Expand Up @@ -59,7 +60,14 @@ export class IndexHtmlGenerator {
extraPlugins.push(inlineCriticalCssPlugin(this));
}

this.plugins = [augmentIndexHtmlPlugin(this), ...extraPlugins, postTransformPlugin(this)];
this.plugins = [
augmentIndexHtmlPlugin(this),
...extraPlugins,
// Runs after the `extraPlugins` to capture any nonce or
// `style` tags that might've been added by them.
addStyleNoncePlugin(),
postTransformPlugin(this),
];
}

async process(options: IndexHtmlGeneratorProcessOptions): Promise<IndexHtmlTransformResult> {
Expand Down Expand Up @@ -139,6 +147,10 @@ function inlineCriticalCssPlugin(generator: IndexHtmlGenerator): IndexHtmlGenera
inlineCriticalCssProcessor.process(html, { outputPath: options.outputPath });
}

function addStyleNoncePlugin(): IndexHtmlGeneratorPlugin {
return (html) => addStyleNonce(html);
}

function postTransformPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
return async (html) => (options.postTransform ? options.postTransform(html) : html);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @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 { htmlRewritingStream } from './html-rewriting-stream';

/**
* Pattern matching the name of the Angular nonce attribute. Note that the it is
* case-insensitive, because HTML attribute names are case-insesitive as well.
*/
const NONCE_ATTR_PATTERN = /ngCspNonce/i;

/**
* Finds the `ngCspNonce` value and copies it to all inline `<style>` tags.
* @param html Markup that should be processed.
*/
export async function addStyleNonce(html: string): Promise<string> {
const nonce = await findNonce(html);

if (!nonce) {
return html;
}

const { rewriter, transformedContent } = await htmlRewritingStream(html);

rewriter.on('startTag', (tag) => {
if (tag.tagName === 'style' && !tag.attrs.some((attr) => attr.name === 'nonce')) {
tag.attrs.push({ name: 'nonce', value: nonce });
}

rewriter.emitStartTag(tag);
});

return await transformedContent();
}

/** Finds the Angular nonce in an HTML string. */
async function findNonce(html: string): Promise<string | null> {
// Inexpensive check to avoid parsing the HTML when we're sure there's no nonce.
if (!NONCE_ATTR_PATTERN.test(html)) {
return null;
}

const { rewriter, transformedContent } = await htmlRewritingStream(html);
let nonce: string | null = null;

rewriter.on('startTag', (tag) => {
const nonceAttr = tag.attrs.find((attr) => NONCE_ATTR_PATTERN.test(attr.name));
if (nonceAttr && nonceAttr.value) {
nonce = nonceAttr.value;
rewriter.stop(); // Stop parsing since we've found the nonce.
}
});

await transformedContent();

return nonce;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @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 { addStyleNonce } from './style-nonce';

describe('add-style-nonce', () => {
it('should add the nonce expression to all inline style tags', async () => {
const result = await addStyleNonce(`
<html>
<head>
<style>.a {color: red;}</style>
<style>.b {color: blue;}</style>
</head>
<body>
<app ngCspNonce="{% nonce %}"></app>
</body>
</html>
`);

expect(result).toContain('<style nonce="{% nonce %}">.a {color: red;}</style>');
expect(result).toContain('<style nonce="{% nonce %}">.b {color: blue;}</style>');
});

it('should add a lowercase nonce expression to style tags', async () => {
const result = await addStyleNonce(`
<html>
<head>
<style>.a {color: red;}</style>
</head>
<body>
<app ngcspnonce="{% nonce %}"></app>
</body>
</html>
`);

expect(result).toContain('<style nonce="{% nonce %}">.a {color: red;}</style>');
});

it('should preserve any pre-existing nonces', async () => {
const result = await addStyleNonce(`
<html>
<head>
<style>.a {color: red;}</style>
<style nonce="{% otherNonce %}">.b {color: blue;}</style>
</head>
<body>
<app ngCspNonce="{% nonce %}"></app>
</body>
</html>
`);

expect(result).toContain('<style nonce="{% nonce %}">.a {color: red;}</style>');
expect(result).toContain('<style nonce="{% otherNonce %}">.b {color: blue;}</style>');
});

it('should use the first nonce that is defined on the page', async () => {
const result = await addStyleNonce(`
<html>
<head>
<style>.a {color: red;}</style>
</head>
<body>
<app ngCspNonce="{% nonce %}"></app>
<other-app ngCspNonce="{% otherNonce %}"></other-app>
</body>
</html>
`);

expect(result).toContain('<style nonce="{% nonce %}">.a {color: red;}</style>');
});
});

0 comments on commit bbf0179

Please sign in to comment.