diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 9c488e327a98..f1bca8cacc1d 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -12,7 +12,12 @@ import { realpathSync } from 'node:fs'; import { access, constants } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; -import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils'; +import { + normalizeAssetPatterns, + normalizeHashFuncNames, + normalizeOptimization, + normalizeSourceMaps, +} from '../../utils'; import { supportColor } from '../../utils/color'; import { useJSONBuildLogs, usePartialSsrBuild } from '../../utils/environment-options'; import { I18nOptions, createI18nOptions } from '../../utils/i18n-options'; @@ -182,6 +187,7 @@ export async function normalizeOptions( const assets = options.assets?.length ? normalizeAssetPatterns(options.assets, workspaceRoot, projectRoot, projectSourceRoot) : undefined; + const hashFuncNames = normalizeHashFuncNames(options.hashFuncNames); let fileReplacements: Record | undefined; if (options.fileReplacements) { @@ -429,6 +435,7 @@ export async function normalizeOptions( preserveSymlinks, stylePreprocessorOptions, subresourceIntegrity, + hashFuncNames, serverEntryPoint, prerenderOptions, appShellOptions, diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index a8e8e13a8016..f0509d7ea6c5 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -462,6 +462,14 @@ "description": "Enables the use of subresource integrity validation.", "default": false }, + "hashFuncNames": { + "description": "Overrides the default hash function names for subresource integrity validation.", + "type": "array", + "items": { + "type": "string" + }, + "default": ["sha384"] + }, "serviceWorker": { "description": "Generates a service worker configuration.", "default": false, diff --git a/packages/angular/build/src/builders/application/tests/options/hash-function-names_spect.ts b/packages/angular/build/src/builders/application/tests/options/hash-function-names_spect.ts new file mode 100644 index 000000000000..3004e5c44f0c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/hash-function-names_spect.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "hashFuncNames"', () => { + it(`does not add integrity attribute when not present`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.not.toContain('integrity='); + }); + + it(`uses default sri algo when not supplied`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('integrity=sha384'); + }); + + it(`will override hash algorithm if provided`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + hashFuncNames: ['sha256'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('integrity=sha256'); + }); + + it(`will use the most secure hash algorithm if multiple are provided`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + hashFuncNames: ['sha256', 'sha512'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('integrity=sha512'); + }); + }); +}); 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 4d11ed4fa45a..befc0a95a15c 100644 --- a/packages/angular/build/src/tools/esbuild/index-html-generator.ts +++ b/packages/angular/build/src/tools/esbuild/index-html-generator.ts @@ -39,6 +39,7 @@ export async function generateIndexHtml( optimizationOptions, crossOrigin, subresourceIntegrity, + hashFuncNames, baseHref, } = buildOptions; @@ -94,6 +95,7 @@ export async function generateIndexHtml( indexPath: indexHtmlOptions.input, entrypoints: indexHtmlOptions.insertionOrder, sri: subresourceIntegrity, + sriHashAlgo: hashFuncNames, optimization: optimizationOptions, crossOrigin: crossOrigin, deployUrl: buildOptions.publicPath, diff --git a/packages/angular/build/src/utils/index-file/augment-index-html.ts b/packages/angular/build/src/utils/index-file/augment-index-html.ts index 30d5f30d2b6e..d4acd679c3f9 100644 --- a/packages/angular/build/src/utils/index-file/augment-index-html.ts +++ b/packages/angular/build/src/utils/index-file/augment-index-html.ts @@ -11,6 +11,7 @@ import { extname } from 'node:path'; import { loadEsmModule } from '../load-esm'; import { htmlRewritingStream } from './html-rewriting-stream'; import { VALID_SELF_CLOSING_TAGS } from './valid-self-closing-tags'; +import { AllowedSRIHash } from '../normalize-hash-func-names'; export type LoadOutputFileFunctionType = (file: string) => Promise; @@ -24,6 +25,7 @@ export interface AugmentIndexHtmlOptions { baseHref?: string; deployUrl?: string; sri: boolean; + sriHashAlgo?: AllowedSRIHash; /** crossorigin attribute setting of elements that provide CORS support */ crossOrigin?: CrossOriginValue; 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 9bfb929c5d11..2eeee57551d8 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 @@ -17,6 +17,7 @@ import { InlineCriticalCssProcessor } from './inline-critical-css'; import { InlineFontsProcessor } from './inline-fonts'; import { addNgcmAttribute } from './ngcm-attribute'; import { addNonce } from './nonce'; +import { AllowedSRIHash } from '../normalize-hash-func-names'; type IndexHtmlGeneratorPlugin = ( html: string, @@ -41,6 +42,7 @@ export interface IndexHtmlGeneratorOptions { indexPath: string; deployUrl?: string; sri?: boolean; + sriHashAlgo?: AllowedSRIHash; entrypoints: Entrypoint[]; postTransform?: IndexHtmlTransform; crossOrigin?: CrossOriginValue; @@ -168,7 +170,14 @@ export class IndexHtmlGenerator { } function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { - const { deployUrl, crossOrigin, sri = false, entrypoints, imageDomains } = generator.options; + const { + deployUrl, + crossOrigin, + sri = false, + sriHashAlgo = 'sha384', + entrypoints, + imageDomains, + } = generator.options; return async (html, options) => { const { lang, baseHref, outputPath = '', files, hints } = options; @@ -179,6 +188,7 @@ function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGenerat deployUrl, crossOrigin, sri, + sriHashAlgo, lang, entrypoints, loadOutputFile: (filePath) => generator.readAsset(join(outputPath, filePath)), diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts index 1a7cb15cd9c3..ba4f556028a0 100644 --- a/packages/angular/build/src/utils/index.ts +++ b/packages/angular/build/src/utils/index.ts @@ -7,6 +7,7 @@ */ export * from './normalize-asset-patterns'; +export * from './normalize-hash-func-names'; export * from './normalize-optimization'; export * from './normalize-source-maps'; export * from './load-proxy-config'; diff --git a/packages/angular/build/src/utils/normalize-hash-func-names.ts b/packages/angular/build/src/utils/normalize-hash-func-names.ts new file mode 100644 index 000000000000..d1e03b98cfd8 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-hash-func-names.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @description checks parameter against hash names according to current OpenSSL + * and W3C acceptable integrity hashes + * @param hashName hash to check + * @returns true for valid hash names + */ +const hashIsValid = (hashName: string) => ['sha256', 'sha384', 'sha512'].includes(hashName); + +export type AllowedSRIHash = 'sha256' | 'sha384' | 'sha512'; + +// Considered best practice as of this writing +export const DEFAULT_SRI_HASH: AllowedSRIHash = 'sha384'; + +/** + * @description returns the strongest hash name from the given list + * @param hashes list of string hash names + * @returns string hash algorithm name + */ +export const getStrongestHash = (hashes: string[]): AllowedSRIHash => { + const filteredHashes = hashes.filter(hashIsValid) as AllowedSRIHash[]; + const sortedHashes = [...filteredHashes].sort(); + const strongestHashName = sortedHashes.pop() ?? DEFAULT_SRI_HASH; + + return strongestHashName; +}; + +export const normalizeHashFuncNames = (hashFuncs?: string[]): AllowedSRIHash => { + if (!hashFuncs || hashFuncs.length === 0) { + return DEFAULT_SRI_HASH; + } + + return getStrongestHash(hashFuncs); +}; diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts index 87f6603e06e2..8e440e432c66 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts @@ -254,7 +254,7 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise