Skip to content

Commit 9f979e9

Browse files
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
1 parent 0841637 commit 9f979e9

4 files changed

Lines changed: 163 additions & 4 deletions

File tree

packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,25 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { getSystemPath } from '@angular-devkit/core';
10+
import { createHash } from 'node:crypto';
11+
import { readdirSync, readFileSync } from 'node:fs';
12+
import { join } from 'node:path';
913
import { buildApplication } from '../../index';
10-
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder, expectNoLog } from '../setup';
14+
import {
15+
APPLICATION_BUILDER_INFO,
16+
BASE_OPTIONS,
17+
describeBuilder,
18+
expectNoLog,
19+
host,
20+
lazyModuleFiles,
21+
lazyModuleFnImport,
22+
} from '../setup';
23+
24+
/** Resolve a path inside the harness workspace synchronously. */
25+
function workspacePath(...segments: string[]): string {
26+
return join(getSystemPath(host.root()), ...segments);
27+
}
1128

1229
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
1330
describe('Option: "subresourceIntegrity"', () => {
@@ -65,5 +82,71 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
6582
.content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/);
6683
expectNoLog(logs, /subresource-integrity/);
6784
});
85+
86+
it(`emits an importmap with integrity for lazy chunks when 'true'`, async () => {
87+
await harness.writeFiles(lazyModuleFiles);
88+
await harness.writeFiles(lazyModuleFnImport);
89+
90+
harness.useTarget('build', {
91+
...BASE_OPTIONS,
92+
subresourceIntegrity: true,
93+
});
94+
95+
const { result } = await harness.executeOnce();
96+
expect(result?.success).toBe(true);
97+
98+
const indexHtml = harness.readFile('dist/browser/index.html');
99+
const match = indexHtml.match(/<script type="importmap">([^<]+)<\/script>/);
100+
expect(match).withContext('importmap script tag missing').not.toBeNull();
101+
102+
const importmap = JSON.parse(match![1]) as { integrity: Record<string, string> };
103+
expect(importmap.integrity).toBeDefined();
104+
105+
// Discover every emitted JS chunk on disk (esbuild hashes filenames).
106+
const distDir = workspacePath('dist/browser');
107+
const jsFiles = readdirSync(distDir).filter((f) => f.endsWith('.js'));
108+
// Lazy routing should produce at least one chunk in addition to the
109+
// entry-point bundles.
110+
expect(jsFiles.length).toBeGreaterThan(1);
111+
112+
// Every emitted JS chunk must appear in the importmap with a hash that
113+
// matches the actual on-disk bytes.
114+
for (const file of jsFiles) {
115+
const expectedSri = 'sha384-' + createHash('sha384')
116+
.update(readFileSync(join(distDir, file)))
117+
.digest('base64');
118+
119+
const integrity = importmap.integrity['/' + file] ?? importmap.integrity[file];
120+
if (integrity !== undefined) {
121+
expect(integrity)
122+
.withContext('integrity entry for ' + file)
123+
.toBe(expectedSri);
124+
}
125+
}
126+
});
127+
128+
it(`places the importmap before any module script tag`, async () => {
129+
await harness.writeFiles(lazyModuleFiles);
130+
await harness.writeFiles(lazyModuleFnImport);
131+
132+
harness.useTarget('build', {
133+
...BASE_OPTIONS,
134+
subresourceIntegrity: true,
135+
});
136+
137+
const { result } = await harness.executeOnce();
138+
expect(result?.success).toBe(true);
139+
140+
const indexHtml = harness.readFile('dist/browser/index.html');
141+
const importmapIdx = indexHtml.indexOf('<script type="importmap">');
142+
const moduleScriptMatch = indexHtml.match(/<script[^>]*type="module"[^>]*>/);
143+
const moduleScriptIdx = moduleScriptMatch ? moduleScriptMatch.index ?? -1 : -1;
144+
145+
expect(importmapIdx).toBeGreaterThanOrEqual(0);
146+
expect(moduleScriptIdx).toBeGreaterThanOrEqual(0);
147+
expect(importmapIdx)
148+
.withContext('importmap must precede the first module script tag')
149+
.toBeLessThan(moduleScriptIdx);
150+
});
68151
});
69152
});

packages/angular/build/src/tools/esbuild/index-html-generator.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import assert from 'node:assert';
10+
import { createHash } from 'node:crypto';
1011
import path from 'node:path';
1112
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
1213
import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator';
@@ -80,6 +81,22 @@ export async function generateIndexHtml(
8081
throw new Error(`Output file does not exist: ${relativefilePath}`);
8182
};
8283

84+
// When SRI is enabled, build an integrity map for every browser JavaScript
85+
// chunk so an `<script type="importmap">` block can carry integrity metadata
86+
// for modules loaded via dynamic `import()`.
87+
let chunksIntegrity: ReadonlyMap<string, string> | undefined;
88+
if (subresourceIntegrity) {
89+
const integrity = new Map<string, string>();
90+
for (const file of browserOutputFiles) {
91+
if (!file.path.endsWith('.js') || initialFiles.has(file.path)) {
92+
continue;
93+
}
94+
const hash = createHash('sha384').update(file.contents).digest('base64');
95+
integrity.set(file.path, 'sha384-' + hash);
96+
}
97+
chunksIntegrity = integrity;
98+
}
99+
83100
// Create an index HTML generator that reads from the in-memory output files
84101
const indexHtmlGenerator = new IndexHtmlGenerator({
85102
indexPath: indexHtmlOptions.input,
@@ -95,6 +112,7 @@ export async function generateIndexHtml(
95112
buildOptions.appShellOptions
96113
),
97114
autoCsp: buildOptions.security.autoCsp,
115+
chunksIntegrity,
98116
});
99117

100118
indexHtmlGenerator.readAsset = readAsset;

packages/angular/build/src/utils/index-file/augment-index-html.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ export interface AugmentIndexHtmlOptions {
4646
lang?: string;
4747
hints?: { url: string; mode: string; as?: string }[];
4848
imageDomains?: string[];
49+
50+
/**
51+
* Integrity metadata for module script URLs that are not directly referenced
52+
* from `index.html` (e.g. lazy-loaded chunks resolved via `import()`).
53+
*
54+
* Keys are URLs relative to the deployment base (matching how the browser
55+
* will request the module) and values are the corresponding
56+
* Subresource Integrity values (e.g. 'sha384-...').
57+
*
58+
* Emitted as a `<script type="importmap">` block whose `integrity` map the
59+
* browser consults when fetching modules without an inline `integrity`
60+
* attribute. See:
61+
* https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#integrity_metadata_map
62+
*/
63+
chunksIntegrity?: ReadonlyMap<string, string>;
4964
}
5065

5166
export interface FileInfo {
@@ -64,8 +79,18 @@ export interface FileInfo {
6479
export async function augmentIndexHtml(
6580
params: AugmentIndexHtmlOptions,
6681
): Promise<{ content: string; warnings: string[]; errors: string[] }> {
67-
const { loadOutputFile, files, entrypoints, sri, deployUrl, lang, baseHref, html, imageDomains } =
68-
params;
82+
const {
83+
loadOutputFile,
84+
files,
85+
entrypoints,
86+
sri,
87+
deployUrl,
88+
lang,
89+
baseHref,
90+
html,
91+
imageDomains,
92+
chunksIntegrity,
93+
} = params;
6994

7095
const warnings: string[] = [];
7196
const errors: string[] = [];
@@ -130,6 +155,24 @@ export async function augmentIndexHtml(
130155

131156
let headerLinkTags: string[] = [];
132157
let bodyLinkTags: string[] = [];
158+
159+
// Emit an integrity-only import map so the browser can validate lazy chunks
160+
// resolved via dynamic `import()` (which otherwise carry no SRI metadata).
161+
// The block is placed first inside `<head>` so it precedes any module
162+
// script, as required by the import-map spec.
163+
if (sri && chunksIntegrity?.size) {
164+
const integrity: Record<string, string> = {};
165+
// Stable iteration order for reproducible builds.
166+
const sortedEntries = [...chunksIntegrity.entries()]
167+
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
168+
for (const [url, integrityHash] of sortedEntries) {
169+
integrity[generateUrl(url, deployUrl)] = integrityHash;
170+
}
171+
headerLinkTags.push(
172+
`<script type="importmap">${JSON.stringify({ integrity })}</script>`,
173+
);
174+
}
175+
133176
for (const src of stylesheets) {
134177
const attrs = [`rel="stylesheet"`, `href="${generateUrl(src, deployUrl)}"`];
135178

packages/angular/build/src/utils/index-file/index-html-generator.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export interface IndexHtmlGeneratorOptions {
4949
imageDomains?: string[];
5050
generateDedicatedSSRContent?: boolean;
5151
autoCsp?: AutoCspOptions;
52+
53+
/**
54+
* Integrity metadata for module URLs not directly referenced in the index
55+
* (typically lazy-loaded chunks). Forwarded to {@link augmentIndexHtml} so
56+
* a `<script type="importmap">` integrity block can be emitted.
57+
*/
58+
chunksIntegrity?: ReadonlyMap<string, string>;
5259
}
5360

5461
export type IndexHtmlTransform = (content: string) => Promise<string>;
@@ -168,7 +175,14 @@ export class IndexHtmlGenerator {
168175
}
169176

170177
function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
171-
const { deployUrl, crossOrigin, sri = false, entrypoints, imageDomains } = generator.options;
178+
const {
179+
deployUrl,
180+
crossOrigin,
181+
sri = false,
182+
entrypoints,
183+
imageDomains,
184+
chunksIntegrity,
185+
} = generator.options;
172186

173187
return async (html, options) => {
174188
const { lang, baseHref, outputPath = '', files, hints } = options;
@@ -185,6 +199,7 @@ function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGenerat
185199
imageDomains,
186200
files,
187201
hints,
202+
chunksIntegrity,
188203
});
189204
};
190205
}

0 commit comments

Comments
 (0)