Node.js Application Security Hardening
Parent: JavaScript and Node.js · researched 2026-06-02T20:15:44.251Z· 10 sources · 6 concepts · skill nodejs-security-hardening
This reference is the consolidated Node.js security playbook: the threats that
Overview
- This reference is the consolidated Node.js security playbook: the threats that [source]
- are specific to running JavaScript on a server with full OS access, and the [source]
- defenses Node ships for them. It is organized threat → defense so you can go from [source]
- a symptom ("untrusted JSON reaches a merge", "user input reaches child_process") [source]
- straight to the mitigation. [source]
- Node's own threat model sets the boundary: Node trusts the code it is asked to [source]
- run and the OS environment it runs in. Everything here is about defending the line [source]
- between trusted application code and untrusted external input - request [source]
- bodies, query strings, JSON, transcripts, file paths, third-party packages. It is [source]
- not about sandboxing untrusted code (Node explicitly does not do that). [source]
- This file owns the application-layer attack surface. Several adjacent concerns [source]
- live in sibling references and are deferred, not duplicated: [source]
- Permission-Model mechanics (the full --allow-fs-read/--allow-net/SEA [source]
- flag matrix) → nodejs-typescript-and-runtime-features. Here we cover only [source]
- why the model is defense-in-depth, not a sandbox. [source]
- Package-manager workflows (lockfile/audit/workspace mechanics) → [source]
- nodejs-package-management-supply-chain. Here we cover supply chain as risk. [source]
- HTTP security headers (Helmet, CSP, HSTS, CORS) → http-security-headers. [source]
- Web Crypto / vault / encryption-at-rest → webcrypto-vault-reviewer. [source]
- OAuth/OIDC/session auth flows → web-auth-patterns. [source]
- **Event-loop / ReDoS-blocking mechanics** → nodejs-concurrency-internals. [source]
- Here we cover ReDoS only from the defense angle. [source]
- The mental model: **validate untrusted input at the boundary, deny shells and [source]
- dynamic eval, freeze what should be immutable, and treat every dependency as [source]
- untrusted code.** Defense-in-depth - no single flag makes a Node app safe. [source]
1. Prototype pollution (CWE-1321)
- JavaScript objects inherit from Object.prototype. If an attacker can write a key [source]
- named __proto__, constructor, or prototype into an object built from [source]
- untrusted data, they mutate that shared prototype - and every object in the [source]
- process suddenly carries the injected property. The classic sink is an **insecure [source]
- recursive merge / deep-clone / extend** (CVE-2018-16487 in lodash) or a [source]
- query-string parser that auto-vivifies nested keys (?__proto__[isAdmin]=1). [source]
- Impact: ranges from logic corruption and DoS to privilege escalation and, via [source]
- a gadget, full RCE (a polluted .env/.shell property read by a later [source]
- child_process call - see concept 6). [source]
- Defenses (layer them): [source]
- Validate against a schema (Ajv/zod) at the boundary - the strongest fix; [source]
- reject unexpected keys with additionalProperties: false. [source]
- Use null-prototype objects for dictionaries: Object.create(null) (no [source]
- __proto__ to pollute) or a Map instead of an object-as-dictionary. [source]
- Object.freeze(MyClass.prototype) / Object.freeze(Object.prototype) to [source]
- block writes to a specific prototype. [source]
- --disable-proto=throw (or =delete) removes the Object.prototype.__proto__ [source]
- accessor process-wide - throw raises ERR_PROTO_ACCESS on access, delete [source]
- A JSON.parse reviver that drops __proto__/constructor keys; check [source]
- ownership with Object.hasOwn(obj, key), never inherited lookups. [source]
- Avoid hand-rolled recursive merges on untrusted data; if unavoidable, skip the [source]
- three magic keys explicitly. [source]
2. Injection (command, path, eval, SQL/NoSQL)
- Command injection is the highest-severity Node-specific sink. [source]
- child_process.exec() / execSync() spawn a shell (/bin/sh) and the Node [source]
- docs warn verbatim: *"Never pass unsanitized user input to this function. Any [source]
- input containing shell metacharacters may be used to trigger arbitrary command [source]
- execution."* Defense: prefer execFile() or spawn(), which run the binary [source]
- directly without a shell by default, and pass arguments as a **separate [source]
- array** (spawn('git', ['log', userRef])) so metacharacters are never parsed. [source]
- Keep shell: false (the default) - enabling shell: true re-introduces the [source]
- exact exec vulnerability. Never build a command string by concatenating input. [source]
- eval() / new Function() / vm with untrusted strings is direct RCE - OWASP [source]
- calls it inherently a remote-code-execution vulnerability. Don't evaluate user [source]
- input; use a parser/lookup table instead. vm is not a security sandbox. [source]
- Path traversal: untrusted input flowing into fs.* enables ../../etc/passwd [source]
- file inclusion. Normalize with path.resolve(), then assert the result [source]
- startsWith the intended base directory; reject otherwise. Decode and strip [source]
- ../null bytes before use. [source]
- SQL/NoSQL injection lives at the app layer: always use **parameterized [source]
- queries / prepared statements** (driver placeholders), never string-built SQL. For [source]
- MongoDB, reject object-typed values where a scalar is expected ({$gt:''} [source]
- operator injection from query strings) and cast inputs to their expected type. [source]
3. Hardening flags & runtime defenses
- Node ships process-level flags that shrink the attack surface as defense-in-depth: [source]
- The Permission Model is defense-in-depth, not a security boundary. The Node [source]
- docs are explicit: *"The permission model implements a 'seat belt' approach … [source]
- It does not provide security guarantees in the presence of malicious code. [source]
- Malicious code can bypass the permission model and execute arbitrary code."* It [source]
- "trusts any code it is asked to run." So --permission is useful to prevent [source]
- trusted code (and its dependencies) from accidentally touching the filesystem [source]
- or network - a containment layer, not a jail for untrusted code. (The [source]
- --allow-* flag matrix, scoping, and SEA mechanics are deferred to [source]
- nodejs-typescript-and-runtime-features.) Also: don't enable experimental [source]
- features in production unless you accept the breaking-change risk. [source]
4. Secrets & configuration hygiene
- Keep secrets in the environment, not in source. Read from process.env; [source]
- never commit .env files or hard-coded API keys/tokens (CWE-552). Add .env to [source]
- .gitignore and use an allowlist (files in package.json, .npmignore) so a [source]
- npm publish doesn't leak them - verify with npm publish --dry-run. [source]
- --env-file caveat: --env-file=.env loads vars into process.env, *and it [source]
- also parses Node-configuring vars like NODE_OPTIONS. The docs warn Node "will [source]
- not sanitize or perform validation on the user-provided configuration, so NEVER [source]
- use untrusted configuration files."* A writable .env is therefore a code-exec [source]
- vector (it can inject NODE_OPTIONS). --env-file is also not subject to [source]
- Permission-Model restrictions. Use a real secrets manager for production. [source]
- Never put secrets in logs or error responses. Redact tokens/passwords/keys [source]
- before logging; don't echo stack traces or err.message containing connection [source]
- strings to clients. Centralize redaction in the logger. [source]
- process.env hygiene: read each secret once at startup into a typed config [source]
- object; don't pass the whole process.env into child processes or templates [source]
- (a prototype-pollution gadget can poison it - concept 6). [source]
5. Dependency & supply-chain risk (CWE-1357)
- Most of a Node app's code is third-party and runs with full privilege. Treat the [source]
- dependency tree as an attack surface: [source]
- npm audit + CVE response: scan regularly; for a flagged CVE, upgrade to the [source]
- fixed version (or apply an override) and re-test. Don't ignore transitive [source]
- Install-script risk: postinstall/preinstall scripts run arbitrary code at [source]
- install time. For untrusted or audited installs use **`npm install [source]
- --ignore-scripts** (or npm config set ignore-scripts true`) and allowlist the [source]
- few packages that legitimately need a build step. [source]
- Dependency confusion: if your build resolves from both a private and the [source]
- public registry, an attacker can publish a public package with your internal [source]
- name and win. Defense: publish internal packages under an @your-scope/, [source]
- register that scope publicly even if unused, and pin the scope to the private [source]
- registry in .npmrc (@your-scope:registry=…). [source]
- Lockfile integrity: commit package-lock.json (it records exact versions [source]
- and the resolved registry + integrity hash) and install with npm ci, [source]
- which fails on any lockfile/package.json mismatch. Guard against lockfile [source]
- poisoning in review. (Lockfile/workspace workflow mechanics → [source]
- nodejs-package-management-supply-chain.) [source]
- Keep Node patched: track Node.js security releases and run a supported [source]
- (Active LTS or Maintenance) line; EOL versions get no security fixes. [source]
6. Request-layer risks (SSRF, ReDoS, smuggling, deserialization)
- SSRF (server-side request forgery): any server-side fetch/undici/http [source]
- call to a user-controlled URL can be steered at internal services or the [source]
- cloud metadata endpoint 169.254.169.254 to steal credentials. Node's [source]
- built-in fetch has no SSRF guard. Defense: allowlist permitted hosts/schemes; [source]
- resolve the hostname to IP and reject private/reserved ranges [source]
- (loopback/private/link-local/ULA/IPv4-mapped) before connecting; **disable or [source]
- re-validate redirects** (a 302 can point inward); beware DNS-rebinding/TOCTOU — [source]
- validate at connect time (libraries: request-filtering-agent, ssrf-req-filter). [source]
- ReDoS (regex denial-of-service): a regex with catastrophic backtracking [source]
- (nested quantifiers like (a+)+$, overlapping alternations) hangs on a crafted [source]
- input. Defense angle: avoid such patterns, cap input length, prefer a [source]
- linear engine (RE2 / node:re2), and screen patterns with safe-regex / [source]
- vuln-regex-detector. (Why it stalls the whole process - the event-loop [source]
- blocking mechanics - is in nodejs-concurrency-internals.) [source]
- HTTP request smuggling (CWE-444): ambiguous Content-Length/Transfer-Encoding [source]
- framing lets a request slip past a front-end. Don't set [source]
- insecureHTTPParser: true; normalize at the proxy; prefer end-to-end HTTP/2. [source]
- Unsafe deserialization: JSON.parse is safe for data, but libraries that [source]
- deserialize functions (e.g. node-serialize ≤0.0.4 unserialize()) execute [source]
- attacker-supplied IIFE payloads → RCE. Never deserialize untrusted input with a [source]
- function-capable format; restrict to JSON + schema validation. [source]
Practical patterns
- Validate at the boundary, once. Run every external payload through an Ajv/zod [source]
- schema with additionalProperties:false before it touches business logic - this [source]
- closes prototype pollution, type-confusion NoSQL injection, and oversized-field [source]
- Ban the shell. Lint for child_process.exec/execSync and shell:true; [source]
- standardize on execFile/spawn with argument arrays. [source]
- Run with --disable-proto=throw (cheap, high-value) and consider [source]
- --frozen-intrinsics once you've confirmed your polyfills load via --require. [source]
- Wrap untrusted text (transcripts, Slack, case bodies, anything fed to an LLM [source]
- or template) in an escaped envelope so it can't be interpreted as control input — [source]
- the same discipline as parameterizing a query. [source]
- Gate outbound URLs through a single SSRF-filtering agent so no code path can [source]
- fetch an arbitrary user URL directly. [source]
- npm ci in CI, --ignore-scripts for untrusted installs, and a scheduled [source]
- npm audit + Node-version check so patch cadence isn't manual. [source]
Anti-patterns
- Deep-merging untrusted JSON with a hand-rolled or old-lodash merge and no [source]
- schema - the #1 prototype-pollution foothold. [source]
- child_process.exec('cmd ' + userInput) or flipping shell:true "to make it [source]
- work" - arbitrary command execution. [source]
- eval/new Function on request data, or trusting vm as a sandbox. [source]
- Committing .env, logging full error objects with secrets, or loading an [source]
- untrusted --env-file (it can inject NODE_OPTIONS). [source]
- Treating --permission as a sandbox for untrusted code - it is a seat belt [source]
- for trusted code; malicious code bypasses it. [source]
- Fetching a user-supplied URL server-side with no allowlist / private-range [source]
- block - SSRF to 169.254.169.254. [source]
- node-serialize.unserialize() (or any function-deserializing format) on [source]
- untrusted input - instant RCE. [source]
- Running an EOL Node version or ignoring npm audit advisories. [source]
Troubleshooting
- Prototype pollution slipped through schema validation → the validator ran [source]
- after the merge, or allowed additionalProperties; validate first and set [source]
- additionalProperties:false. Confirm with ({}).polluted === undefined after the [source]
- --frozen-intrinsics breaks a dependency → a library mutates a built-in; load [source]
- required polyfills via --require/--import (they run before the freeze) or drop [source]
- the flag for that service - it's experimental and root-context only. [source]
- execFile still runs a shell → you passed shell:true or a single [source]
- command-line string; pass the binary + an args array with shell:false. [source]
- SSRF filter bypassed → likely DNS rebinding (TOCTOU) or an IPv6-mapped/redirect [source]
- bypass; re-validate the resolved IP at connect time and disallow redirects to new [source]
- Regex still hangs after switching libraries → the pattern is still [source]
- backtracking-prone; move to RE2 (linear) and cap input length. For why one [source]
- blocked regex freezes all requests, see nodejs-concurrency-internals. [source]
- npm audit flags a transitive dep with no direct fix → use an overrides [source]
- entry to force the patched version, then re-audit and test. [source]
References
- Node.js - Security best practices (prototype pollution, monkey-patching, secure heap, HTTP smuggling, supply chain, permission model, secrets): https://nodejs.org/en/learn/getting-started/security-best-practices [source]
- Node.js - CLI flags (--frozen-intrinsics, --disable-proto, --secure-heap, --permission, --env-file): https://nodejs.org/api/cli.html [source]
- Node.js - Permission Model ("seat belt" / not-a-sandbox, threat model, limitations): https://nodejs.org/api/permissions.html [source]
- Node.js - child_process (exec vs execFile/spawn, shell option, shell-injection warning): https://nodejs.org/api/child_process.html [source]
- OWASP - Node.js Security Cheat Sheet (command injection, eval, path traversal, ReDoS, request-size limits): https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html [source]
- OWASP - SSRF Prevention in Node.js (allowlist, private-range block, metadata endpoint): https://owasp.org/www-community/pages/controls/SSRF_Prevention_in_Nodejs [source]
- Snyk - Preventing insecure deserialization in Node.js (node-serialize, IIFE RCE): https://snyk.io/blog/preventing-insecure-deserialization-node-js/ [source]
- Snyk - Detect and prevent dependency confusion attacks on npm: https://snyk.io/blog/detect-prevent-dependency-confusion-attacks-npm-supply-chain-security/ [source]
- nodejs/undici - SSRF protection in undici / native fetch (no built-in guard): https://github.com/nodejs/undici/issues/2019 [source]
- OWASP - Prototype Pollution Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html [source]
Children
- Prototype pollution (attack + defenses) (frontier)
- Injection in Node (command/path/eval/SQL-NoSQL) (frontier)
- Hardening flags & runtime defenses (incl. Permission Model as defense-in-depth) (frontier)
- Secrets & configuration hygiene (frontier)
- Dependency & supply-chain risk (frontier)
- Request-layer risks (SSRF, ReDoS, smuggling, unsafe deserialization) (frontier)
Frontier under this node: Dependency & supply-chain risk, Hardening flags & runtime defenses (incl. Permission Model as defense-in-depth), Injection in Node (command/path/eval/SQL-NoSQL), Prototype pollution (attack + defenses), Request-layer risks (SSRF, ReDoS, smuggling, unsafe deserialization), Secrets & configuration hygiene