Skip to content

Commit

Permalink
feat: support importmap integrity (#424)
Browse files Browse the repository at this point in the history
  • Loading branch information
guybedford committed Apr 28, 2024
1 parent 35da7f2 commit 1ac6306
Show file tree
Hide file tree
Showing 5 changed files with 47 additions and 15 deletions.
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

0 comments on commit 1ac6306

Please sign in to comment.