Skip to content

Commit d9b6d29

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 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 fix: Lint failures in tests
1 parent ba9500e commit d9b6d29

8 files changed

Lines changed: 205 additions & 9 deletions

File tree

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

Lines changed: 97 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,84 @@ 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+
const initialJsFiles = new Set<string>();
106+
for (const [, src] of indexHtml.matchAll(/<script[^>]*src="([^"]+)"[^>]*>/g)) {
107+
initialJsFiles.add(src);
108+
}
109+
for (const [, href] of indexHtml.matchAll(
110+
/<link[^>]*rel="modulepreload"[^>]*href="([^"]+)"[^>]*>/g,
111+
)) {
112+
initialJsFiles.add(href);
113+
}
114+
115+
const distDir = workspacePath('dist/browser');
116+
const lazyJsFiles = readdirSync(distDir).filter(
117+
(f) => f.endsWith('.js') && !initialJsFiles.has(f),
118+
);
119+
expect(lazyJsFiles.length)
120+
.withContext('expected at least one non-initial (lazy) JS file')
121+
.toBeGreaterThan(0);
122+
123+
const importmapFiles = Object.keys(importmap.integrity);
124+
expect(importmapFiles.sort())
125+
.withContext('importmap integrity keys should match emitted lazy JS files')
126+
.toEqual(lazyJsFiles.sort());
127+
128+
for (const [file, integrity] of Object.entries(importmap.integrity)) {
129+
const expectedSri =
130+
'sha384-' +
131+
createHash('sha384')
132+
.update(readFileSync(join(distDir, file)))
133+
.digest('base64');
134+
135+
expect(integrity)
136+
.withContext('importmap integrity hash should match for emitted lazy JS file ' + file)
137+
.toBe(expectedSri);
138+
}
139+
});
140+
141+
it(`places the importmap before any module script tag`, async () => {
142+
await harness.writeFiles(lazyModuleFiles);
143+
await harness.writeFiles(lazyModuleFnImport);
144+
145+
harness.useTarget('build', {
146+
...BASE_OPTIONS,
147+
subresourceIntegrity: true,
148+
});
149+
150+
const { result } = await harness.executeOnce();
151+
expect(result?.success).toBe(true);
152+
153+
const indexHtml = harness.readFile('dist/browser/index.html');
154+
const importmapIdx = indexHtml.indexOf('<script type="importmap">');
155+
const moduleScriptMatch = indexHtml.match(/<script[^>]*type="module"[^>]*>/);
156+
const moduleScriptIdx = moduleScriptMatch ? (moduleScriptMatch.index ?? -1) : -1;
157+
158+
expect(importmapIdx).toBeGreaterThanOrEqual(0);
159+
expect(moduleScriptIdx).toBeGreaterThanOrEqual(0);
160+
expect(importmapIdx)
161+
.withContext('importmap must precede the first module script tag')
162+
.toBeLessThan(moduleScriptIdx);
163+
});
68164
});
69165
});

packages/angular/build/src/tools/angular/compilation/jit-compilation.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,8 @@ export class JitCompilation extends AngularCompilation {
4343
referencedFiles: readonly string[];
4444
}> {
4545
// Dynamically load the Angular compiler CLI package
46-
const { constructorParametersDownlevelTransform } = await import(
47-
'@angular/compiler-cli/private/tooling'
48-
);
46+
const { constructorParametersDownlevelTransform } =
47+
await import('@angular/compiler-cli/private/tooling');
4948

5049
// Load the compiler configuration and transform as needed
5150
const {

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/tools/esbuild/watcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function createWatcher(options?: {
4141
followSymlinks?: boolean;
4242
}): BuildWatcher {
4343
const watcher = new WatchPack({
44-
poll: options?.polling ? options?.interval ?? true : false,
44+
poll: options?.polling ? (options?.interval ?? true) : false,
4545
ignored: options?.ignored,
4646
followSymlinks: options?.followSymlinks,
4747
aggregateTimeout: 250,

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

Lines changed: 52 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[] = [];
@@ -128,8 +153,27 @@ export async function augmentIndexHtml(
128153
scriptTags.push(`<script ${attrs.join(' ')}></script>`);
129154
}
130155

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

@@ -212,6 +256,9 @@ export async function augmentIndexHtml(
212256
if (!baseTagExists && isString(baseHref)) {
213257
rewriter.emitStartTag(tag);
214258
rewriter.emitRaw(`<base href="${baseHref}">`);
259+
if (subResourceIntegrityTag) {
260+
rewriter.emitRaw(subResourceIntegrityTag);
261+
}
215262

216263
return;
217264
}
@@ -221,6 +268,9 @@ export async function augmentIndexHtml(
221268
if (isString(baseHref)) {
222269
updateAttribute(tag, 'href', baseHref);
223270
}
271+
if (subResourceIntegrityTag) {
272+
rewriter.emitRaw(subResourceIntegrityTag);
273+
}
224274
break;
225275
case 'link':
226276
if (readAttribute(tag, 'rel') === 'preconnect') {

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,22 @@ describe('augment-index-html', () => {
459459
);
460460
});
461461

462+
it('should escape `<` characters in inline importmap JSON', async () => {
463+
const { content } = await augmentIndexHtml({
464+
...indexGeneratorOptions,
465+
sri: true,
466+
chunksIntegrity: new Map([['lazy<chunk.js', 'sha384-abc']]),
467+
});
468+
469+
const match = content.match(/<script type="importmap">([^<]+)<\/script>/);
470+
expect(match).withContext('importmap script tag missing').not.toBeNull();
471+
expect(match?.[1]).toContain('lazy\\u003cchunk.js');
472+
expect(match?.[1]).not.toContain('lazy<chunk.js');
473+
expect(JSON.parse(match?.[1] ?? '{}')).toEqual({
474+
integrity: { 'lazy<chunk.js': 'sha384-abc' },
475+
});
476+
});
477+
462478
it('should add image domain preload tags', async () => {
463479
const imageDomains = ['https://www.example.com', 'https://www.example2.com'];
464480
const { content, warnings } = await augmentIndexHtml({

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
}

packages/angular/build/src/utils/server-rendering/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
*/
88

99
import type { createRequestHandler } from '@angular/ssr';
10-
import type { createNodeRequestHandler } from '@angular/ssr/node' with { 'resolution-mode': 'import' };
10+
import type { createNodeRequestHandler } from '@angular/ssr/node' with {
11+
'resolution-mode': 'import',
12+
};
1113

1214
export function isSsrNodeRequestHandler(
1315
value: unknown,

0 commit comments

Comments
 (0)