Secure Coding in Node.js and Express

Advanced
13 min

Secure Coding in Node.js and Express

The general secure coding lesson gave you principles; this one applies them to the platform most of this course's examples use. Node.js and Express are minimal by design, which means the framework does very little for you: no request size limits, no security headers, no protection against a JSON body that rewrites Object.prototype. Every one of those has to be added deliberately.

In this lesson you will learn the Express middleware baseline, the Node.js-specific bug classes (prototype pollution, path traversal, ReDoS, unsafe child processes), and the runtime settings that limit damage when something slips through.

The Middleware Baseline

The sample code is the minimum for any Express service:

  • helmet() sets the standard security headers covered earlier in the course.
  • express.json({ limit, strict }) bounds request bodies and rejects primitives at the top level; unbounded parsing is a trivial memory-exhaustion attack.
  • app.disable("x-powered-by") stops advertising the framework.
  • trust proxy must match your deployment exactly. Setting it to true behind no proxy lets clients spoof req.ip and defeat IP-based rate limits.
  • A final error handler that logs internally and returns a generic body.

Add express-rate-limit on authentication routes, cors with an explicit allowlist, and CSRF tokens for cookie-based sessions.

Prototype Pollution

JavaScript objects inherit from Object.prototype. A parsed JSON body such as { "__proto__": { "isAdmin": true } } does no harm by itself, but a recursive merge or "deep extend" helper that copies keys blindly writes isAdmin onto the prototype of every object in the process.

javascript
// Safe: prototype-less objects for user-controlled keys, and explicit key filtering const settings = Object.create(null); for (const [k, v] of Object.entries(req.body)) { if (["__proto__", "constructor", "prototype"].includes(k)) continue; if (ALLOWED_SETTINGS.has(k)) settings[k] = v; }

Use Map for user-keyed collections, validate bodies with a schema so unknown keys never reach a merge, and remove the __proto__ accessor at the runtime level:

bash
node --disable-proto=delete server.js

Path Traversal and Child Processes

Any file path built from user input can be pointed at ../../etc/passwd or ../.env. The sample resolves the path first and then checks that it still starts with the intended root; a check on the raw string misses encoded forms.

Child processes carry the same risk in a different form:

javascript
const { execFile } = require("node:child_process"); // exec("git log " + branch) hands the string to a shell; execFile does not execFile("git", ["log", "--oneline", "-n", "20", "--", branch], { timeout: 5000 }, cb);

Combine the argument array with an allowlist on the value (a branch name matches ^[\w./-]{1,100}$) and a timeout so a stuck process cannot pile up.

ReDoS and Event Loop Starvation

Node.js runs on a single event loop. A regular expression with nested quantifiers such as ^(a+)+$ takes exponential time on crafted input, and one request can freeze the whole server for seconds. Defenses:

| Risk | Mitigation | |---|---| | Catastrophic backtracking | Avoid nested quantifiers; test patterns with a ReDoS checker; use the re2 package for user-facing patterns | | Large inputs to any regex | Length-limit strings before matching | | CPU-heavy work in a handler | Move to a worker thread or a queue | | Synchronous fs or crypto calls | Use the async variants; pbkdf2Sync on a login route blocks every other user |

Also set server.headersTimeout and server.requestTimeout so slow clients cannot hold connections open indefinitely. At the process level, run as a non-root user with NODE_ENV=production, never expose --inspect beyond localhost, and handle unhandledRejection by logging and exiting so the process manager restarts a clean instance.

Quick Quiz
Question 1 of 2

Why is `trust proxy: true` dangerous when the app is not actually behind a proxy?

Key Takeaways

  • Express does nothing by default: add helmet, body limits, a correct trust proxy value and a generic error handler.
  • Filter __proto__-style keys, use Object.create(null) or Map, and validate bodies against pollution.
  • Resolve paths and check the root prefix; use execFile with argument arrays and timeouts.
  • Avoid nested-quantifier regexes and synchronous CPU work; the event loop is shared by every user.
  • Run as non-root in production mode, crash cleanly on unhandled errors, and stay on supported Node.js releases.

Next lesson: Secure Database Access — least-privilege accounts, encrypted connections and query hygiene beyond injection.

Secure Coding in Node.js and Express - Cyber Security | CodeYourCraft | CodeYourCraft