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 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.Add express-rate-limit on authentication routes, cors with an explicit allowlist, and CSRF tokens for cookie-based sessions.
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.
// 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:
node --disable-proto=delete server.jsAny 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:
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.
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.
Why is `trust proxy: true` dangerous when the app is not actually behind a proxy?
helmet, body limits, a correct trust proxy value and a generic error handler.__proto__-style keys, use Object.create(null) or Map, and validate bodies against pollution.execFile with argument arrays and timeouts.Next lesson: Secure Database Access — least-privilege accounts, encrypted connections and query hygiene beyond injection.