Safeguard
Application Security

The security case for Node.js's newer runtime features

Node's permission model went stable in v23.5.0, the built-in test runner in v20 — both quietly shrink attack surface, but neither is the sandbox teams assume it is.

Safeguard Research Team
Research
7 min read

Node.js 23.5.0 shipped on December 19, 2024, and with it the process-level permission model moved from an experimental flag to a stable, documented API — a change most teams still haven't noticed almost two years later. Enable it with --permission and Node restricts filesystem reads and writes, child-process spawning, node:worker_threads, native addons, WASI, and the runtime inspector by default, unless a flag like --allow-fs-read explicitly opens the door. It arrived alongside two other quieter runtime shifts: node:test, the built-in test runner, went stable in Node.js 20 in April 2023 and picked up native mocking and coverage reporting through the 22.x line; --watch mode followed a similar path from experimental to stable. None of these three features were marketed as security tools. All three change what a compromised dependency, a malicious postinstall script, or a careless engineer can actually do once code executes. This post looks at what each one buys you, what it doesn't, and where the real gaps still sit — because Node's own documentation is unusually blunt about the limits, and that nuance gets lost in most coverage of the release notes.

What does the Node.js permission model actually restrict?

The permission model, enabled with the --permission flag, restricts a process to an explicit allowlist across six capability classes: filesystem access, child-process spawning, node:worker_threads, native addons, WASI, and the inspector protocol. Each is closed by default and reopened individually — --allow-fs-read=* or a specific path, --allow-fs-write, --allow-child-process, --allow-worker, --allow-addons. This directly targets the most common post-compromise action in a supply-chain attack: a malicious npm package trying to read ~/.aws/credentials or .env, or spawning a shell to exfiltrate data. Node reached this stable milestone through a multi-year path that started as --experimental-permission in Node.js 20 in April 2023, per the official permissions documentation at nodejs.org. Practically, it means a CI job or a script running untrusted third-party code can be started with node --permission --allow-fs-read=/app/src app.js and any attempt to touch /etc/passwd or fork a subprocess throws an ERR_ACCESS_DENIED error instead of succeeding silently.

Is the permission model actually a sandbox?

No — and Node's own documentation says so explicitly, which is worth stating plainly because vendor blog posts about the feature routinely imply otherwise. The permissions guide describes the model as constraining access to specific resources during execution but explicitly not a security mechanism that guarantees isolation of malicious code; it does not restrict network access on its own, does not isolate memory between the main thread and any allowed workers, and coverage for some native/C++ addon code paths has been reported as incomplete by community testing. A process running under --permission can still make outbound HTTP requests, still runs in the same OS process and memory space as the rest of your application, and depends on every internal module correctly checking permission state — a bug in that enforcement path is a bypass, not a hardening failure of the sandbox model, because there isn't one. Teams should treat it as defense-in-depth that shrinks blast radius for filesystem and process abuse specifically, layered under OS-level controls like containers, seccomp profiles, or a non-root user — not as a replacement for any of them.

How does the built-in test runner change the dependency graph?

The built-in test runner, stable in node:test since Node.js 20 and extended with native mocking (t.mock ) and coverage reporting through the Node.js 22.x releases, removes the need for a Mocha, Jest, or Chai/Sinon chain in projects that only need core assertion, mocking, and coverage functionality. That matters for supply-chain exposure specifically: a typical Jest-based test setup pulls in dozens of transitive packages across the Jest, Babel, and Istanbul ecosystems, each one a package that needs auditing, pinning, and patching on its own release cadence. Replacing that chain with node:test doesn't eliminate risk — Node core itself has shipped CVEs — but it collapses a devDependency tree that previously sat entirely outside most SCA tooling's runtime-relevant scope, since test-only packages are rarely reachable from production code paths. Fewer independently-versioned test packages means fewer places a typosquat or a compromised maintainer account in the test toolchain can land malicious code during npm install, even though it changes nothing about vulnerabilities in your actual application dependencies.

Does watch mode have any real security relevance?

Watch mode, run as node --watch or combined with the test runner as node --test --watch, is a developer-experience feature first and a security control a distant second at best — it reruns a script or test suite automatically when a watched file changes, the same job nodemon did for years as a separate dependency. Its stabilization in the Node.js 22.x line matters for the same supply-chain-consolidation reason as the test runner: one fewer devDependency (nodemon, ts-node-dev, or similar) in the dependency tree of every project that adopts it, with one fewer package's install scripts and transitive tree to vet. It has no production runtime security function — nobody should be running --watch outside a local dev loop or CI test stage — but as part of the broader pattern across these three features, it's evidence that Node core is deliberately absorbing dev-tooling functionality that used to require trusting third-party maintainers.

How should teams actually use the permission model today?

Carefully, and incrementally — the model is stable as of v23.5.0 but adoption still lags because retrofitting an existing application to run under a default-deny filesystem policy typically surfaces dozens of implicit path dependencies that were never documented. The realistic rollout pattern reported by early adopters, including the walkthrough published by developer Brian Douglass, is to start with CI jobs and sandboxed script execution — running an untrusted build step, a linter plugin, or a third-party CLI tool under --permission --allow-fs-read=<workdir> — rather than flipping it on for a full production web server on day one, where every dynamically-resolved file path and worker thread needs an explicit allowance. It pairs best with contexts where the set of legitimate filesystem and process operations is small and known in advance: package build scripts, plugin execution, and any place code from node_modules executes with your process's ambient privileges today.

Where does this fit next to dependency scanning?

The permission model and dependency scanning solve different halves of the same problem: one asks whether a package is malicious or vulnerable before it runs, the other limits what it can do if it turns out to be either after you've already installed it. Safeguard's dependency scanning and malware-detection layers are built for the first question — flagging a vulnerable transitive package or a package exhibiting known malicious behavior patterns before it ever reaches a build. Node's permission model is a complementary, orthogonal control for the moment code executes anyway, whether because a scanner missed something novel or because the "vulnerability" is really a postinstall script nobody reviewed. Neither replaces the other. A dependency that passes every SCA check today can still be compromised via a maintainer account takeover tomorrow, and a --permission-restricted process still needs its dependency tree vetted before it's trusted to run at all — the two controls are meant to be stacked, not substituted.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.