mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-21 05:52:41 +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 #29603
This commit is contained in:
parent
33ed6e875e
commit
e6deb82c6c
@ -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);
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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');
|
||||||
|
@ -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) =>
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
|
@ -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=');
|
||||||
|
Loading…
x
Reference in New Issue
Block a user