Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support importmap integrity #424

Merged
merged 3 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ Using this polyfill we can write:
"/": {
"test-dep": "/test-dep.js"
}
},
"integrity": {
"/test.js": "sha386-..."
}
}
</script>
Expand All @@ -277,6 +280,10 @@ Using this polyfill we can write:

All modules are still loaded with the native browser module loader, but with their specifiers rewritten then executed as Blob URLs, so there is a relatively minimal overhead to using a polyfill approach like this.

#### Integrity

The `"integrity"` field for import maps is supported when possible, throwing an error in es-module-shims when the integrity does not match the expected value.

#### Multiple Import Maps

Multiple import maps are not currently supported in any native implementation, Chromium support is currently being tracked in https://bugs.chromium.org/p/chromium/issues/detail?id=927119.
Expand Down Expand Up @@ -636,17 +643,24 @@ This option can also be set to `true` to entirely disable the native passthrough

### Enforce Integrity

When enabled, `enforceIntegrity` will ensure that all modules loaded through ES Module Shims must have integrity defined either on a `<link rel="modulepreload" integrity="...">` or on
a `<link rel="modulepreload-shim" integrity="...">` preload tag in shim mode. Modules without integrity will throw at fetch time.
When enabled, `enforceIntegrity` will ensure that all modules loaded through ES Module Shims must have integrity defined either on a `<link rel="modulepreload" integrity="...">`, a `<link rel="modulepreload-shim" integrity="...">` preload tag in shim mode, or the `"integrity"` field in the import map. Modules without integrity will throw at fetch time.

For example in the following, only the listed `app.js` and `dep.js` modules will be able to execute with the provided integrity:
For example in the following, only the listed `app.js`, `dep.js` and `another.js` modules will be able to execute with the provided integrity:

```html
<script type="importmap">
{
"integrity": {
"/another.js": "sha384-..."
}
}
</script>
<script type="esms-options">{ "enforceIntegrity": true }</script>
<link rel="modulepreload-shim" href="/app.js" integrity="sha384-..." />\
<link rel="modulepreload-shim" href="/dep.js" integrity="sha384-..." />
<script type="module-shim">
import '/app.js';
import '/another.js';
</script>
```

Expand Down
5 changes: 3 additions & 2 deletions src/es-module-shims.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async function loadAll (load, seen) {
load.n = load.d.some(dep => dep.l.n);
}

let importMap = { imports: {}, scopes: {} };
let importMap = { imports: {}, scopes: {}, integrity: {} };
let baselinePassthrough;

const initPromise = featureDetectionPromise.then(() => {
Expand Down Expand Up @@ -458,7 +458,8 @@ async function doFetch (url, fetchOpts, parent) {
}

async function fetchModule (url, fetchOpts, parent) {
const res = await doFetch(url, fetchOpts, parent);
const mapIntegrity = importMap.integrity[url];
const res = await doFetch(url, mapIntegrity && !fetchOpts.integrity ? Object.assign({}, fetchOpts, { integrity: mapIntegrity }) : fetchOpts, parent);
const r = res.url;
const contentType = res.headers.get('content-type');
if (jsContentType.test(contentType))
Expand Down
17 changes: 14 additions & 3 deletions src/resolve.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { mapOverrides, shimMode } from './env.js';

export let importMap = { imports: Object.create(null), scopes: Object.create(null) };

const backslashRegEx = /\\/g;

export function asURL (url) {
Expand Down Expand Up @@ -100,7 +98,7 @@ export function resolveIfNotPlainOrUrl (relUrl, parentUrl) {
}

export function resolveAndComposeImportMap (json, baseUrl, parentMap) {
const outMap = { imports: Object.assign({}, parentMap.imports), scopes: Object.assign({}, parentMap.scopes) };
const outMap = { imports: Object.assign({}, parentMap.imports), scopes: Object.assign({}, parentMap.scopes), integrity: Object.assign({}, parentMap.integrity) };

if (json.imports)
resolveAndComposePackages(json.imports, outMap.imports, baseUrl, parentMap, null);
Expand All @@ -111,6 +109,9 @@ export function resolveAndComposeImportMap (json, baseUrl, parentMap) {
resolveAndComposePackages(json.scopes[s], outMap.scopes[resolvedScope] || (outMap.scopes[resolvedScope] = {}), baseUrl, parentMap);
}

if (json.integrity)
resolveAndComposeIntegrity(json.integrity, outMap.integrity, baseUrl);

return outMap;
}

Expand Down Expand Up @@ -163,3 +164,13 @@ function resolveAndComposePackages (packages, outPackages, baseUrl, parentMap) {
console.warn(`Mapping "${p}" -> "${packages[p]}" does not resolve`);
}
}

function resolveAndComposeIntegrity (integrity, outIntegrity, baseUrl) {
for (let p in integrity) {
const resolvedLhs = resolveIfNotPlainOrUrl(p, baseUrl) || p;
if ((!shimMode || !mapOverrides) && outIntegrity[resolvedLhs] && (outIntegrity[resolvedLhs] !== integrity[resolvedLhs])) {
throw Error(`Rejected map integrity override "${resolvedLhs}" from ${outIntegrity[resolvedLhs]} to ${integrity[resolvedLhs]}.`);
}
outIntegrity[resolvedLhs] = integrity[p];
}
}
11 changes: 7 additions & 4 deletions test/shim.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ suite('Get import map', () => {
const sortEntriesByKey = (entries) => [...entries].sort(([key1], [key2]) => key1.localeCompare(key2));
const baseURL = document.location.href.replace(/\/test\/.*/, '/');

assert.equal(JSON.stringify(Object.keys(importMap)), JSON.stringify(["imports", "scopes"]));
assert.equal(JSON.stringify(Object.keys(importMap)), JSON.stringify(["imports", "scopes", "integrity"]));
assert.equal(
JSON.stringify(sortEntriesByKey(Object.entries(importMap.imports))),
JSON.stringify(sortEntriesByKey(Object.entries({
Expand Down Expand Up @@ -376,6 +376,9 @@ suite('Errors', function () {
"scheduler": "https://ga.jspm.io/npm:scheduler@0.20.2/dev.index.js",
"scheduler/tracing": "https://ga.jspm.io/npm:scheduler@0.20.2/dev.tracing.js"
}
},
"integrity": {
"//ga.jspm.io/npm:scheduler@0.20.2/dev.index.js": "sha384-qF0Jy83btjdPADN4QLKKmk/aUUyJnDqT+kYomKiUQk4nWrBsHVkM67Pua+8nHYUt"
}
});
const [React, ReactDOM] = await Promise.all([
Expand All @@ -394,7 +397,7 @@ suite('Errors', function () {
});
const lodash = await importShim("lodash");
assert.ok(lodash);
})
});

test('Dynamic import map shim with override attempt', async function () {
const listeningForError = new Promise((resolve, reject) => {
Expand All @@ -415,7 +418,7 @@ suite('Errors', function () {
removeImportMap();

assert(error.message.match(new RegExp(String.raw`Rejected map override \"global1\" from http://[^/]+/test/fixtures/es-modules/global1.js to data:text/javascript,throw new Error\('Shim should not allow dynamic import map to override existing entries'\);\.`)));
})
});

test('Dynamic import map shim with override to the same mapping is allowed', async function () {
const expectingNoError = new Promise((resolve, reject) => {
Expand All @@ -436,7 +439,7 @@ suite('Errors', function () {
await expectingNoError;

removeImportMap();
})
});

function insertDynamicImportMap(importMap) {
const script = Object.assign(document.createElement('script'), {
Expand Down
9 changes: 6 additions & 3 deletions test/test-shim.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"/test/fixtures/es-modules/import-relative-path.js": {
"./fixtures/es-modules/relative-path": "./fixtures/es-modules/es6-dep.js"
}
},
"integrity": {
"./fixtures/es-modules/es6-dep.js": "sha384-WBlJ+EO4b/8SuvC2RFiCt9z42esd35mcYuzHGIlqiltBvS6X11jqp06aksdZWflh"
}
}
</script>
Expand Down Expand Up @@ -66,14 +69,14 @@
</script>
<script>
window.resolveHook = (id, parentUrl, defaultResolve) => defaultResolve(id, parentUrl);
window.fetchHook = url => fetch(url);
window.fetchHook = (url, opts) => fetch(url, opts);
window.esmsInitOptions = {
shimMode: true,
resolve (id, parentUrl, defaultResolve) {
return window.resolveHook(id, parentUrl, defaultResolve);
},
fetch (url) {
return window.fetchHook(url);
fetch (url, opts) {
return window.fetchHook(url, opts);
},
onerror: e => window.e = e,
};
Expand Down
Loading