fix(@angular/build): update critical CSS inlining to support autoCsp

This update improves the handling of inlined critical CSS to align with `autoCsp`, ensuring compliance with Content Security Policy (CSP) directives. Previously, inlined styles could trigger CSP violations in certain configurations. With this fix, critical CSS is inlined in a way that maintains security while supporting `autoCsp`.

Closes #29603
This commit is contained in:
Alan Agius 2025-02-14 15:11:48 +00:00 committed by Douglas Parker
parent 33ed6e875e
commit e6deb82c6c
9 changed files with 64 additions and 29 deletions

View File

@ -240,11 +240,6 @@ export async function executeBuild(
); );
} }
// Override auto-CSP settings if we are serving through Vite middleware.
if (context.builder.builderName === 'dev-server' && options.security) {
options.security.autoCsp = false;
}
// Perform i18n translation inlining if enabled // Perform i18n translation inlining if enabled
if (i18nOptions.shouldInline) { if (i18nOptions.shouldInline) {
const result = await inlineI18n(metafile, options, executionResult, initialFiles); const result = await inlineI18n(metafile, options, executionResult, initialFiles);

View File

@ -386,6 +386,15 @@ export async function normalizeOptions(
} }
} }
const autoCsp = options.security?.autoCsp;
const security = {
autoCsp: autoCsp
? {
unsafeEval: autoCsp === true ? false : !!autoCsp.unsafeEval,
}
: undefined,
};
// Initial options to keep // Initial options to keep
const { const {
allowedCommonJsDependencies, allowedCommonJsDependencies,
@ -415,7 +424,6 @@ export async function normalizeOptions(
partialSSRBuild = false, partialSSRBuild = false,
externalRuntimeStyles, externalRuntimeStyles,
instrumentForCoverage, instrumentForCoverage,
security,
} = options; } = options;
// Return all the normalized options // Return all the normalized options

View File

@ -112,6 +112,11 @@ export async function* serveWithVite(
browserOptions.ssr ||= true; browserOptions.ssr ||= true;
} }
// Disable auto CSP.
browserOptions.security = {
autoCsp: false,
};
// Set all packages as external to support Vite's prebundle caching // Set all packages as external to support Vite's prebundle caching
browserOptions.externalPackages = serverOptions.prebundle; browserOptions.externalPackages = serverOptions.prebundle;

View File

@ -80,15 +80,6 @@ export async function generateIndexHtml(
throw new Error(`Output file does not exist: ${relativefilePath}`); throw new Error(`Output file does not exist: ${relativefilePath}`);
}; };
// Read the Auto CSP options.
const autoCsp = buildOptions.security?.autoCsp;
const autoCspOptions =
autoCsp === true
? { unsafeEval: false }
: autoCsp
? { unsafeEval: !!autoCsp.unsafeEval }
: undefined;
// Create an index HTML generator that reads from the in-memory output files // Create an index HTML generator that reads from the in-memory output files
const indexHtmlGenerator = new IndexHtmlGenerator({ const indexHtmlGenerator = new IndexHtmlGenerator({
indexPath: indexHtmlOptions.input, indexPath: indexHtmlOptions.input,
@ -103,7 +94,7 @@ export async function generateIndexHtml(
buildOptions.prerenderOptions || buildOptions.prerenderOptions ||
buildOptions.appShellOptions buildOptions.appShellOptions
), ),
autoCsp: autoCspOptions, autoCsp: buildOptions.security.autoCsp,
}); });
indexHtmlGenerator.readAsset = readAsset; indexHtmlGenerator.readAsset = readAsset;

View File

@ -98,7 +98,7 @@ export async function autoCsp(html: string, unsafeEval = false): Promise<string>
scriptContent = []; scriptContent = [];
} }
rewriter.on('startTag', (tag, html) => { rewriter.on('startTag', (tag) => {
if (tag.tagName === 'script') { if (tag.tagName === 'script') {
openedScriptTag = tag; openedScriptTag = tag;
const src = getScriptAttributeValue(tag, 'src'); const src = getScriptAttributeValue(tag, 'src');

View File

@ -82,7 +82,7 @@ export class IndexHtmlGenerator {
// CSR plugins // CSR plugins
if (options?.optimization?.styles?.inlineCritical) { if (options?.optimization?.styles?.inlineCritical) {
this.csrPlugins.push(inlineCriticalCssPlugin(this)); this.csrPlugins.push(inlineCriticalCssPlugin(this, !!options.autoCsp));
} }
this.csrPlugins.push(addNoncePlugin()); this.csrPlugins.push(addNoncePlugin());
@ -197,11 +197,15 @@ function inlineFontsPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorP
return async (html) => inlineFontsProcessor.process(html); return async (html) => inlineFontsProcessor.process(html);
} }
function inlineCriticalCssPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { function inlineCriticalCssPlugin(
generator: IndexHtmlGenerator,
autoCsp: boolean,
): IndexHtmlGeneratorPlugin {
const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({ const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
minify: generator.options.optimization?.styles.minify, minify: generator.options.optimization?.styles.minify,
deployUrl: generator.options.deployUrl, deployUrl: generator.options.deployUrl,
readAsset: (filePath) => generator.readAsset(filePath), readAsset: (filePath) => generator.readAsset(filePath),
autoCsp,
}); });
return async (html, options) => return async (html, options) =>

View File

@ -68,6 +68,7 @@ export interface InlineCriticalCssProcessorOptions {
minify?: boolean; minify?: boolean;
deployUrl?: string; deployUrl?: string;
readAsset?: (path: string) => Promise<string>; readAsset?: (path: string) => Promise<string>;
autoCsp?: boolean;
} }
/** Partial representation of an `HTMLElement`. */ /** Partial representation of an `HTMLElement`. */
@ -163,7 +164,7 @@ class BeastiesExtended extends BeastiesBase {
const returnValue = await super.embedLinkedStylesheet(link, document); const returnValue = await super.embedLinkedStylesheet(link, document);
const cspNonce = this.findCspNonce(document); const cspNonce = this.findCspNonce(document);
if (cspNonce) { if (cspNonce || this.optionsExtended.autoCsp) {
const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);
if (beastiesMedia) { if (beastiesMedia) {
@ -180,11 +181,13 @@ class BeastiesExtended extends BeastiesBase {
// a way of doing that at the moment so we fall back to doing it any time a `link` tag is // a way of doing that at the moment so we fall back to doing it any time a `link` tag is
// inserted. We mitigate it by only iterating the direct children of the `<head>` which // inserted. We mitigate it by only iterating the direct children of the `<head>` which
// should be pretty shallow. // should be pretty shallow.
document.head.children.forEach((child) => { if (cspNonce) {
if (child.tagName === 'style' && !child.hasAttribute('nonce')) { document.head.children.forEach((child) => {
child.setAttribute('nonce', cspNonce); if (child.tagName === 'style' && !child.hasAttribute('nonce')) {
} child.setAttribute('nonce', cspNonce);
}); }
});
}
} }
return returnValue; return returnValue;
@ -215,7 +218,7 @@ class BeastiesExtended extends BeastiesBase {
*/ */
private conditionallyInsertCspLoadingScript( private conditionallyInsertCspLoadingScript(
document: PartialDocument, document: PartialDocument,
nonce: string, nonce: string | null,
link: PartialHTMLElement, link: PartialHTMLElement,
): void { ): void {
if (this.addedCspScriptsDocuments.has(document)) { if (this.addedCspScriptsDocuments.has(document)) {
@ -223,8 +226,11 @@ class BeastiesExtended extends BeastiesBase {
} }
const script = document.createElement('script'); const script = document.createElement('script');
script.setAttribute('nonce', nonce);
script.textContent = LINK_LOAD_SCRIPT_CONTENT; script.textContent = LINK_LOAD_SCRIPT_CONTENT;
if (nonce) {
script.setAttribute('nonce', nonce);
}
// Prepend the script to the head since it needs to // Prepend the script to the head since it needs to
// run as early as possible, before the `link` tags. // run as early as possible, before the `link` tags.
document.head.insertBefore(script, link); document.head.insertBefore(script, link);

View File

@ -106,6 +106,26 @@ describe('InlineCriticalCssProcessor', () => {
expect(content).toContain('<style>body{margin:0}html{color:white}</style>'); expect(content).toContain('<style>body{margin:0}html{color:white}</style>');
}); });
it(`should process the inline 'onload' handlers if a 'autoCsp' is true`, async () => {
const inlineCssProcessor = new InlineCriticalCssProcessor({
readAsset,
autoCsp: true,
});
const { content } = await inlineCssProcessor.process(getContent(''), {
outputPath: '/dist/',
});
expect(content).toContain(
'<link href="styles.css" rel="stylesheet" media="print" ngCspMedia="all">',
);
expect(tags.stripIndents`${content}`).toContain(tags.stripIndents`
<style>
body { margin: 0; }
html { color: white; }
</style>`);
});
it('should process the inline `onload` handlers if a CSP nonce is specified', async () => { it('should process the inline `onload` handlers if a CSP nonce is specified', async () => {
const inlineCssProcessor = new InlineCriticalCssProcessor({ const inlineCssProcessor = new InlineCriticalCssProcessor({
readAsset, readAsset,

View File

@ -1,6 +1,6 @@
import assert from 'node:assert'; import assert from 'node:assert';
import { getGlobalVariable } from '../../utils/env'; import { getGlobalVariable } from '../../utils/env';
import { expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; import { expectFileToMatch, writeFile, writeMultipleFiles } from '../../utils/fs';
import { findFreePort } from '../../utils/network'; import { findFreePort } from '../../utils/network';
import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; import { execAndWaitForOutputToMatch, ng } from '../../utils/process';
import { updateJsonFile } from '../../utils/project'; import { updateJsonFile } from '../../utils/project';
@ -13,6 +13,9 @@ export default async function () {
'This test should not be called in the Webpack suite.', 'This test should not be called in the Webpack suite.',
); );
// Add global css to trigger critical css inlining
await writeFile('src/styles.css', `body { color: green }`);
// Turn on auto-CSP // Turn on auto-CSP
await updateJsonFile('angular.json', (json) => { await updateJsonFile('angular.json', (json) => {
const build = json['projects']['test-project']['architect']['build']; const build = json['projects']['test-project']['architect']['build'];
@ -54,7 +57,7 @@ export default async function () {
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
<script> <script>
const inlineScriptBodyCreated = 1338; const inlineScriptBodyCreated = 1338;
console.warn("Inline Script Body: " + inlineScriptHeadCreated); console.warn("Inline Script Body: " + inlineScriptHeadCreated);
@ -130,6 +133,9 @@ export default async function () {
// Make sure the output files have auto-CSP as a result of `ng build` // Make sure the output files have auto-CSP as a result of `ng build`
await expectFileToMatch('dist/test-project/browser/index.html', CSP_META_TAG); await expectFileToMatch('dist/test-project/browser/index.html', CSP_META_TAG);
// Make sure if contains the critical CSS inlining CSP code.
await expectFileToMatch('dist/test-project/browser/index.html', 'ngCspMedia');
// Make sure that our e2e protractor tests run to confirm that our angular project runs. // Make sure that our e2e protractor tests run to confirm that our angular project runs.
const port = await spawnServer(); const port = await spawnServer();
await ng('e2e', `--base-url=http://localhost:${port}`, '--dev-server-target='); await ng('e2e', `--base-url=http://localhost:${port}`, '--dev-server-target=');