diff --git a/src/browser/modules/NativeModuleResolver.ts b/src/browser/modules/NativeModuleResolver.ts index 4383a27e0c..3cc45301e4 100644 --- a/src/browser/modules/NativeModuleResolver.ts +++ b/src/browser/modules/NativeModuleResolver.ts @@ -33,6 +33,7 @@ import type { } from "../../domain/webModuleTrace.js"; import { webModuleResolutionSchema } from "../../domain/webModuleTrace.js"; import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js"; +import { importMapForTrustedResolver } from "./importMapForTrustedResolver.js"; const OPERATION = "trace_web_module_imports"; const TIMEOUT_MS = 20000; @@ -367,7 +368,7 @@ const resolveInBrowser = async ( await route.fulfill({ status: 200, contentType: "text/html", - body: `${input.importMap === null ? "" : ``}`, + body: `${input.importMap === null ? "" : ``}`, }); } else if ( !stubSent && diff --git a/src/browser/modules/importMapForTrustedResolver.test.ts b/src/browser/modules/importMapForTrustedResolver.test.ts new file mode 100644 index 0000000000..c1ae0f6200 --- /dev/null +++ b/src/browser/modules/importMapForTrustedResolver.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { importMapForTrustedResolver } from "./importMapForTrustedResolver.js"; + +describe("importMapForTrustedResolver", () => { + const baseUrl = "https://app.test/index.html"; + const importerUrl = "https://app.test/main.js"; + + it("omits integrity that covers the selected importer URL", () => { + const map = { + imports: { dep: "/dep.js" }, + integrity: { + "https://app.test/main.js": "sha256-importer", + "https://app.test/dep.js": "sha256-dep", + }, + }; + expect(importMapForTrustedResolver(map, importerUrl, baseUrl)).toEqual({ + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/dep.js": "sha256-dep" }, + }); + }); + + it("resolves relative integrity keys against the import-map base URL", () => { + const map = { + imports: { dep: "./dep.js" }, + integrity: { + "./main.js": "sha256-importer", + "./dep.js": "sha256-dep", + }, + }; + expect( + importMapForTrustedResolver(map, "https://app.test/main.js", baseUrl), + ).toEqual({ + imports: { dep: "./dep.js" }, + integrity: { "./dep.js": "sha256-dep" }, + }); + }); + + it("matches importer integrity across query and fragment differences", () => { + const map = { + imports: { dep: "/dep.js" }, + integrity: { + "https://app.test/main.js": "sha256-importer", + "https://app.test/dep.js": "sha256-dep", + }, + }; + expect( + importMapForTrustedResolver( + map, + "https://app.test/main.js?build=1#entry", + baseUrl, + ), + ).toEqual({ + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/dep.js": "sha256-dep" }, + }); + }); + + it("drops an empty integrity member after removing the importer entry", () => { + const map = { + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/main.js": "sha256-importer" }, + }; + expect(importMapForTrustedResolver(map, importerUrl, baseUrl)).toEqual({ + imports: { dep: "/dep.js" }, + }); + }); + + it("preserves maps without integrity and dependency-only integrity", () => { + const without = { imports: { dep: "/dep.js" } }; + expect(importMapForTrustedResolver(without, importerUrl, baseUrl)).toBe( + without, + ); + const dependencyOnly = { + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/dep.js": "sha256-dep" }, + }; + expect( + importMapForTrustedResolver(dependencyOnly, importerUrl, baseUrl), + ).toBe(dependencyOnly); + }); + + it("preserves a dependency integrity key named __proto__ without prototype pollution", () => { + const integrity = Object.assign(Object.create(null), { + "https://app.test/main.js": "sha256-importer", + "https://app.test/dep.js": "sha256-dep", + }); + integrity["__proto__"] = "sha256-proto-dep"; + const map = { imports: { dep: "/dep.js" }, integrity }; + const next = importMapForTrustedResolver(map, importerUrl, baseUrl); + const nextIntegrity = next.integrity as { readonly [key: string]: string }; + expect(nextIntegrity["https://app.test/dep.js"]).toBe("sha256-dep"); + expect( + Object.getOwnPropertyDescriptor(nextIntegrity, "__proto__")?.value, + ).toBe("sha256-proto-dep"); + expect(Object.getPrototypeOf(nextIntegrity)).toBe(null); + expect(Object.prototype.hasOwnProperty.call({}, "__proto__")).toBe(false); + }); +}); diff --git a/src/browser/modules/importMapForTrustedResolver.ts b/src/browser/modules/importMapForTrustedResolver.ts new file mode 100644 index 0000000000..f4e254e2e8 --- /dev/null +++ b/src/browser/modules/importMapForTrustedResolver.ts @@ -0,0 +1,75 @@ +import type { JsonValue } from "../../domain/jsonValue.js"; + +/** + * Build the import map installed for REA's trusted resolver stub. + * + * Chromium applies import-map `integrity` to the module script URL. The + * resolver fulfills that URL with REA's replacement bytes, so integrity that + * covers the selected importer must not be installed. Dependency integrity and + * the caller-selected map identity remain unchanged in Evidence. + */ +export const importMapForTrustedResolver = ( + map: { readonly [key: string]: JsonValue }, + importerUrl: string, + baseUrl: string, +): { readonly [key: string]: JsonValue } => { + const integrity = map.integrity; + if ( + integrity === null || + typeof integrity !== "object" || + Array.isArray(integrity) + ) + return map; + const importer = importerModuleIdentity(importerUrl, baseUrl); + const nextIntegrity: { [key: string]: JsonValue } = Object.create(null); + let removed = false; + for (const [key, value] of Object.entries(integrity)) { + if (integrityKeyMatchesImporter(key, baseUrl, importer)) { + removed = true; + continue; + } + nextIntegrity[key] = value; + } + if (!removed) return map; + if (Object.keys(nextIntegrity).length === 0) { + const { integrity: _omit, ...rest } = map; + return rest; + } + return { ...map, integrity: nextIntegrity }; +}; + +const importerModuleIdentity = ( + importerUrl: string, + baseUrl: string, +): { readonly hrefs: ReadonlySet; readonly moduleKey: string } => { + const absolute = new URL(importerUrl, baseUrl); + const withoutHash = new URL(absolute.href); + withoutHash.hash = ""; + return { + hrefs: new Set([absolute.href, withoutHash.href]), + // Chromium's integrity lookup reports the module URL without search/hash. + moduleKey: `${absolute.origin}${absolute.pathname}`, + }; +}; + +const integrityKeyMatchesImporter = ( + key: string, + baseUrl: string, + importer: { + readonly hrefs: ReadonlySet; + readonly moduleKey: string; + }, +): boolean => { + try { + const resolved = new URL(key, baseUrl); + const withoutHash = new URL(resolved.href); + withoutHash.hash = ""; + return ( + importer.hrefs.has(resolved.href) || + importer.hrefs.has(withoutHash.href) || + `${resolved.origin}${resolved.pathname}` === importer.moduleKey + ); + } catch { + return false; + } +};