diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index 1be46d5f16d4..4e2619cee434 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -48,6 +48,7 @@ export interface ApplicationBuilderOptions { preserveSymlinks?: boolean; progress?: boolean; scripts?: ScriptElement[]; + security?: Security; server?: string; serviceWorker?: ServiceWorker_2; sourceMap?: SourceMapUnion; diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index bc78be6aa3e5..c03d8dc00bb6 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -192,6 +192,11 @@ 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(options, executionResult, initialFiles); diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index a454e88e375c..79c0052af18e 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -398,6 +398,7 @@ export async function normalizeOptions( partialSSRBuild = false, externalRuntimeStyles, instrumentForCoverage, + security, } = options; // Return all the normalized options @@ -461,6 +462,7 @@ export async function normalizeOptions( partialSSRBuild: usePartialSsrBuild || partialSSRBuild, externalRuntimeStyles, instrumentForCoverage, + security, }; } diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 2022969a2f10..d47875c6527e 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -37,6 +37,33 @@ "type": "string", "description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations." }, + "security": { + "description": "Security features to protect against XSS and other common attacks", + "type": "object", + "additionalProperties": false, + "properties": { + "autoCsp": { + "description": "Enables automatic generation of a hash-based Strict Content Security Policy (https://web.dev/articles/strict-csp#choose-hash) based on scripts in index.html. Will default to true once we are out of experimental/preview phases.", + "default": false, + "oneOf": [ + { + "type": "object", + "properties": { + "unsafeEval": { + "type": "boolean", + "description": "Include the `unsafe-eval` directive (https://web.dev/articles/strict-csp#remove-eval) in the auto-CSP. Please only enable this if you are absolutely sure that you need to, as allowing calls to eval will weaken the XSS defenses provided by the auto-CSP.", + "default": false + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + } + } + }, "scripts": { "description": "Global scripts to be included in the build.", "type": "array", diff --git a/packages/angular/build/src/tools/esbuild/index-html-generator.ts b/packages/angular/build/src/tools/esbuild/index-html-generator.ts index afe92dfb0b18..4d11ed4fa45a 100644 --- a/packages/angular/build/src/tools/esbuild/index-html-generator.ts +++ b/packages/angular/build/src/tools/esbuild/index-html-generator.ts @@ -80,6 +80,15 @@ 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, @@ -94,6 +103,7 @@ export async function generateIndexHtml( buildOptions.prerenderOptions || buildOptions.appShellOptions ), + autoCsp: autoCspOptions, }); indexHtmlGenerator.readAsset = readAsset; diff --git a/packages/angular/build/src/utils/index-file/auto-csp.ts b/packages/angular/build/src/utils/index-file/auto-csp.ts index e39e29c80f49..07e183aaba36 100644 --- a/packages/angular/build/src/utils/index-file/auto-csp.ts +++ b/packages/angular/build/src/utils/index-file/auto-csp.ts @@ -80,7 +80,7 @@ export function hashTextContent(scriptText: string): string { * @param html Markup that should be processed. * @returns The transformed HTML that contains the `` tag CSP and dynamic loader scripts. */ -export async function autoCsp(html: string): Promise { +export async function autoCsp(html: string, unsafeEval = false): Promise { const { rewriter, transformedContent } = await htmlRewritingStream(html); let openedScriptTag: StartTag | undefined = undefined; @@ -170,7 +170,11 @@ export async function autoCsp(html: string): Promise { if (tag.tagName === 'head') { // See what hashes we came up with! secondPass.rewriter.emitRaw( - ``, + ``, ); } }); diff --git a/packages/angular/build/src/utils/index-file/index-html-generator.ts b/packages/angular/build/src/utils/index-file/index-html-generator.ts index 3e7cdb167ced..9bfb929c5d11 100644 --- a/packages/angular/build/src/utils/index-file/index-html-generator.ts +++ b/packages/angular/build/src/utils/index-file/index-html-generator.ts @@ -12,6 +12,7 @@ import { NormalizedCachedOptions } from '../normalize-cache'; import { NormalizedOptimizationOptions } from '../normalize-optimization'; import { addEventDispatchContract } from './add-event-dispatch-contract'; import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html'; +import { autoCsp } from './auto-csp'; import { InlineCriticalCssProcessor } from './inline-critical-css'; import { InlineFontsProcessor } from './inline-fonts'; import { addNgcmAttribute } from './ngcm-attribute'; @@ -32,6 +33,10 @@ export interface IndexHtmlGeneratorProcessOptions { hints?: { url: string; mode: HintMode; as?: string }[]; } +export interface AutoCspOptions { + unsafeEval: boolean; +} + export interface IndexHtmlGeneratorOptions { indexPath: string; deployUrl?: string; @@ -43,6 +48,7 @@ export interface IndexHtmlGeneratorOptions { cache?: NormalizedCachedOptions; imageDomains?: string[]; generateDedicatedSSRContent?: boolean; + autoCsp?: AutoCspOptions; } export type IndexHtmlTransform = (content: string) => Promise; @@ -86,6 +92,14 @@ export class IndexHtmlGenerator { this.csrPlugins.push(addNgcmAttributePlugin()); this.ssrPlugins.push(addEventDispatchContractPlugin(), addNoncePlugin()); } + + // Auto-CSP (as the last step) + if (options.autoCsp) { + if (options.generateDedicatedSSRContent) { + throw new Error('Cannot set both SSR and auto-CSP at the same time.'); + } + this.csrPlugins.push(autoCspPlugin(options.autoCsp.unsafeEval)); + } } async process(options: IndexHtmlGeneratorProcessOptions): Promise { @@ -198,6 +212,10 @@ function addNoncePlugin(): IndexHtmlGeneratorPlugin { return (html) => addNonce(html); } +function autoCspPlugin(unsafeEval: boolean): IndexHtmlGeneratorPlugin { + return (html) => autoCsp(html, unsafeEval); +} + function postTransformPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { return async (html) => (options.postTransform ? options.postTransform(html) : html); }