From bc88ad99dd83c8b1b34de920b34fb82220c920ed Mon Sep 17 00:00:00 2001 From: Dennis Kugelmann Date: Thu, 30 Apr 2026 15:24:54 +0200 Subject: [PATCH] feat(@angular/build): subresource integrity validation for dynamically loaded modules Adds support for verifying the integrity of dynamically loaded sub-resources by generating and pre-pending an import map with integrity hashes in the index.html Closes #30724 Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> style: Fix lint issues test: Fix missing lazy modules resulting in test failures test: Adjust test to validate more specific assumptions fix: Escape < in generated importmap JSON style: fix all formatting issues in angular-build refactor: Inject importmap after base tag --- .../options/subresource-integrity_spec.ts | 98 ++++++++++++++++++- .../angular/compilation/jit-compilation.ts | 5 +- .../src/tools/esbuild/index-html-generator.ts | 18 ++++ .../build/src/tools/esbuild/watcher.ts | 2 +- .../utils/index-file/augment-index-html.ts | 54 +++++++++- .../index-file/augment-index-html_spec.ts | 16 +++ .../utils/index-file/index-html-generator.ts | 17 +++- .../build/src/utils/server-rendering/utils.ts | 4 +- 8 files changed, 205 insertions(+), 9 deletions(-) diff --git a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts index f3ec9476b21f..5153045dba73 100644 --- a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts @@ -6,8 +6,25 @@ * found in the LICENSE file at https://angular.dev/license */ +import { getSystemPath } from '@angular-devkit/core'; +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { buildApplication } from '../../index'; -import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder, expectNoLog } from '../setup'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectNoLog, + host, + lazyModuleFiles, + lazyModuleFnImport, +} from '../setup'; + +/** Resolve a path inside the harness workspace synchronously. */ +function workspacePath(...segments: string[]): string { + return join(getSystemPath(host.root()), ...segments); +} describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { describe('Option: "subresourceIntegrity"', () => { @@ -65,5 +82,84 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { .content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); expectNoLog(logs, /subresource-integrity/); }); + + it(`emits an importmap with integrity for lazy chunks when 'true'`, async () => { + await harness.writeFiles(lazyModuleFiles); + await harness.writeFiles(lazyModuleFnImport); + + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + const indexHtml = harness.readFile('dist/browser/index.html'); + const match = indexHtml.match(/`); } + let subResourceIntegrityTag: string | undefined; let headerLinkTags: string[] = []; let bodyLinkTags: string[] = []; + + // Emit an integrity-only import map so the browser can validate lazy chunks + // resolved via dynamic `import()` (which otherwise carry no SRI metadata). + // The block is placed first inside `` so it precedes any module + // script, as required by the import-map spec. + if (sri && chunksIntegrity?.size) { + const integrity: Record = {}; + // Stable iteration order for reproducible builds. + const sortedEntries = [...chunksIntegrity.entries()].sort(([keyA], [keyB]) => + keyA.localeCompare(keyB), + ); + for (const [url, integrityHash] of sortedEntries) { + integrity[generateUrl(url, deployUrl)] = integrityHash; + } + const importMapJson = JSON.stringify({ integrity }).replace(/${importMapJson}`; + } + for (const src of stylesheets) { const attrs = [`rel="stylesheet"`, `href="${generateUrl(src, deployUrl)}"`]; @@ -212,6 +256,9 @@ export async function augmentIndexHtml( if (!baseTagExists && isString(baseHref)) { rewriter.emitStartTag(tag); rewriter.emitRaw(``); + if (subResourceIntegrityTag) { + rewriter.emitRaw(subResourceIntegrityTag); + } return; } @@ -221,6 +268,9 @@ export async function augmentIndexHtml( if (isString(baseHref)) { updateAttribute(tag, 'href', baseHref); } + if (subResourceIntegrityTag) { + rewriter.emitRaw(subResourceIntegrityTag); + } break; case 'link': if (readAttribute(tag, 'rel') === 'preconnect') { diff --git a/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts index 55adf8d88f0b..d9a5d1a753d4 100644 --- a/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts +++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts @@ -459,6 +459,22 @@ describe('augment-index-html', () => { ); }); + it('should escape `<` characters in inline importmap JSON', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + sri: true, + chunksIntegrity: new Map([['lazy([^<]+)<\/script>/); + expect(match).withContext('importmap script tag missing').not.toBeNull(); + expect(match![1]).toContain('lazy\\u003cchunk.js'); + expect(match![1]).not.toContain('lazy { const imageDomains = ['https://www.example.com', 'https://www.example2.com']; const { content, warnings } = await augmentIndexHtml({ 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 52a926ef58eb..fcece48647ae 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 @@ -49,6 +49,13 @@ export interface IndexHtmlGeneratorOptions { imageDomains?: string[]; generateDedicatedSSRContent?: boolean; autoCsp?: AutoCspOptions; + + /** + * Integrity metadata for module URLs not directly referenced in the index + * (typically lazy-loaded chunks). Forwarded to {@link augmentIndexHtml} so + * a `