1
0
mirror of https://github.com/angular/angular-cli.git synced 2025-05-18 20:02:40 +08:00

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 
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
packages/angular/build/src
tests/legacy-cli/e2e/tests/build

@ -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
if (i18nOptions.shouldInline) {
const result = await inlineI18n(metafile, options, executionResult, initialFiles);

@ -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
const {
allowedCommonJsDependencies,
@ -415,7 +424,6 @@ export async function normalizeOptions(
partialSSRBuild = false,
externalRuntimeStyles,
instrumentForCoverage,
security,
} = options;
// Return all the normalized options

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

@ -80,15 +80,6 @@ export async function generateIndexHtml(
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
const indexHtmlGenerator = new IndexHtmlGenerator({
indexPath: indexHtmlOptions.input,
@ -103,7 +94,7 @@ export async function generateIndexHtml(
buildOptions.prerenderOptions ||
buildOptions.appShellOptions
),
autoCsp: autoCspOptions,
autoCsp: buildOptions.security.autoCsp,
});
indexHtmlGenerator.readAsset = readAsset;

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

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

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

@ -106,6 +106,26 @@ describe('InlineCriticalCssProcessor', () => {
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 () => {
const inlineCssProcessor = new InlineCriticalCssProcessor({
readAsset,

@ -1,6 +1,6 @@
import assert from 'node:assert';
import { getGlobalVariable } from '../../utils/env';
import { expectFileToMatch, writeMultipleFiles } from '../../utils/fs';
import { expectFileToMatch, writeFile, writeMultipleFiles } from '../../utils/fs';
import { findFreePort } from '../../utils/network';
import { execAndWaitForOutputToMatch, ng } from '../../utils/process';
import { updateJsonFile } from '../../utils/project';
@ -13,6 +13,9 @@ export default async function () {
'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
await updateJsonFile('angular.json', (json) => {
const build = json['projects']['test-project']['architect']['build'];
@ -54,7 +57,7 @@ export default async function () {
</head>
<body>
<app-root></app-root>
<script>
const inlineScriptBodyCreated = 1338;
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`
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.
const port = await spawnServer();
await ng('e2e', `--base-url=http://localhost:${port}`, '--dev-server-target=');