{"slug": "secure-eval-worker-least-privilege-javascript-execution-in-node-js", "title": "Secure-Eval-Worker: Least-Privilege JavaScript Execution in Node.js", "summary": "Platformatic released secure-eval-worker, an open-source Node.js library that runs JavaScript scripts, ES modules, and trusted local modules inside worker threads with only the permissions the host explicitly grants, using Node's Permission Model and bounded worker lifetimes. The library offers runUntrustedCode() for single-use execution and createUntrustedWorker() for persistent components, copying data by value and supplying a defined environment instead of inheriting process.env. Platformatic cautions that Node's Permission Model and worker threads do not fully isolate malicious code, so risky multi-tenant workloads should run the library inside a separate sandboxed process or container with OS-level CPU, memory, filesystem, network, and syscall limits.", "body_md": "# Introducing secure-eval-worker\n\nLeast-Privilege JavaScript Execution in Node.js\n\nRunning JavaScript at runtime is becoming more common. Tasks like AI-generated changes, user automation, plugins, and programmable app parts all work better when code runs near the data and APIs it uses.\n\nThe main challenge is controlling authority. A regular eval() has the same permissions as its host, and even if you move the code to a worker thread, it still has access to the filesystem, network, environment, and other Node.js features unless you remove them yourself.\n\n[secure-eval-worker](https://github.com/platformatic/secure-eval-worker) is a new library that lets you run JavaScript scripts, ES modules, and trusted local modules in Node.js workers with only the permissions you choose. It works for both single-use code and longer-running components that talk to their host.\n\nThis package uses Node’s Permission Model, clear host capabilities, and limited worker lifetimes. The aim isn’t to create a perfect JavaScript sandbox, but to make least privilege the standard when running code natively in Node.js.\n\n**Security boundary:** Node’s Permission Model and worker threads add extra layers of defense, but they don’t fully isolate malicious code. Workers still share the same process, and V8 worker limits don’t cover every possible failure. For risky multi-tenant workloads, use this library inside a separate sandboxed process or container with OS-level limits on CPU, memory, filesystem, network, and syscalls.\n\n## **Why another way to evaluate JavaScript?**\n\nNode.js already offers several ways to run code dynamically, but they don’t always make it clear what the code is allowed to do.\n\n`eval()` and new `Function()` run in the current process and inherit all the app’s permissions. A regular worker thread helps manage the code’s lifecycle and keeps heavy tasks off the main event loop, but it still starts with wide access to Node.js features unless you remove them yourself.\n\nFor programmable components, you usually want a much tighter set of permissions:\n\n- receive a bounded input;\n- compute or retain state;\n- call a few application-defined operations;\n- return values or emit messages;\n- stop after a deadline; and\n- remain unable to turn those operations into general filesystem, network, process, or worker access.\n\n`secure-eval-worker` is designed for this kind of contract. Guest code only gets the data and specific capabilities you give it, instead of inheriting all the app’s permissions.\n\n## **A quick look at the API**\n\nThe simplest API is runUntrustedCode(). It starts a new worker, runs the code as an async function, returns the result, and then shuts down the worker:\n\nimport { runUntrustedCode } from 'secure-eval-worker'\n\n``` js\nimport { runUntrustedCode } from 'secure-eval-worker'\nconst total = await runUntrustedCode('return input.values.reduce((sum, value) => sum + value, 0)', {\n input: { values: [10, 20, 12] },\n timeoutMs: 500,\n environment: { LANG: 'C' }\n})\n\nconsole.log(total) // 42\n```\n\nThe guest code gets a single argument called `input`. Data sent in and out is copied by value, not by reference, so shared memory isn’t allowed. The worker also gets a specific environment instead of inheriting `process.env`.\n\nEach time you call it, a new worker is created.\n\n## **Persistent components, not just single-use functions**\n\nSome tasks need to keep state and handle ongoing messages, not just return one result. `createUntrustedWorker()` lets you make a persistent component with a set lifetime and handles messages one at a time:\n\n``` js\nimport { createUntrustedWorker } from 'secure-eval-worker'\n\nconst counter = createUntrustedWorker(\n `\n let value = input.initialValue\n\n send({ status: 'ready', value })\n\n onMessage((message) => {\n   if (message.type === 'increment') {\n     value += message.amount\n   }\n\n   return { value }\n })\n`,\n {\n   input: { initialValue: 40 },\n   startupTimeoutMs: 1000,\n   messageTimeoutMs: 500,\n   lifetimeTimeoutMs: 60_000\n }\n)\n\ncounter.on('message', message => {\n console.log('component message:', message)\n})\n\nawait counter.ready\nconsole.log(await counter.request({ type: 'increment', amount: 2 }))\n// { value: 42 }\n\nawait counter.terminate()\n```\n\nScript components get `input`, `send()`, and `onMessage()`. You can also use self-contained ES modules, as long as the module exports a setup function that takes these operations. This keeps normal JavaScript features like lexical state, timers, async functions, and built-in modules, as long as they fit within the worker’s limited permissions.\n\nEach request has a deadline that includes cloning, queuing, and handling. If the deadline passes, the whole session ends, since you can’t safely interrupt CPU-heavy JavaScript in a worker without risking its state.\n\n## **Authority is provided through host functions**\n\nUseful code often needs to do more than just calculations. It might need to fetch a record, check a feature flag, log an event, or call an app service. If you give it a general database or network client, you’re giving back the broad permissions you wanted to avoid.\n\nHost functions offer a more limited and controlled way to give authority:\n\n``` js\nconst result = await runUntrustedCode('return records.find(input.id)', {\n input: { id: 'record-42' },\n hostFunctions: {\n   records: {\n     find: async id => database.records.find(id)\n   }\n }\n})\n```\n\nThe guest sees `records.find()` as a promise-returning function. The host retains control over the implementation, credentials, authorization, validation, and output. Namespaces and functions are installed as read-only globals, and malformed or accessor-based capability definitions are rejected before the worker starts.\n\nThis is capability-oriented design: expose `records.find(id)`, `not fetch(url)`; expose `audit.record(event)`, not unrestricted database access.\n\nHost functions still grant authority, so their code must check every argument from the guest and make sure the right tenant or user is used in trusted closures. Session and request IDs help with tracing, but they aren’t used for authorization. Also, writes should be idempotent at the app level, since canceling a request can’t undo a side effect that’s already happened.\n\nFor host operations that can be canceled, `getHostFunctionContext()` gives you an `AbortSignal` that triggers if the session is canceled, times out, fails, or ends. Any unexpected host errors are hidden before reaching the guest. Applications should only throw `HostFunctionError` when it’s safe to share a specific message and code.\n\n## **What happens before guest code runs**\n\nThe key security rule is simple: remove all extra permissions before compiling, importing, or running any code provided by the caller.\n\nA source-string execution follows this sequence:\n\n1. **Acquire admission.** The process reserves a worker slot before inspecting caller-controlled options or cloning input.\n2. **Validate the boundary.** Source size, options, environment, resource limits, host functions, and protocol values are checked against explicit allowlists and limits.\n3. **Construct a restricted worker.** The worker starts with Node’s Permission Model enabled, only the worker permission, an explicit environment, and V8 resource limits.\n4. **Drop nested-worker authority.** The trusted bootstrap captures the primitives it needs and immediately calls`process.permission.drop('worker')` .\n5. **Close ambient communication.** It creates private channels, closes and hides`parentPort` , and removes access to inherited worker environment data.\n6. **Harden known same-process surfaces.** Filesystem, network, process mutation, loader hooks, native bindings, cross-thread messaging, runtime inspection, and other reviewed escape surfaces are disabled before guest execution.\n7. **Compile and run the guest.** Only after the permission drop and hardening does the bootstrap compile a script or import a self-contained module.\n\nThe order is important. If you validate code after it starts, or drop permissions after importing a module, there’s a window where the code has more authority than you want.\n\nThe security hardening is tied to specific Node.js versions. The first release works with Node.js 26.3.0 through the current 26.x, since it needs `process.permission.drop()`. Future major Node.js versions will need a new review, since new features or APIs could change what’s accessible.\n\n## **A protocol designed for an untrusted participant**\n\nA worker boundary also separates data and control. Guest code shouldn’t be able to sneak in shared authority, fake lifecycle messages, replay old responses, or use up memory by sending unlimited output.\n\n`secure-eval-worker` creates a private `MessageChannel` inside the trusted bootstrap and authenticates messages with a per-session HMAC and monotonic sequence number. Authentication covers the exact V8-serialized bytes, not a second reconstruction of the value. Capturing or replaying a message cannot produce a valid new control operation.\n\nValues are intentionally narrower than everything Node’s structured clone can carry. Supported values include primitives, plain objects and arrays, `ArrayBuffer` and non-shared views, `Date`, `RegExp`, `Map`, and `Set`. The boundary rejects:\n\n- `SharedArrayBuffer` and other shared-memory representations;\n- custom class instances and authority-bearing platform objects;\n- accessors, symbols, and non-enumerable properties on data objects;\n- ports, file handles, sockets, and cryptographic key objects; and\n- ordinary `Error` values, whose native serialization can disclose unwanted implementation details.\n\nIf guest code throws an error, it becomes an `UntrustedCodeError` on the host. The remote stack trace can be kept as limited diagnostic text, but since it comes from the guest, it shouldn’t be trusted as proof of what happened.\n\nBy default, console output from guest code is ignored and not sent to app logs. Diagnostics are optional and use a separate secure channel with limits on record size and total bytes. Formatting avoids reading object properties or running custom inspection, and any terminal control characters are escaped.\n\n## **Local modules without granting the source tree**\n\nSelf-contained source strings provide the narrowest filesystem policy: no filesystem permission remains when guest execution begins. They work well for generated code or module graphs bundled by a trusted host.\n\nExisting components may need Node’s native ESM loader, relative imports, dynamic imports, or package resolution. For those cases, `runUntrustedFile()` and `createUntrustedWorkerFromFile()` accept an entry file and a trusted root:\n\n``` js\nimport { runUntrustedFile } from 'secure-eval-worker'\n\nconst result = await runUntrustedFile('./components/calculate.mjs', {\n rootDirectory: './components',\n input: { values: [10, 20, 12] },\n timeoutMs: 500\n})\n```\n\nBefore starting the worker, the host copies the allowed files into a private temporary folder. It skips symbolic links and special files, checks that files are really inside the allowed folder, and enforces limits on the number and size of files. The worker can only read from this snapshot, not from the original source path.\n\nThis setup stops later changes to the source tree from redirecting imports outside the copied files. The guest can use Node’s loader and a limited synchronous `node:fs` read inside the snapshot. It can’t write files, use promise-based filesystem APIs, read outside the snapshot, inherit file descriptors, create nested workers, or use native addons.\n\n`rootDirectory` gives the guest access to all copied files, so don’t include secrets or unrelated files in that folder. The source tree and anything that can change it must be trusted during setup, and the temporary folder and any process with the same identity must be trusted while the snapshot exists. Node.js APIs can’t always guarantee files stay contained if directories are renamed or processes race, and the copy isn’t a true atomic snapshot. If these risks matter, use an immutable app-owned tree and an OS sandbox with a separate identity.\n\n## **Bound the entire lifecycle, not just execution time**\n\nA timeout by itself isn’t enough to control resources. This package sets limits at several stages:\n\n| Boundary | Default behavior | \n| Concurrent admitted workers | 4, fail-fast with no internal queue | \n| Source size | 64 KiB | \n| Setup input | 1 MiB serialized | \n| Protocol message | 1 MiB serialized | \n| Unsolicited messages and attempted host calls | 1,024 messages and 16 MiB cumulatively | \n| Host-function calls | 256 total and 32 in flight | \n| Persistent startup | 1 second | \n| Persistent request | 1 second | \n| Persistent lifetime | 30 seconds | \n\nThe one-shot `timeoutMs` covers input cloning, startup, and execution. For local files, it also covers staging. If cloning or native work can’t be stopped in the middle, any timeout is reported right after, and the worker might take extra time to fully exit.\n\nAdmission remains occupied until the worker actually exits, even if the public termination deadline has already produced an error. This prevents slow termination from allowing the process to exceed its configured worker limit. Sandbox workers must be created from the process main thread; creation from a host worker thread fails closed. Local workers also have descriptor caps of 64 per worker and 256 process-wide, independent of worker admission.\n\nThe default worker limit is set low on purpose. On Linux x86-64, each worker added about 12–20 MiB of memory in tests with Node.js 26. This isn’t a universal rule or a performance benchmark. You should measure your own app’s needs before changing the limit.\n\nV8 resource limits and admission control can’t cover every possible memory or CPU issue, like ArrayBuffers, WebAssembly, native code, or host functions. That’s another reason to use process or container isolation for risky workloads.\n\n## **Why one-shot workers are not pooled**\n\nStarting a worker takes resources, so pooling might seem like a good idea. The problem is making sure a used Node.js environment is really clean and safe for new untrusted code.\n\nA previous guest could change global objects, fill module caches, add listeners or timers, leave unfinished tasks, or keep data in closures. Permissions, environment, resource limits, secrets, counters, and output limits are all set up for each worker when it’s created.\n\nTrying to clean up after each use isn’t the same as having a true reset. Until Node.js offers a fully disposable environment with the same permission controls, `secure-eval-worker` creates a new worker for every one-shot call. Persistent workers are created on purpose, so you control their state and how long they live.\n\n## **Testing the security boundary**\n\nThe repository tests the same exploit matrix across one-shot scripts, persistent scripts, source modules, one-shot files, and persistent file modules. The suite covers permission denial, inherited descriptors, process and V8 surfaces, cross-thread APIs, shared memory, protocol limits, host-function failures, lifecycle races, local-root containment, and cleanup behavior. CI runs on Ubuntu, macOS, and Windows across the supported Node.js 26 range.\n\nThese tests show regression evidence, not a guarantee or security certification. Runtime protections need to keep up with Node.js changes, and any claims about deployment only apply to the versions and platforms that have been reviewed and tested.\n\n## **Getting started**\n\nYou can find the source code, API docs, threat model notes, and test suite in the [platformatic/secure-eval-worker](https://github.com/platformatic/secure-eval-worker) repository. Right now, the project is only available from source and hasn’t been published to npm yet. You can install the current version directly from GitHub:\n\n```\nnpm install secure-eval-worker\n```\n\nThe package currently works with Node.js 26.3.0 through the latest 26.x release. Start by using a one-shot source string without host functions, and only add the minimum capabilities your component needs. If you use local modules, set up a dedicated, unchangeable root folder. Set admission limits based on your workload, not by allowing unlimited concurrency.\n\n## **Final thoughts**\n\n`secure-eval-worker` changes dynamic JavaScript execution from a broad-permission task to an explicit contract: you send in limited data, grant only specific capabilities, get back authenticated messages, and keep control of the lifecycle on the host side.\n\nIts most important design choices are also the most straightforward:\n\n- permissions are removed before guest code runs;\n- capabilities are explicit and application-defined;\n- protocol values and output are bounded;\n- capacity is reserved before attacker-controlled work;\n- slots are retained until resources really exit; and\n- fresh one-shot workers are preferred over an unverifiable reset.\n\nIf your app needs generated transformations, programmable workflows, or stateful JavaScript components, [secure-eval-worker](https://github.com/platformatic/secure-eval-worker) gives you a practical way to use least privilege. Use it as one part of your security setup, keep permissions as limited as possible, and add process or container isolation when needed.", "url": "https://wpnews.pro/news/secure-eval-worker-least-privilege-javascript-execution-in-node-js", "canonical_source": "https://blog.platformatic.dev/introducing-secure-eval-worker", "published_at": "2026-09-17 18:30:47+00:00", "updated_at": "2026-09-17 18:55:49.297052+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-safety"], "entities": ["Platformatic", "secure-eval-worker", "Node.js", "runUntrustedCode", "createUntrustedWorker", "V8"], "alternates": {"html": "https://wpnews.pro/news/secure-eval-worker-least-privilege-javascript-execution-in-node-js", "markdown": "https://wpnews.pro/news/secure-eval-worker-least-privilege-javascript-execution-in-node-js.md", "text": "https://wpnews.pro/news/secure-eval-worker-least-privilege-javascript-execution-in-node-js.txt", "jsonld": "https://wpnews.pro/news/secure-eval-worker-least-privilege-javascript-execution-in-node-js.jsonld"}}