Skip to content

Commit

Permalink
feat(@angular/build): Auto-CSP support as a part of angular.json schema
Browse files Browse the repository at this point in the history
Following up on the logic provided in #28639, we want to offer an opt-in
option in angular.json to enable the auto-CSP transformation.

For now, builds for `ng serve` will have Auto-CSP disabled.
  • Loading branch information
aaronshim authored and dgp1130 committed Oct 22, 2024
1 parent b893a6a commit efb4341
Show file tree
Hide file tree
Showing 7 changed files with 69 additions and 2 deletions.
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": {
"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));
}
}

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

0 comments on commit efb4341

Please sign in to comment.