Security Deep Dive

Node.js Advanced — Security Deep Dive

Node.js Advanced

Chapter 4 of 8  ·  Security Deep Dive

Security Deep Dive

The security courses on XSS, CSRF, SQLi, and authentication cover the universal web vulnerabilities. This chapter covers the ones that bite Node specifically: prototype pollution (a JavaScript quirk that lets attackers corrupt shared objects), ReDoS (a regex backtracking trap that freezes the event loop), SSRF (your server being tricked into fetching internal services), path traversal, timing leaks, and supply chain risks via npm. Node's single-threaded model makes some of these hurt more than in multi-threaded runtimes — a frozen event loop means every request stalls.

1 — Prototype Pollution

In JavaScript, Object.prototype is the root of nearly every object's prototype chain. If an attacker can write a property onto Object.prototype, that property appears on every plain object in the process — including objects your code creates internally and checks with if (obj.admin).

// The attack: user-controlled JSON merged into an object function merge(target, source) { for (const key in source) { if (typeof source[key] === 'object') { target[key] = target[key] || {}; merge(target[key], source[key]); // ← vulnerable recursive merge } else { target[key] = source[key]; } } } // Attacker sends this JSON payload: const malicious = JSON.parse('{"__proto__":{"admin":true}}'); merge({}, malicious); // writes 'admin: true' onto Object.prototype // Now EVERY plain object in the process has .admin === true const user = { name: 'alice' }; console.log(user.admin); // true — even though we never set it! // Real consequence: access control bypass if (user.admin) { grantAdminAccess(); // ← executes for every user }

Prototype Pollution Defences

// Defence 1: blocklist __proto__, constructor, prototype in merge logic function safeMerge(target, source) { for (const key in source) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; // skip polluting keys } if (typeof source[key] === 'object' && source[key] !== null) { target[key] = target[key] || {}; safeMerge(target[key], source[key]); } else { target[key] = source[key]; } } } // Defence 2: use Object.create(null) for lookup maps — no prototype chain at all const cache = Object.create(null); // cache.__proto__ === undefined cache['key'] = 'value'; // safe — no prototype to pollute // Defence 3: freeze Object.prototype at process startup (nuclear option) Object.freeze(Object.prototype); // writes to Object.prototype silently fail // Defence 4: use structuredClone() or JSON.parse(JSON.stringify(obj)) // structuredClone does NOT copy __proto__ — safe for copying user data const safe = structuredClone(userInput); // Defence 5: prefer lodash.merge ≥ 4.17.21 or deepmerge ≥ 4.2.2 // (patched against prototype pollution — always check npm audit)

🔴 Libraries Historically Vulnerable to Prototype Pollution

Lodash (pre-4.17.21), jQuery $.extend (pre-3.4.0), Hoek, minimist, and many "deep merge / deep clone" utilities have had prototype pollution CVEs. Always run npm audit and update these immediately.

The pattern to search your codebase for: any function that iterates for (key in source) and writes target[key] without checking the key — including your own code.

2 — ReDoS (Regular Expression Denial of Service)

Certain regex patterns have exponential backtracking: for a carefully crafted input string, the engine tries an astronomical number of possible matches before concluding there's no match. In Node's single-threaded model, an attacker-controlled string hitting a vulnerable regex freezes the event loop — every other request stalls until the regex engine gives up.

// VULNERABLE: nested quantifiers cause exponential backtracking const vulnerable = /^(a+)+$/; // Safe input: matches instantly vulnerable.test('aaaaaaaaa'); // fast // Attack input: 'aaa...a!' (many 'a's followed by a non-matching char) // The engine tries every way to partition the 'a' run — 2^n attempts vulnerable.test('aaaaaaaaaaaaaaaaaab'); // may hang for seconds/minutes // More real-world examples of vulnerable patterns: const v1 = /(\d+)+/; // digit runs const v2 = /(a|aa)+/; // alternation with overlap const v3 = /([a-z]+\s?)+/; // word + optional space, repeated // These commonly appear in email, URL, and date validators written by hand
// FIX 1: rewrite the regex to avoid catastrophic backtracking // (a+)+ → just a+ — the outer + is redundant and causes the problem) const safe = /^a+$/; // FIX 2: use a timeout wrapper for third-party / legacy regexes function regexWithTimeout(pattern, input, timeoutMs = 50) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Regex timeout')), timeoutMs); // Run in a worker thread so the timeout can actually interrupt it const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads'); if (isMainThread) { const w = new Worker(__filename, { workerData: { pattern: pattern.source, flags: pattern.flags, input } }); w.on('message', (result) => { clearTimeout(timer); resolve(result); }); w.on('error', (err) => { clearTimeout(timer); reject(err); }); timer.unref(); setTimeout(() => w.terminate(), timeoutMs).unref(); } else { const { pattern: p, flags, input: i } = workerData; parentPort.postMessage(new RegExp(p, flags).test(i)); } }); } // FIX 3: use a linear-time regex engine (npm install re2) const RE2 = require('re2'); // Google's RE2 — no backtracking const safeEmail = new RE2(/^[^@]+@[^@]+\.[^@]+$/); safeEmail.test('user@example.com'); // guaranteed linear time

3 — SSRF (Server-Side Request Forgery)

SSRF happens when your Node code makes an HTTP request using a URL that an attacker controls. The attacker's goal is to make your server talk to an internal service it shouldn't — a metadata API, an internal admin interface, Redis, the database, or cloud provider metadata endpoints like 169.254.169.254.

// VULNERABLE: fetching a user-supplied URL with no validation app.post('/fetch-preview', async (req, res) => { const { url } = req.body; const response = await fetch(url); // attacker controls 'url' res.send(await response.text()); }); // Attack payloads an attacker might supply: // http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS metadata) // http://localhost:6379/ (Redis) // http://internal-admin.company.local/users (internal app) // file:///etc/passwd (local file)
// FIX: validate the URL before fetching const dns = require('node:dns/promises'); // Private IP ranges that must never be fetched const PRIVATE_RANGES = [ /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^127\./, /^169\.254\./, // link-local / cloud metadata /^::1$/, // IPv6 loopback /^fc00:/, ]; function isPrivateIp(ip) { return PRIVATE_RANGES.some(re => re.test(ip)); } async function safeUrl(rawUrl) { let url; try { url = new URL(rawUrl); } catch { throw new Error('Invalid URL'); } // Only allow https — blocks file://, ftp://, gopher://, etc. if (url.protocol !== 'https:') throw new Error('Only https:// is allowed'); // Allowlist: only known external domains const ALLOWED = ['api.partner.com', 'cdn.images.io']; if (!ALLOWED.includes(url.hostname)) throw new Error('Domain not allowed'); // Resolve the hostname and check the resulting IPs // This guards against DNS rebinding: attacker owns a domain that resolves // to a public IP for the allowlist check, then re-resolves to 192.168.x.x const addresses = await dns.resolve(url.hostname); for (const ip of addresses) { if (isPrivateIp(ip)) throw new Error(`Resolved to private IP: ${ip}`); } return url.href; } app.post('/fetch-preview', async (req, res, next) => { try { const safe = await safeUrl(req.body.url); const response = await fetch(safe, { redirect: 'manual' }); // don't follow redirects res.send(await response.text()); } catch (err) { res.status(400).json({ error: err.message }); } });

4 — Path Traversal

// VULNERABLE: user controls a filename used in fs operations app.get('/download', (req, res) => { const file = req.query.file; res.sendFile(path.join(__dirname, 'uploads', file)); // Attack: ?file=../../etc/passwd → reads /etc/passwd }); // FIX: resolve the path and verify it stays inside the allowed directory const path = require('node:path'); const UPLOAD_DIR = path.resolve(__dirname, 'uploads'); app.get('/download', (req, res) => { const requested = path.resolve(UPLOAD_DIR, req.query.file); // If the resolved path doesn't start with UPLOAD_DIR, reject it if (!requested.startsWith(UPLOAD_DIR + path.sep)) { return res.status(403).json({ error: 'Forbidden' }); } res.sendFile(requested); });

5 — Timing Attacks on Secret Comparison

// VULNERABLE: early-exit string comparison leaks timing information // An attacker can measure response times to determine correct chars one by one if (req.headers['x-api-key'] === process.env.API_KEY) { // ← timing leak return next(); } // FIX: use crypto.timingSafeEqual — always compares every byte regardless of match const crypto = require('node:crypto'); function verifyApiKey(supplied, expected) { if (typeof supplied !== 'string') return false; const a = Buffer.from(supplied, 'utf8'); const b = Buffer.from(expected, 'utf8'); // Buffers must be the same length for timingSafeEqual if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } app.use('/api', (req, res, next) => { if (!verifyApiKey(req.headers['x-api-key'], process.env.API_KEY)) { return res.status(401).json({ error: 'Unauthorized' }); } next(); });

6 — npm Audit & Supply Chain Security

# Run after every npm install — shows known CVEs in your dependency tree npm audit # Auto-fix non-breaking vulnerabilities (semver-compatible updates) npm audit fix # Force-fix even if it needs major version bumps (review breaking changes!) npm audit fix --force # Machine-readable output for CI pipelines npm audit --json | jq '.vulnerabilities | keys' # Fail CI if any high/critical vulnerabilities exist npm audit --audit-level=high
// Lockfile integrity — never skip package-lock.json in CI // npm ci (vs npm install): // - Reads from package-lock.json exactly — no resolution surprises // - Fails if package-lock.json is missing or out of sync with package.json // - Always use 'npm ci' in Docker builds and CI pipelines // Detect if a package was tampered with after publish: npm.commands.audit.verify // (conceptual — use 'npm audit signatures') npm audit signatures # Node 18+: verifies registry signatures of installed packages // .npmrc hardening // Save to your project root or ~/.npmrc: // ignore-scripts=true # don't auto-run postinstall/preinstall scripts // audit=true # run audit on every npm install // fund=false # suppress funding messages in CI
Supply Chain AttackHow it worksDefence
Typosquatting lodash vs lodahs — a malicious package with a typo'd name Triple-check package names; use npm audit; internal registry mirror
Dependency confusion Attacker publishes a public package with the same name as your private package — npm may pull the higher-versioned public one Scope private packages (@yourcompany/pkg); set registry in .npmrc per scope
Malicious postinstall A package's postinstall script runs arbitrary shell commands on npm install ignore-scripts=true in .npmrc; review scripts before installing unfamiliar packages
Account takeover Attacker hijacks a maintainer's npm account and publishes a malicious version Enable 2FA on npm accounts; pin exact versions in package.json; use npm audit signatures

💡 Node.js Permission Model (v20+)

Node 20 introduced an experimental permissions flag: node --experimental-permission --allow-fs-read=/uploads server.js

This restricts what the process can access at the OS level — the process can't read /etc/passwd even if the code tries. It also supports --allow-fs-write, --allow-net, --allow-child-process, and --allow-worker. Still experimental but directionally important for defence-in-depth.

Child Process Shell Injection

Covered in Chapter 3 (node2-3): using exec() with user-controlled strings allows command injection. Always use execFile() or spawn() with an args array, never build shell strings from user input.

Environment Variable Leakage

Never log process.env directly — it contains every secret the process was started with. If an error handler serializes the request context and accidentally includes env vars, those secrets appear in logs. Use the createSafeLogger pattern from node2-6.

Security Headers Checklist

Add helmet middleware to any Express app: app.use(require('helmet')()). Sets X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Content-Security-Policy defaults, and removes the X-Powered-By: Express fingerprint.

Rate Limiting

Use express-rate-limit to cap requests per IP: rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }). Apply tighter limits to auth endpoints. Combine with slow-down to add progressive delays before the hard limit.

Coding Challenges

Challenge 1 — Safe Deep Merge

Write a deepMerge(target, ...sources) function that blocks prototype pollution (rejects __proto__, constructor, prototype keys at any depth), handles arrays by replacing (not merging) them, and correctly merges nested plain objects. Write 8 tests that cover: normal merge, nested merge, array replacement, and four prototype-pollution attack variants (__proto__, constructor.prototype, via JSON.parse, and a deeply nested payload). All four attack tests must leave Object.prototype clean after the call.

View sample solution ↗

Challenge 2 — ReDoS Detector

Write a testRegexSafety(pattern, inputs, timeoutMs) function that runs a list of test inputs against a regex pattern, each in a Worker Thread with the given timeout. It returns a report: { safe: boolean, results: [{ input, matched, durationMs, timedOut }] }safe is false if any input timed out. Demonstrate it with three patterns: a safe one (/^a+$/), a ReDoS-vulnerable one (/^(a+)+$/), and an email validator. For the vulnerable pattern, use an attack input ('a'.repeat(30) + 'b') that should time out and prove it returns safe: false.

View sample solution ↗

Challenge 3 — SSRF-Safe Fetch Middleware

Build an Express middleware ssrfGuard(allowedDomains) that reads a url query parameter, validates it (https only, domain in allowlist, resolves to a public IP), and attaches the validated URL to req.safeUrl — or sends a 400 with a descriptive error if it fails. Test the middleware with 6 inputs that should fail (http URL, file:// URL, a private IP URL, a localhost URL, a domain not in the allowlist, and a DNS rebinding simulation) and 1 that should pass. Use nock or manual DNS mocking to avoid real network calls in tests.

View sample solution ↗