npm install isolated-function --saveisolated-function is a modern solution for running untrusted code in Node.js.
const isolatedFunction = require('isolated-function')
/* create an isolated-function, with resources limitation */
const [sum, teardown] = isolatedFunction((y, z) => y + z, {
memory: 128, // in MB
timeout: 10000 // in milliseconds
})
/* interact with the isolated-function */
const { value, profiling } = await sum(3, 2)
/* close resources associated with the isolated-function initialization */
await teardown()The hosted code runs in a separate process, with minimal privilege, using Node.js permission model API.
const [fn, teardown] = isolatedFunction(() => {
const fs = require('fs')
fs.writeFileSync('/etc/passwd', 'foo')
})
await fn()
// => PermissionError: Access to 'FileSystemWrite' has been restricted.If you exceed your limit, an error will occur. Any of the following interaction will throw an error:
- Native modules
- Child process
- Worker Threads
- Inspector protocol
- File system access
- WASI
You can grant specific permissions to the isolated function using the allow.permissions option:
const [fn, teardown] = isolatedFunction(
() => {
const { execSync } = require('child_process')
return execSync('echo hello').toString().trim()
},
{
allow: { permissions: ['child-process'] }
}
)
const { value } = await fn()
console.log(value) // 'hello'
await teardown()See #allow.permissions to know more.
The hosted code is parsed for detecting require/import calls and install these dependencies:
const [isEmoji, teardown] = isolatedFunction(input => {
/* this dependency only exists inside the isolated function */
const isEmoji = require('[email protected]') // default is latest
return isEmoji(input)
})
await isEmoji('🙌') // => true
await isEmoji('foo') // => false
await teardown()The dependencies, along with the hosted code, are bundled by esbuild into a single file that will be evaluated at runtime.
When running untrusted code, you should restrict which npm packages can be installed to prevent supply chain attacks:
const [fn, teardown] = isolatedFunction(
input => {
const isEmoji = require('is-standard-emoji')
return isEmoji(input)
},
{
allow: { dependencies: ['is-standard-emoji', 'lodash'] }
}
)
await fn('🙌') // => true
await teardown()If the code tries to require a package not in the allowed list, a DependencyUnallowedError is thrown before any npm install happens:
const [fn, teardown] = isolatedFunction(
() => {
const malicious = require('malicious-package')
return malicious()
},
{
allow: { dependencies: ['lodash'] }
}
)
await fn()
// => DependencyUnallowedError: Dependency 'malicious-package' is not in the allowed listSecurity Note: Even with the sandbox, arbitrary package installation is dangerous because npm packages can execute code during installation via
preinstall/postinstallscripts. The--ignore-scriptsflag is used to mitigate this, but providing anallow.dependencieswhitelist is the recommended approach for running untrusted code.
Any hosted code execution will be run in their own separate process:
/** make a function to consume ~128MB */
const [fn, teardown] = isolatedFunction(() => {
const storage = []
const oneMegabyte = 1024 * 1024
while (storage.length < 78) {
const array = new Uint8Array(oneMegabyte)
for (let ii = 0; ii < oneMegabyte; ii += 4096) {
array[ii] = 1
}
storage.push(array)
}
})
t.teardown(cleanup)
const { value, profiling } = await fn()
console.log(profiling)
// {
// memory: 128204800,
// duration: 54.98325
// }Each execution has a profiling, which helps understand what happened.
You can limit a isolated-function by memory:
const [fn, teardown] = isolatedFunction(() => {
const storage = []
const oneMegabyte = 1024 * 1024
while (storage.length < 78) {
const array = new Uint8Array(oneMegabyte)
for (let ii = 0; ii < oneMegabyte; ii += 4096) {
array[ii] = 1
}
storage.push(array)
}
}, { memory: 64 })
await fn()
// => MemoryError: Out of memoryor by execution duration:
const [fn, teardown] = isolatedFunction(() => {
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
await delay(duration)
return 'done'
}, { timeout: 50 })
await fn(100)
// => TimeoutError: Execution timed outThe logs are collected into a logging object returned after the execution:
const [fn, teardown] = isolatedFunction(() => {
console.log('console.log')
console.info('console.info')
console.debug('console.debug')
console.warn('console.warn')
console.error('console.error')
return 'done'
})
const { logging } await fn()
console.log(logging)
// {
// log: ['console.log'],
// info: ['console.info'],
// debug: ['console.debug'],
// warn: ['console.warn'],
// error: ['console.error']
// }Any error during isolated-function execution will be propagated:
const [fn, cleanup] = isolatedFunction(() => {
throw new TypeError('oh no!')
})
const result = await fn()
// TypeError: oh no!You can also return the error instead of throwing it with { throwError: false }:
const [fn, cleanup] = isolatedFunction(() => {
throw new TypeError('oh no!')
})
const { isFullfiled, value } = await fn()
if (!isFufilled) {
console.error(value)
// TypeError: oh no!
}Required
Type: function
The hosted function to run.
Type: number
Default: Infinity
Set the function memory limit, in megabytes.
Type: boolean
Default: false
When is true, it returns the error rather than throw it.
The error will be accessible against { value: error, isFufilled: false } object.
Set the function memory limit, in megabytes.
Type: number
Default: Infinity
Timeout after a specified amount of time, in milliseconds.
Type: function
It setup the temporal folder to be used for installing code dependencies.
The default implementation is:
const tmpdir = async () => {
const cwd = await fs.mkdtemp(path.join(require('os').tmpdir(), 'compile-'))
await fs.mkdir(cwd, { recursive: true })
const cleanup = () => fs.rm(cwd, { recursive: true, force: true })
return { cwd, cleanup }
}Type: object
Default: {}
Configuration object for allowed permissions and dependencies.
const [fn, cleanup] = isolatedFunction(
() => {
const { execSync } = require('child_process')
const lodash = require('lodash')
return lodash.uniq([1, 2, 2, 3])
},
{
allow: {
permissions: ['child-process'],
dependencies: ['lodash']
}
}
)Type: string[]
Default: []
An array of permissions to grant to the isolated function based on Node.js Options
When empty, the function runs with minimal privileges and will throw an error if it attempts to access restricted resources. Available permissions are:
addonschild-processfs-readfs-writeinspectornetwasiworker
Example:
const [fn, cleanup] = isolatedFunction(
async () => {
const http = require('node:http')
// Network request code here
},
{
allow: { permissions: ['net'] }
}
)Type: string[]
Default: undefined
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.
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.
const [fn, cleanup] = isolatedFunction(
() => {
const lodash = require('lodash')
const axios = require('axios')
return lodash.get({ a: 1 }, 'a')
},
{
allow: { dependencies: ['lodash', 'axios'] }
}
)When allow.dependencies is not provided, any package can be installed (default behavior for backwards compatibility).
Type: function
The isolated function to execute. You can pass arguments over it.
Type: function
A function to be called to release resources associated with the isolated-function.
Default: true
When is false, it disabled minify the compiled code.
Pass DEBUG=isolated-function for enabling debug timing output.
isolated-function © Kiko Beats, released under the MIT License.
Authored and maintained by Kiko Beats with help from contributors.
kikobeats.com · GitHub @Kiko Beats · X @Kikobeats
