Skip to main content
  1. posts/

Thinking Outside the Sandbox: Decoding and Defeating Node.js Permissions

Thinking Outside the Sandbox #

Decoding and Defeating the Node.js Permission Model #

Node.js has been steadily moving toward a stronger sandboxing and permission model. The goal is clear: limit what code can do, especially third-party dependencies, even after they’re loaded into your process. In theory, this should dramatically reduce the blast radius of supply-chain attacks. In practice… it’s complicated. This post walks through how the Node.js permission model works, where it breaks down, and how real-world exploits have bypassed both module-based and process-based protections.


Node.js Permissions: The Big Picture #

Node.js has been experimenting with two different “permission” concepts that are easy to conflate:

  • Module-based permissions (Policies): restrict what modules a given script (and its dependency graph) can load.
  • Process-based permissions: restrict what the process can access (filesystem, child processes, etc.).

This post is a write-up version of a talk given at KernalCon: Thinking outside the Sandbox and walks through how each model works, where it broke, and what changed to fix it.


Module-Based Permissions #

The Goal #

Module-based permissions aim to control what modules a dependency can require().

Examples:

  • “I don’t expect my app to ever spawn a child process.”
  • “Fastify needs access to http, but no one else should.”

This is enforced via a policy.json file passed to Node at startup:

node --experimental-policy=policy.json main.js

Module-based permissions (Policies) #

Policies are intended to answer: “Fastify needs node:http, but no one else should.” The usual shape is a policy.json manifest that enumerates which resources may depend on which specifiers.

A minimal policy setup #

main.js (allowed to load node:child_process):

const child_process = require("node:child_process");
console.log(child_process.execSync("ls -la").toString());

require("./child.js");

child.js (not allowed unless it declares the dependency in policy):

const child_process = require("node:child_process");
console.log(child_process.execSync("ls -la").toString());

policy.json (only ./main.js is granted node:child_process):

{
  "resources": {
    "./main.js": {
      "integrity": true,
      "dependencies": {
        "node:child_process": true,
        "./child.js": true
      }
    }
  },
  "scopes": {
    "./": { "integrity": true }
  }
}

Running child.js or letting it require("node:child_process") without listing the dependency typically fails with a manifest error.

Bypass #1: module.constructor._load (CVE-2023-32002) #

One early bypass was to avoid the “policy-aware” require path and use internal loader APIs directly:

// CVE-2023-32002 (example)
const child_process = module.constructor._load("node:child_process");
console.log(child_process.execSync("ls -la").toString());

This worked because policy enforcement was wired into normal compilation/loading paths, and some internal entrypoints weren’t being consistently guarded.

Fix (high level): harden/guard internal module loader surfaces so that untrusted code can’t reach privileged loaders via module.constructor the way it used to.

Bypass #2: “Module Moocher” via require.cache #

Even if your module can’t load something under policy, another already-loaded module might have a require function that can. Node caches loaded modules in require.cache, and policy is about who is doing the requiring.

Proof-of-concept shape:

// We could loop to find the module with the permissions we want.
const main_module_ref = Object.values(require.cache)[0];

const child_process = main_module_ref.require("node:child_process");
console.log(child_process.execSync("ls -la").toString());

If you can get a reference to a “privileged” module’s require, you can “mooch” its access.

Fix (high level): make it harder/impossible for untrusted code to obtain or reuse privileged loader entrypoints, and tighten how policy scope follows the requester.

Process-based permissions (--experimental-permission) #

Process permissions aim to answer: “I don’t want or expect my application to spawn a child process ever.” This model is about restricting runtime access to system resources (fs, child processes, etc.) for the entire Node process.

Bypass #1: WASI file access #

The core idea: process permissions were framed around access “through fs”, but WASI provides a separate path to file IO via WebAssembly + preopened directories.

The PoC pattern is:

  • Enable process permissions and restrict fs read.
  • Use wasi with preopens to map a virtual directory to a real host path.
  • Run a WASI-compiled module that reads a file via POSIX-like syscalls.

Conceptual snippet:

const { WASI } = require("wasi");

const wasi = new WASI({
  version: "preview1",
  preopens: { "/": "/" },
  args: ["/etc/passwd"]
});

// instantiate and start a WASI wasm module that fopen()/reads argv[0]

Fix (high level): ensure the permission model applies consistently to all filesystem access paths, not just the fs module surface.

Bypass #2: “Inspector Gadget” (debugger-assisted permission escalation) #

Another class of bypass came from using the inspector/debugger machinery to tamper with internal state and reach restricted APIs indirectly.

The slide deck demonstrates the idea by:

  • Connecting to the inspector (node:inspector/promises),
  • Using the debugger protocol to locate internal source,
  • Setting a conditional breakpoint to flip a guard variable (e.g., forcing an isInternal path),
  • Creating a Worker with eval: true and permissive execArgv.

High-level pseudocode shape:

const { Session } = require("node:inspector/promises");
const session = new Session();
session.connect();

// enable Debugger + Runtime, locate node:internal/worker source,
// set a conditional breakpoint to force an internal-only path,
// then construct Worker with eval + permissive execArgv.

Fix (high level): restrict inspector access under the permission model so it can’t be used as an escalation vector (i.e., “Access to this API has been restricted”).

Takeaways #

  • Security boundaries must cover alternative access paths: guarding fs doesn’t help if another built-in API reaches the same OS primitives.
  • Internal APIs are part of the attack surface: if untrusted code can reach “internal-only” entrypoints, policy/permission checks need to follow.
  • Debugger/inspector is extremely powerful: it should be treated as privileged and gated accordingly.

References #