Skip to content

Commit 24648b5

Browse files
authored
lib,esm: handle bypass network-import via data:
PR-URL: nodejs-private/node-private#522 Refs: https://hackerone.com/bugs?subject=nodejs&report_id=2092749 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com> CVE-ID: CVE-2024-22020 PR-URL: #53764 Reviewed-By: Yagiz Nizipli <yagiz.nizipli@sentry.io> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent c126a1f commit 24648b5

File tree

3 files changed

+164
-64
lines changed

3 files changed

+164
-64
lines changed

lib/internal/modules/esm/resolve.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,16 @@ function defaultResolve(specifier, context = {}) {
10671067
if (parsed != null) {
10681068
// Avoid accessing the `protocol` property due to the lazy getters.
10691069
protocol = parsed.protocol;
1070+
1071+
if (protocol === 'data:' &&
1072+
parsedParentURL.protocol !== 'file:' &&
1073+
experimentalNetworkImports) {
1074+
throw new ERR_NETWORK_IMPORT_DISALLOWED(
1075+
specifier,
1076+
parsedParentURL,
1077+
'import data: from a non file: is not allowed',
1078+
);
1079+
}
10701080
if (protocol === 'data:' ||
10711081
(experimentalNetworkImports &&
10721082
(
@@ -1078,7 +1088,6 @@ function defaultResolve(specifier, context = {}) {
10781088
return { __proto__: null, url: parsed.href };
10791089
}
10801090
}
1081-
10821091
// There are multiple deep branches that can either throw or return; instead
10831092
// of duplicating that deeply nested logic for the possible returns, DRY and
10841093
// check for a return. This seems the least gnarly.

test/es-module/test-http-imports.mjs

+153-63
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
// Flags: --experimental-network-imports --dns-result-order=ipv4first
22
import * as common from '../common/index.mjs';
3-
import { path, readKey } from '../common/fixtures.mjs';
4-
import { pathToFileURL } from 'url';
3+
import * as fixtures from '../common/fixtures.mjs';
4+
import tmpdir from '../common/tmpdir.js';
55
import assert from 'assert';
66
import http from 'http';
77
import os from 'os';
88
import util from 'util';
9+
import { describe, it } from 'node:test';
910

1011
if (!common.hasCrypto) {
1112
common.skip('missing crypto');
1213
}
14+
tmpdir.refresh();
1315

1416
const https = (await import('https')).default;
1517

@@ -18,8 +20,8 @@ const createHTTPServer = http.createServer;
1820
// Needed to deal w/ test certs
1921
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
2022
const options = {
21-
key: readKey('agent1-key.pem'),
22-
cert: readKey('agent1-cert.pem')
23+
key: fixtures.readKey('agent1-key.pem'),
24+
cert: fixtures.readKey('agent1-cert.pem')
2325
};
2426

2527
const createHTTPSServer = https.createServer.bind(null, options);
@@ -136,72 +138,14 @@ for (const { protocol, createServer } of [
136138
url.href + 'bar/baz.js'
137139
);
138140

139-
const crossProtocolRedirect = new URL(url.href);
140-
crossProtocolRedirect.searchParams.set('redirect', JSON.stringify({
141-
status: 302,
142-
location: 'data:text/javascript,'
143-
}));
144-
await assert.rejects(
145-
import(crossProtocolRedirect.href),
146-
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
147-
);
148-
149-
const deps = new URL(url.href);
150-
deps.searchParams.set('body', `
151-
export {data} from 'data:text/javascript,export let data = 1';
152-
import * as http from ${JSON.stringify(url.href)};
153-
export {http};
154-
`);
155-
const depsNS = await import(deps.href);
156-
assert.strict.deepStrictEqual(Object.keys(depsNS), ['data', 'http']);
157-
assert.strict.equal(depsNS.data, 1);
158-
assert.strict.equal(depsNS.http, ns);
159-
160-
const relativeDeps = new URL(url.href);
161-
relativeDeps.searchParams.set('body', `
162-
import * as http from "./";
163-
export {http};
164-
`);
165-
const relativeDepsNS = await import(relativeDeps.href);
166-
assert.strict.deepStrictEqual(Object.keys(relativeDepsNS), ['http']);
167-
assert.strict.equal(relativeDepsNS.http, ns);
168-
const fileDep = new URL(url.href);
169-
const { href } = pathToFileURL(path('/es-modules/message.mjs'));
170-
fileDep.searchParams.set('body', `
171-
import ${JSON.stringify(href)};
172-
export default 1;`);
173-
await assert.rejects(
174-
import(fileDep.href),
175-
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
176-
);
177-
178-
const builtinDep = new URL(url.href);
179-
builtinDep.searchParams.set('body', `
180-
import 'node:fs';
181-
export default 1;
182-
`);
183-
await assert.rejects(
184-
import(builtinDep.href),
185-
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
186-
);
187-
188-
const unprefixedBuiltinDep = new URL(url.href);
189-
unprefixedBuiltinDep.searchParams.set('body', `
190-
import 'fs';
191-
export default 1;
192-
`);
193-
await assert.rejects(
194-
import(unprefixedBuiltinDep.href),
195-
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
196-
);
197-
198141
const unsupportedMIME = new URL(url.href);
199142
unsupportedMIME.searchParams.set('mime', 'application/node');
200143
unsupportedMIME.searchParams.set('body', '');
201144
await assert.rejects(
202145
import(unsupportedMIME.href),
203146
{ code: 'ERR_UNKNOWN_MODULE_FORMAT' }
204147
);
148+
205149
const notFound = new URL(url.href);
206150
notFound.pathname = '/not-found';
207151
await assert.rejects(
@@ -216,6 +160,152 @@ for (const { protocol, createServer } of [
216160
assert.deepStrictEqual(Object.keys(json), ['default']);
217161
assert.strictEqual(json.default.x, 1);
218162

163+
await describe('guarantee data url will not bypass import restriction', () => {
164+
it('should not be bypassed by cross protocol redirect', async () => {
165+
const crossProtocolRedirect = new URL(url.href);
166+
crossProtocolRedirect.searchParams.set('redirect', JSON.stringify({
167+
status: 302,
168+
location: 'data:text/javascript,'
169+
}));
170+
await assert.rejects(
171+
import(crossProtocolRedirect.href),
172+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
173+
);
174+
});
175+
176+
it('should not be bypassed by data URL', async () => {
177+
const deps = new URL(url.href);
178+
deps.searchParams.set('body', `
179+
export {data} from 'data:text/javascript,export let data = 1';
180+
import * as http from ${JSON.stringify(url.href)};
181+
export {http};
182+
`);
183+
await assert.rejects(
184+
import(deps.href),
185+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
186+
);
187+
});
188+
189+
it('should not be bypassed by encodedURI import', async () => {
190+
const deepDataImport = new URL(url.href);
191+
deepDataImport.searchParams.set('body', `
192+
import 'data:text/javascript,import${encodeURIComponent(JSON.stringify('data:text/javascript,import "os"'))}';
193+
`);
194+
await assert.rejects(
195+
import(deepDataImport.href),
196+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
197+
);
198+
});
199+
200+
it('should not be bypassed by relative deps import', async () => {
201+
const relativeDeps = new URL(url.href);
202+
relativeDeps.searchParams.set('body', `
203+
import * as http from "./";
204+
export {http};
205+
`);
206+
const relativeDepsNS = await import(relativeDeps.href);
207+
assert.strict.deepStrictEqual(Object.keys(relativeDepsNS), ['http']);
208+
assert.strict.equal(relativeDepsNS.http, ns);
209+
});
210+
211+
it('should not be bypassed by file dependency import', async () => {
212+
const fileDep = new URL(url.href);
213+
const { href } = fixtures.fileURL('/es-modules/message.mjs');
214+
fileDep.searchParams.set('body', `
215+
import ${JSON.stringify(href)};
216+
export default 1;`);
217+
await assert.rejects(
218+
import(fileDep.href),
219+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
220+
);
221+
});
222+
223+
it('should not be bypassed by builtin dependency import', async () => {
224+
const builtinDep = new URL(url.href);
225+
builtinDep.searchParams.set('body', `
226+
import 'node:fs';
227+
export default 1;
228+
`);
229+
await assert.rejects(
230+
import(builtinDep.href),
231+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
232+
);
233+
});
234+
235+
236+
it('should not be bypassed by unprefixed builtin dependency import', async () => {
237+
const unprefixedBuiltinDep = new URL(url.href);
238+
unprefixedBuiltinDep.searchParams.set('body', `
239+
import 'fs';
240+
export default 1;
241+
`);
242+
await assert.rejects(
243+
import(unprefixedBuiltinDep.href),
244+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
245+
);
246+
});
247+
248+
it('should not be bypassed by indirect network import', async () => {
249+
const indirect = new URL(url.href);
250+
indirect.searchParams.set('body', `
251+
import childProcess from 'data:text/javascript,export { default } from "node:child_process"'
252+
export {childProcess};
253+
`);
254+
await assert.rejects(
255+
import(indirect.href),
256+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
257+
);
258+
});
259+
260+
it('data: URL can always import other data:', async () => {
261+
const data = new URL('data:text/javascript,');
262+
data.searchParams.set('body',
263+
'import \'data:text/javascript,import \'data:\''
264+
);
265+
// doesn't throw
266+
const empty = await import(data.href);
267+
assert.ok(empty);
268+
});
269+
270+
it('data: URL cannot import file: or builtin', async () => {
271+
const data1 = new URL(url.href);
272+
data1.searchParams.set('body',
273+
'import \'file:///some/file.js\''
274+
);
275+
await assert.rejects(
276+
import(data1.href),
277+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
278+
);
279+
280+
const data2 = new URL(url.href);
281+
data2.searchParams.set('body',
282+
'import \'node:fs\''
283+
);
284+
await assert.rejects(
285+
import(data2.href),
286+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
287+
);
288+
});
289+
290+
it('data: URL cannot import HTTP URLs', async () => {
291+
const module = fixtures.fileURL('/es-modules/import-data-url.mjs');
292+
try {
293+
await import(module);
294+
} catch (err) {
295+
// We only want the module to load, we don't care if the module throws an
296+
// error as long as the loader does not.
297+
assert.notStrictEqual(err?.code, 'ERR_MODULE_NOT_FOUND');
298+
}
299+
const data1 = new URL(url.href);
300+
const dataURL = 'data:text/javascript;export * from "node:os"';
301+
data1.searchParams.set('body', `export * from ${JSON.stringify(dataURL)};`);
302+
await assert.rejects(
303+
import(data1),
304+
{ code: 'ERR_NETWORK_IMPORT_DISALLOWED' }
305+
);
306+
});
307+
});
308+
219309
server.close();
220310
}
221311
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "data:text/javascript;export * from \"node:os\"";

0 commit comments

Comments
 (0)