isolated-function 0.1.45 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@
21
21
  - [Minimal privilege execution](#minimal-privilege-execution)
22
22
  - [Granting specific permissions](#granting-specific-permissions)
23
23
  - [Auto install dependencies](#auto-install-dependencies)
24
+ - [Restricting allowed dependencies](#restricting-allowed-dependencies)
24
25
  - [Execution profiling](#execution-profiling)
25
26
  - [Resource limits](#resource-limits)
26
27
  - [Logging](#logging)
@@ -34,6 +35,8 @@
34
35
  - [timeout](#timeout)
35
36
  - [tmpdir](#tmpdir)
36
37
  - [allow](#allow)
38
+ - [permissions](#permissions)
39
+ - [dependencies](#dependencies)
37
40
  - [=\> (fn(\[...args\]), teardown())](#-fnargs-teardown)
38
41
  - [fn](#fn)
39
42
  - [teardown](#teardown)
@@ -42,13 +45,15 @@
42
45
  - [`DEBUG`](#debug)
43
46
  - [License](#license)
44
47
 
45
- ## Install
48
+
49
+
50
+ # Install
46
51
 
47
52
  ```bash
48
53
  npm install isolated-function --save
49
54
  ```
50
55
 
51
- ## Quickstart
56
+ # Quickstart
52
57
 
53
58
  **isolated-function** is a modern solution for running untrusted code in Node.js.
54
59
 
@@ -68,7 +73,7 @@ const { value, profiling } = await sum(3, 2)
68
73
  await teardown()
69
74
  ```
70
75
 
71
- ### Minimal privilege execution
76
+ ## Minimal privilege execution
72
77
 
73
78
  The hosted code runs in a separate process, with minimal privilege, using [Node.js permission model API](https://nodejs.org/api/permissions.html#permission-model).
74
79
 
@@ -91,9 +96,9 @@ If you exceed your limit, an error will occur. Any of the following interaction
91
96
  - File system access
92
97
  - WASI
93
98
 
94
- ### Granting specific permissions
99
+ ## Granting specific permissions
95
100
 
96
- You can grant specific permissions to the isolated function using the `allow` option:
101
+ You can grant specific permissions to the isolated function using the `allow.permissions` option:
97
102
 
98
103
  ```js
99
104
  const [fn, teardown] = isolatedFunction(
@@ -102,7 +107,7 @@ const [fn, teardown] = isolatedFunction(
102
107
  return execSync('echo hello').toString().trim()
103
108
  },
104
109
  {
105
- allow: ['child-process']
110
+ allow: { permissions: ['child-process'] }
106
111
  }
107
112
  )
108
113
 
@@ -111,9 +116,9 @@ console.log(value) // 'hello'
111
116
  await teardown()
112
117
  ```
113
118
 
114
- See [#allow](#allow) to know more.
119
+ See [#allow.permissions](#permissions) to know more.
115
120
 
116
- ### Auto install dependencies
121
+ ## Auto install dependencies
117
122
 
118
123
  The hosted code is parsed for detecting `require`/`import` calls and install these dependencies:
119
124
 
@@ -131,7 +136,45 @@ await teardown()
131
136
 
132
137
  The dependencies, along with the hosted code, are bundled by [esbuild](https://esbuild.github.io/) into a single file that will be evaluated at runtime.
133
138
 
134
- ### Execution profiling
139
+ ## Restricting allowed dependencies
140
+
141
+ When running untrusted code, you should restrict which npm packages can be installed to prevent supply chain attacks:
142
+
143
+ ```js
144
+ const [fn, teardown] = isolatedFunction(
145
+ input => {
146
+ const isEmoji = require('is-standard-emoji')
147
+ return isEmoji(input)
148
+ },
149
+ {
150
+ allow: { dependencies: ['is-standard-emoji', 'lodash'] }
151
+ }
152
+ )
153
+
154
+ await fn('🙌') // => true
155
+ await teardown()
156
+ ```
157
+
158
+ If the code tries to require a package not in the allowed list, an `UntrustedDependencyError` is thrown **before** any npm install happens:
159
+
160
+ ```js
161
+ const [fn, teardown] = isolatedFunction(
162
+ () => {
163
+ const malicious = require('malicious-package')
164
+ return malicious()
165
+ },
166
+ {
167
+ allow: { dependencies: ['lodash'] }
168
+ }
169
+ )
170
+
171
+ await fn()
172
+ // => UntrustedDependencyError: Dependency 'malicious-package' is not in the allowed list
173
+ ```
174
+
175
+ > **Security Note**: Even with the sandbox, arbitrary package installation is dangerous because npm packages can execute code during installation via `preinstall`/`postinstall` scripts. The `--ignore-scripts` flag is used to mitigate this, but providing an `allow.dependencies` whitelist is the recommended approach for running untrusted code.
176
+
177
+ ## Execution profiling
135
178
 
136
179
  Any hosted code execution will be run in their own separate process:
137
180
 
@@ -160,7 +203,7 @@ console.log(profiling)
160
203
 
161
204
  Each execution has a profiling, which helps understand what happened.
162
205
 
163
- ### Resource limits
206
+ ## Resource limits
164
207
 
165
208
  You can limit a **isolated-function** by memory:
166
209
 
@@ -194,7 +237,7 @@ await fn(100)
194
237
  // => TimeoutError: Execution timed out
195
238
  ```
196
239
 
197
- ### Logging
240
+ ## Logging
198
241
 
199
242
  The logs are collected into a `logging` object returned after the execution:
200
243
 
@@ -220,7 +263,7 @@ console.log(logging)
220
263
  // }
221
264
  ```
222
265
 
223
- ### Error handling
266
+ ## Error handling
224
267
 
225
268
  Any error during **isolated-function** execution will be propagated:
226
269
 
@@ -248,27 +291,27 @@ if (!isFufilled) {
248
291
  }
249
292
  ```
250
293
 
251
- ## API
294
+ # API
252
295
 
253
- ### isolatedFunction(code, [options])
296
+ ## isolatedFunction(code, [options])
254
297
 
255
- #### code
298
+ ### code
256
299
 
257
300
  _Required_<br>
258
301
  Type: `function`
259
302
 
260
303
  The hosted function to run.
261
304
 
262
- #### options
305
+ ### options
263
306
 
264
- ##### memory
307
+ #### memory
265
308
 
266
309
  Type: `number`<br>
267
310
  Default: `Infinity`
268
311
 
269
312
  Set the function memory limit, in megabytes.
270
313
 
271
- ##### throwError
314
+ #### throwError
272
315
 
273
316
  Type: `boolean`<br>
274
317
  Default: `false`
@@ -279,14 +322,14 @@ The error will be accessible against `{ value: error, isFufilled: false }` objec
279
322
 
280
323
  Set the function memory limit, in megabytes.
281
324
 
282
- ##### timeout
325
+ #### timeout
283
326
 
284
327
  Type: `number`<br>
285
328
  Default: `Infinity`
286
329
 
287
330
  Timeout after a specified amount of time, in milliseconds.
288
331
 
289
- ##### tmpdir
332
+ #### tmpdir
290
333
 
291
334
  Type: `function`<br>
292
335
 
@@ -303,7 +346,30 @@ const tmpdir = async () => {
303
346
  }
304
347
  ```
305
348
 
306
- ##### allow
349
+ #### allow
350
+
351
+ Type: `object`<br>
352
+ Default: `{}`
353
+
354
+ Configuration object for allowed permissions and dependencies.
355
+
356
+ ```js
357
+ const [fn, cleanup] = isolatedFunction(
358
+ () => {
359
+ const { execSync } = require('child_process')
360
+ const lodash = require('lodash')
361
+ return lodash.uniq([1, 2, 2, 3])
362
+ },
363
+ {
364
+ allow: {
365
+ permissions: ['child-process'],
366
+ dependencies: ['lodash']
367
+ }
368
+ }
369
+ )
370
+ ```
371
+
372
+ ##### permissions
307
373
 
308
374
  Type: `string[]`<br>
309
375
  Default: `[]`
@@ -330,38 +396,62 @@ const [fn, cleanup] = isolatedFunction(
330
396
  // Network request code here
331
397
  },
332
398
  {
333
- allow: ['net']
399
+ allow: { permissions: ['net'] }
400
+ }
401
+ )
402
+ ```
403
+
404
+ ##### dependencies
405
+
406
+ Type: `string[]`<br>
407
+ Default: `undefined`
408
+
409
+ A whitelist of npm package names that are allowed to be installed. When provided, only packages in this list can be required/imported by the isolated function.
410
+
411
+ This is a critical security feature when running untrusted code, as it prevents arbitrary package installation which could lead to remote code execution via malicious packages.
412
+
413
+ ```js
414
+ const [fn, cleanup] = isolatedFunction(
415
+ () => {
416
+ const lodash = require('lodash')
417
+ const axios = require('axios')
418
+ return lodash.get({ a: 1 }, 'a')
419
+ },
420
+ {
421
+ allow: { dependencies: ['lodash', 'axios'] }
334
422
  }
335
423
  )
336
424
  ```
337
425
 
338
- ### => (fn([...args]), teardown())
426
+ When `allow.dependencies` is not provided, any package can be installed (default behavior for backwards compatibility).
427
+
428
+ ## => (fn([...args]), teardown())
339
429
 
340
- #### fn
430
+ ### fn
341
431
 
342
432
  Type: `function`
343
433
 
344
434
  The isolated function to execute. You can pass arguments over it.
345
435
 
346
- #### teardown
436
+ ### teardown
347
437
 
348
438
  Type: `function`
349
439
 
350
440
  A function to be called to release resources associated with the **isolated-function**.
351
441
 
352
- ## Environment Variables
442
+ # Environment Variables
353
443
 
354
- #### `ISOLATED_FUNCTIONS_MINIFY`
444
+ ### `ISOLATED_FUNCTIONS_MINIFY`
355
445
 
356
446
  Default: `true`
357
447
 
358
448
  When is `false`, it disabled minify the compiled code.
359
449
 
360
- #### `DEBUG`
450
+ ### `DEBUG`
361
451
 
362
452
  Pass `DEBUG=isolated-function` for enabling debug timing output.
363
453
 
364
- ## License
454
+ # License
365
455
 
366
456
  **isolated-function** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/isolated-function/blob/master/LICENSE.md) License.<br>
367
457
  Authored and maintained by Kiko Beats with help from [contributors](https://github.com/Kikobeats/isolated-function/contributors).
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "isolated-function",
3
3
  "description": "Runs untrusted code in a Node.js v8 sandbox.",
4
4
  "homepage": "https://github.com/Kikobeats/isolated-function",
5
- "version": "0.1.45",
5
+ "version": "0.1.47",
6
6
  "types": "src/index.d.ts",
7
7
  "main": "src/index.js",
8
8
  "exports": {
@@ -19,14 +19,14 @@ const tmpdirDefault = async () => {
19
19
  return { cwd, cleanup }
20
20
  }
21
21
 
22
- module.exports = async (snippet, tmpdir = tmpdirDefault) => {
22
+ module.exports = async (snippet, { tmpdir = tmpdirDefault, allow = {} } = {}) => {
23
23
  let content = template(snippet)
24
24
  const { cwd, cleanup } = await tmpdir()
25
25
 
26
26
  const dependencies = detectDependencies(content)
27
27
  if (dependencies.length) {
28
28
  content = transformDependencies(content)
29
- await duration('npm:install', () => installDependencies({ dependencies, cwd }), {
29
+ await duration('npm:install', () => installDependencies({ dependencies, cwd, allow }), {
30
30
  dependencies
31
31
  })
32
32
  }
@@ -41,3 +41,4 @@ module.exports = async (snippet, tmpdir = tmpdirDefault) => {
41
41
 
42
42
  module.exports.detectDependencies = detectDependencies
43
43
  module.exports.transformDependencies = transformDependencies
44
+ module.exports.UntrustedDependencyError = installDependencies.UntrustedDependencyError
@@ -10,10 +10,50 @@ const install = (() => {
10
10
  .trim()
11
11
  return 'pnpm install --no-lockfile --prefer-offline --ignore-workspace-root-check --ignore-scripts --engine-strict=false'
12
12
  } catch {
13
- return 'npm install --no-package-lock --silent'
13
+ return 'npm install --no-package-lock --ignore-scripts --silent'
14
14
  }
15
15
  })()
16
16
 
17
- module.exports = async ({ dependencies, cwd }) => {
17
+ class UntrustedDependencyError extends Error {
18
+ constructor (dependency) {
19
+ super(`Dependency '${dependency}' is not in the allowed list`)
20
+ this.name = 'UntrustedDependencyError'
21
+ this.dependency = dependency
22
+ }
23
+ }
24
+
25
+ const extractPackageName = dependency => {
26
+ if (dependency.startsWith('@')) {
27
+ const slashIndex = dependency.indexOf('/')
28
+ if (slashIndex !== -1) {
29
+ const atVersionIndex = dependency.indexOf('@', slashIndex)
30
+ if (atVersionIndex !== -1) {
31
+ return dependency.substring(0, atVersionIndex)
32
+ }
33
+ }
34
+ } else {
35
+ const atVersionIndex = dependency.indexOf('@')
36
+ if (atVersionIndex !== -1) {
37
+ return dependency.substring(0, atVersionIndex)
38
+ }
39
+ }
40
+ return dependency
41
+ }
42
+
43
+ const validateDependencies = (dependencies, allowed) => {
44
+ if (!allowed) return
45
+
46
+ for (const dependency of dependencies) {
47
+ const packageName = extractPackageName(dependency)
48
+ if (!allowed.includes(packageName)) {
49
+ throw new UntrustedDependencyError(packageName)
50
+ }
51
+ }
52
+ }
53
+
54
+ module.exports = async ({ dependencies, cwd, allow = {} }) => {
55
+ validateDependencies(dependencies, allow.dependencies)
18
56
  return $(`${install} ${dependencies.join(' ')}`, { cwd, env: { ...process.env, CI: true } })
19
57
  }
58
+
59
+ module.exports.UntrustedDependencyError = UntrustedDependencyError
package/src/index.d.ts CHANGED
@@ -44,6 +44,23 @@ export interface FailureResult {
44
44
  */
45
45
  export type ExecutionResult<T = unknown> = SuccessResult<T> | FailureResult
46
46
 
47
+ /**
48
+ * Allow configuration for permissions and dependencies
49
+ */
50
+ export interface AllowOptions {
51
+ /**
52
+ * Array of Node.js permissions to grant to the isolated function.
53
+ * Available permissions: addons, child-process, fs-read, fs-write, inspector, net, wasi, worker
54
+ */
55
+ permissions?: string[]
56
+ /**
57
+ * List of allowed npm package names that can be installed.
58
+ * When provided, only these packages can be required/imported.
59
+ * This prevents arbitrary package installation which could lead to RCE.
60
+ */
61
+ dependencies?: string[]
62
+ }
63
+
47
64
  /**
48
65
  * Options for creating an isolated function
49
66
  */
@@ -56,8 +73,8 @@ export interface IsolatedFunctionOptions {
56
73
  memory?: number
57
74
  /** When false, errors are returned instead of thrown */
58
75
  throwError?: boolean
59
- /** Array of resources to allow access to */
60
- allow?: string[]
76
+ /** Configuration for allowed permissions and dependencies */
77
+ allow?: AllowOptions
61
78
  }
62
79
 
63
80
  /**
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { deserializeError } = require('serialize-error')
3
+ const { deserializeError, serializeError } = require('serialize-error')
4
4
  const timeSpan = require('@kikobeats/time-span')()
5
5
  const { Readable } = require('node:stream')
6
6
  const $ = require('tinyspawn')
@@ -19,16 +19,17 @@ const [nodeMajor] = process.version.slice(1).split('.').map(Number)
19
19
 
20
20
  const PERMISSION_FLAG = nodeMajor >= 24 ? '--permission' : '--experimental-permission'
21
21
 
22
- const flags = ({ memory, allow }) => {
22
+ const flags = ({ memory, permissions }) => {
23
23
  const flags = ['--disable-warning=ExperimentalWarning', PERMISSION_FLAG]
24
24
  if (memory) flags.push(`--max-old-space-size=${memory}`)
25
- allow.forEach(resource => flags.push(`--allow-${resource}`))
25
+ permissions.forEach(resource => flags.push(`--allow-${resource}`))
26
26
  return flags.join(' ')
27
27
  }
28
28
 
29
- module.exports = (snippet, { tmpdir, timeout, memory, throwError = true, allow = [] } = {}) => {
29
+ module.exports = (snippet, { tmpdir, timeout, memory, throwError = true, allow = {} } = {}) => {
30
30
  if (!['function', 'string'].includes(typeof snippet)) throw new TypeError('Expected a function')
31
- const compilePromise = compile(snippet, tmpdir)
31
+ const { permissions = [] } = allow
32
+ const compilePromise = compile(snippet, { tmpdir, allow })
32
33
 
33
34
  const fn = async (...args) => {
34
35
  let duration
@@ -39,7 +40,7 @@ module.exports = (snippet, { tmpdir, timeout, memory, throwError = true, allow =
39
40
  const subprocess = $('node', ['-', JSON.stringify(args)], {
40
41
  env: {
41
42
  ...process.env,
42
- NODE_OPTIONS: flags({ memory, allow })
43
+ NODE_OPTIONS: flags({ memory, permissions })
43
44
  },
44
45
  timeout,
45
46
  killSignal: 'SIGKILL'
@@ -61,7 +62,7 @@ module.exports = (snippet, { tmpdir, timeout, memory, throwError = true, allow =
61
62
  })()
62
63
  : { isFulfilled: false, value: deserializeError(value), profiling, logging }
63
64
  } catch (error) {
64
- console.log('DEBUG ERROR', error)
65
+ debug.error(serializeError(error))
65
66
  if (error.signalCode === 'SIGTRAP') {
66
67
  throw createError({
67
68
  name: 'MemoryError',