Node.js Application Security Hardening

Node.js Application Security Hardening

Overview

This reference is the consolidated Node.js security playbook: the threats that are specific to running JavaScript on a server with full OS access, and the defenses Node ships for them. It is organized threat → defense so you can go from a symptom (“untrusted JSON reaches a merge”, “user input reaches child_process”) straight to the mitigation.

Node’s own threat model sets the boundary: Node trusts the code it is asked to run and the OS environment it runs in. Everything here is about defending the line between trusted application code and untrusted external input — request bodies, query strings, JSON, transcripts, file paths, third-party packages. It is not about sandboxing untrusted code (Node explicitly does not do that).

This file owns the application-layer attack surface. Several adjacent concerns live in sibling references and are deferred, not duplicated:

The mental model: validate untrusted input at the boundary, deny shells and dynamic eval, freeze what should be immutable, and treat every dependency as untrusted code. Defense-in-depth — no single flag makes a Node app safe.

Core concepts

1. Prototype pollution (CWE-1321)

JavaScript objects inherit from Object.prototype. If an attacker can write a key named __proto__, constructor, or prototype into an object built from untrusted data, they mutate that shared prototype — and every object in the process suddenly carries the injected property. The classic sink is an insecure recursive merge / deep-clone / extend (CVE-2018-16487 in lodash) or a query-string parser that auto-vivifies nested keys (?__proto__[isAdmin]=1).

const data = JSON.parse('{"__proto__": { "polluted": true }}');
const c = Object.assign({}, { a: 1 }, data);
console.log(({}).polluted); // true  — every object is now polluted

Impact: ranges from logic corruption and DoS to privilege escalation and, via a gadget, full RCE (a polluted .env/.shell property read by a later child_process call — see concept 6).

Defenses (layer them):

2. Injection (command, path, eval, SQL/NoSQL)

Command injection is the highest-severity Node-specific sink. child_process.exec() / execSync() spawn a shell (/bin/sh) and the Node docs warn verbatim: “Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution.” Defense: prefer execFile() or spawn(), which run the binary directly without a shell by default, and pass arguments as a separate array (spawn('git', ['log', userRef])) so metacharacters are never parsed. Keep shell: false (the default) — enabling shell: true re-introduces the exact exec vulnerability. Never build a command string by concatenating input.

eval() / new Function() / vm with untrusted strings is direct RCE — OWASP calls it inherently a remote-code-execution vulnerability. Don’t evaluate user input; use a parser/lookup table instead. vm is not a security sandbox.

Path traversal: untrusted input flowing into fs.* enables ../../etc/passwd file inclusion. Normalize with path.resolve(), then assert the result startsWith the intended base directory; reject otherwise. Decode and strip ../null bytes before use.

SQL/NoSQL injection lives at the app layer: always use parameterized queries / prepared statements (driver placeholders), never string-built SQL. For MongoDB, reject object-typed values where a scalar is expected ({$gt:''} operator injection from query strings) and cast inputs to their expected type.

3. Hardening flags & runtime defenses

Node ships process-level flags that shrink the attack surface as defense-in-depth:

Flag Effect Note
--frozen-intrinsics Recursively freezes built-ins (Array, Object, prototypes) so monkey-patching (CWE-349) fails with a TypeError. Experimental; root context only; --require/--import run before freezing so polyfills can load.
--disable-proto=throw|delete Removes/poisons Object.prototype.__proto__ (see concept 1). throwERR_PROTO_ACCESS.
--secure-heap=n Allocates an OpenSSL secure heap (size n) for key material, guarding against some memory-disclosure reads (CWE-284). Not on Windows; --secure-heap-min sets the min allocation.
--permission Enables the Permission Model — denies fs/net/child-process/etc. unless explicitly granted. See below — seat belt, not sandbox.

The Permission Model is defense-in-depth, not a security boundary. The Node docs are explicit: “The permission model implements a ‘seat belt’ approach … It does not provide security guarantees in the presence of malicious code. Malicious code can bypass the permission model and execute arbitrary code.” It “trusts any code it is asked to run.” So --permission is useful to prevent trusted code (and its dependencies) from accidentally touching the filesystem or network — a containment layer, not a jail for untrusted code. (The --allow-* flag matrix, scoping, and SEA mechanics are deferred to nodejs-typescript-and-runtime-features.) Also: don’t enable experimental features in production unless you accept the breaking-change risk.

4. Secrets & configuration hygiene

5. Dependency & supply-chain risk (CWE-1357)

Most of a Node app’s code is third-party and runs with full privilege. Treat the dependency tree as an attack surface:

6. Request-layer risks (SSRF, ReDoS, smuggling, deserialization)

Threat → defense table

Threat Node-specific sink Primary defense
Prototype pollution recursive merge / clone, query parser, JSON.parse schema validation; Object.create(null) / Map; --disable-proto=throw; Object.freeze; reviver
Command injection child_process.exec/execSync, shell:true execFile/spawn with arg array, shell:false; never concatenate
Eval/code injection eval, new Function, vm never eval input; parser/lookup; vm ≠ sandbox
Path traversal fs.* with user path path.resolve + base-dir startsWith check
SQL/NoSQL injection string-built queries, object operators parameterized queries; type-cast/reject operator objects
Monkey-patching mutated intrinsics --frozen-intrinsics; Object.freeze(globalThis)
Secret leakage source, logs, --env-file env-only secrets; .gitignore/files; redact logs; no untrusted .env
Supply chain install scripts, dep confusion, stale deps --ignore-scripts, scoped pkgs, npm ci + lockfile, npm audit, patch Node
SSRF server-side fetch/undici to user URL host allowlist; block private IPs; validate redirects/DNS
ReDoS catastrophic-backtracking regex RE2/linear engine; input-length cap; safe-regex
Request smuggling insecureHTTPParser, ambiguous framing leave parser strict; normalize at proxy; HTTP/2
Unsafe deserialization node-serialize unserialize() JSON + schema only; no function-capable formats

Practical patterns

Anti-patterns

Troubleshooting

References