isolated-function 0.1.46 → 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 +119 -29
- package/package.json +1 -1
- package/src/compile/index.js +3 -2
- package/src/compile/install-dependencies.js +42 -2
- package/src/index.d.ts +19 -2
- package/src/index.js +6 -5
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
|
-
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Install
|
|
46
51
|
|
|
47
52
|
```bash
|
|
48
53
|
npm install isolated-function --save
|
|
49
54
|
```
|
|
50
55
|
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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](#
|
|
119
|
+
See [#allow.permissions](#permissions) to know more.
|
|
115
120
|
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
+
# API
|
|
252
295
|
|
|
253
|
-
|
|
296
|
+
## isolatedFunction(code, [options])
|
|
254
297
|
|
|
255
|
-
|
|
298
|
+
### code
|
|
256
299
|
|
|
257
300
|
_Required_<br>
|
|
258
301
|
Type: `function`
|
|
259
302
|
|
|
260
303
|
The hosted function to run.
|
|
261
304
|
|
|
262
|
-
|
|
305
|
+
### options
|
|
263
306
|
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
+
# Environment Variables
|
|
353
443
|
|
|
354
|
-
|
|
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
|
-
|
|
450
|
+
### `DEBUG`
|
|
361
451
|
|
|
362
452
|
Pass `DEBUG=isolated-function` for enabling debug timing output.
|
|
363
453
|
|
|
364
|
-
|
|
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.
|
|
5
|
+
"version": "0.1.47",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
7
|
"main": "src/index.js",
|
|
8
8
|
"exports": {
|
package/src/compile/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
60
|
-
allow?:
|
|
76
|
+
/** Configuration for allowed permissions and dependencies */
|
|
77
|
+
allow?: AllowOptions
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
/**
|
package/src/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
43
|
+
NODE_OPTIONS: flags({ memory, permissions })
|
|
43
44
|
},
|
|
44
45
|
timeout,
|
|
45
46
|
killSignal: 'SIGKILL'
|