Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@angular/build): Auto-CSP support as a part of angular.json schema #28663

Merged
merged 1 commit into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/public-api/angular/build/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface ApplicationBuilderOptions {
preserveSymlinks?: boolean;
progress?: boolean;
scripts?: ScriptElement[];
security?: Security;
server?: string;
serviceWorker?: ServiceWorker_2;
sourceMap?: SourceMapUnion;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ export async function normalizeOptions(
partialSSRBuild = false,
externalRuntimeStyles,
instrumentForCoverage,
security,
} = options;

// Return all the normalized options
Expand Down Expand Up @@ -461,6 +462,7 @@ export async function normalizeOptions(
partialSSRBuild: usePartialSsrBuild || partialSSRBuild,
externalRuntimeStyles,
instrumentForCoverage,
security,
};
}

Expand Down
27 changes: 27 additions & 0 deletions packages/angular/build/src/builders/application/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
aaronshim marked this conversation as resolved.
Show resolved Hide resolved
"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",
Expand Down
10 changes: 10 additions & 0 deletions packages/angular/build/src/tools/esbuild/index-html-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -94,6 +103,7 @@ export async function generateIndexHtml(
buildOptions.prerenderOptions ||
buildOptions.appShellOptions
),
autoCsp: autoCspOptions,
});

indexHtmlGenerator.readAsset = readAsset;
Expand Down
8 changes: 6 additions & 2 deletions packages/angular/build/src/utils/index-file/auto-csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function hashTextContent(scriptText: string): string {
* @param html Markup that should be processed.
* @returns The transformed HTML that contains the `<meta>` tag CSP and dynamic loader scripts.
*/
export async function autoCsp(html: string): Promise<string> {
export async function autoCsp(html: string, unsafeEval = false): Promise<string> {
const { rewriter, transformedContent } = await htmlRewritingStream(html);

let openedScriptTag: StartTag | undefined = undefined;
Expand Down Expand Up @@ -170,7 +170,11 @@ export async function autoCsp(html: string): Promise<string> {
if (tag.tagName === 'head') {
// See what hashes we came up with!
secondPass.rewriter.emitRaw(
`<meta http-equiv="Content-Security-Policy" content="${getStrictCsp(hashes)}">`,
`<meta http-equiv="Content-Security-Policy" content="${getStrictCsp(hashes, {
enableBrowserFallbacks: true,
enableTrustedTypes: false,
enableUnsafeEval: unsafeEval,
})}">`,
);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -43,6 +48,7 @@ export interface IndexHtmlGeneratorOptions {
cache?: NormalizedCachedOptions;
imageDomains?: string[];
generateDedicatedSSRContent?: boolean;
autoCsp?: AutoCspOptions;
}

export type IndexHtmlTransform = (content: string) => Promise<string>;
Expand Down Expand Up @@ -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));
dgp1130 marked this conversation as resolved.
Show resolved Hide resolved
}
}

async process(options: IndexHtmlGeneratorProcessOptions): Promise<IndexHtmlProcessResult> {
Expand Down Expand Up @@ -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);
}
Expand Down