In March 2022, researchers disclosed CVE-2022-23529, a remote code execution bug in jsonwebtoken 8.5.1 and earlier that let an attacker who controlled the key-lookup function forge a valid JWT. Two months later, a separate advisory covered node-tar's symlink-based arbitrary file write (CVE-2021-32803), patched in version 6.1.1. Neither bug required an exotic exploit chain — both came down to five or six lines of application code that trusted input a little too much: a jwt.verify() call missing an algorithm allowlist, a path.join() that never checked where the resolved path actually landed. This post walks through five specific code patterns — the vulnerable version and the fixed version — for the bug classes that show up most often in Node.js backend security reviews: prototype pollution, NoSQL injection, missing HTTP security headers, path traversal, and JWT algorithm confusion. Each snippet is something you can grep your own repo for today. We'll also flag two adjacent hygiene checks worth running against the same repo: your Node.js Dockerfile and the license posture of everything sitting in node_modules.
What does a prototype pollution vulnerability look like in Node.js code?
A prototype pollution bug in Node.js almost always looks like an unguarded recursive merge, clone, or "deep set" function that copies attacker-controlled keys — including __proto__ or constructor.prototype — onto a shared object. Lodash shipped three separate CVEs for this exact pattern: CVE-2018-3721 in merge/mergeWith (versions before 4.17.5), CVE-2019-10744 in defaultsDeep (versions before 4.17.12), and CVE-2020-8203 in zipObjectDeep (versions before 4.17.19, disclosed by Snyk in July 2020). A minimal vulnerable pattern looks like this:
// Vulnerable: no key filtering on a recursive merge
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, JSON.parse(req.body.settings)); // attacker sends {"__proto__":{"isAdmin":true}}
The fix rejects dangerous keys before recursing and freezes Object.prototype at process start so pollution attempts throw instead of silently succeeding:
// Fixed: block __proto__ / constructor / prototype keys explicitly
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) continue;
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Belt-and-suspenders: freeze the prototype at startup
Object.freeze(Object.prototype);
How do you stop NoSQL injection in a Mongoose or MongoDB query?
You stop NoSQL injection by validating that fields expected to be strings can't arrive as query operator objects, since MongoDB happily evaluates operators like $gt, $ne, and $where if they land inside a query document. The classic case is a login handler that passes req.body straight into findOne:
// Vulnerable: attacker sends {"username":"admin","password":{"$ne":null}}
const user = await User.findOne({
username: req.body.username,
password: req.body.password,
});
if (user) return res.json({ token: signToken(user) }); // auth bypass
Because {"$ne": null} is a valid Mongo query operator, that payload matches any document where password is not null — which is every document. The fix is to cast and validate types before they ever reach the query, either manually or with mongo-sanitize (first published to npm in 2016, still actively used to strip $-prefixed keys):
const sanitize = require('mongo-sanitize');
const username = String(sanitize(req.body.username));
const password = String(sanitize(req.body.password));
const user = await User.findOne({ username }); // exact string match only
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'invalid credentials' });
}
Coercing with String() and sanitizing keys means an object payload can no longer smuggle a query operator into the filter.
Why doesn't Express send secure HTTP headers by default, and how do you fix it?
Express doesn't send secure headers by default because it was designed as a minimal routing layer, not a hardened HTTP server — the one header it does add by default, X-Powered-By: Express, has shipped since Express 3 in 2013 and actively helps attackers fingerprint your stack for framework-specific CVEs. Left unconfigured, a response looks like this:
X-Powered-By: Express
The standard fix is the helmet middleware, which sets thirteen security-related headers with sane defaults in one line, plus explicitly disabling the fingerprinting header:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.disable('x-powered-by');
app.use(helmet()); // sets HSTS, X-Content-Type-Options, CSP, etc.
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
},
})
);
Without Strict-Transport-Security and X-Content-Type-Options: nosniff specifically, a Node.js API is exposed to protocol-downgrade and MIME-sniffing attacks that cost one line each to close.
How do you prevent path traversal when a Node.js app reads a file based on user input?
You prevent path traversal by resolving the final path and checking it still starts with your intended base directory, rather than trusting path.join() alone — path.join('/uploads', '../../etc/passwd') still resolves outside /uploads, and path.join will not stop it. Zip-based variants of this bug (zip-slip) hit real packages: CVE-2018-16487 affected multiple archive-extraction libraries, and node-tar's CVE-2021-32803 (fixed in 6.1.1, disclosed August 2021) let a malicious tarball write files outside the extraction directory via symlinks. A vulnerable download handler:
// Vulnerable: no check that the resolved path stays inside baseDir
app.get('/download', (req, res) => {
const filePath = path.join(__dirname, 'uploads', req.query.file);
res.sendFile(filePath); // ?file=../../../etc/passwd
});
The fix resolves both paths to absolute form and rejects anything that escapes the base directory:
app.get('/download', (req, res) => {
const baseDir = path.resolve(__dirname, 'uploads');
const requested = path.resolve(baseDir, req.query.file);
if (!requested.startsWith(baseDir + path.sep)) {
return res.status(400).json({ error: 'invalid path' });
}
res.sendFile(requested);
});
The path.sep suffix check matters: without it, /uploads-secret would incorrectly pass a naive startsWith('/uploads') test.
What is JWT algorithm confusion, and how do you call jwt.verify() safely?
JWT algorithm confusion happens when a server accepts whatever alg value an incoming token specifies instead of pinning it to the algorithm the server actually issues, letting an attacker switch a token from RS256 to HS256 and sign it using the server's own RSA public key as an HMAC secret. jsonwebtoken shipped two directly relevant advisories: CVE-2015-9235, where versions before 4.2.2 would accept a token signed with alg: none if the verify call omitted an explicit algorithm list, and CVE-2022-23529, disclosed by Unit42 researchers in January 2022, where a malicious secretOrPublicKey callback could trigger RCE via a crafted JWKS response in jsonwebtoken version 8.5.1 or earlier. Both trace back to the same missing constraint:
// Vulnerable: no algorithm allowlist, decoder trusts the token's own header
const decoded = jwt.verify(token, publicKey);
// Fixed: pin the algorithm explicitly and set an issuer/audience check
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
});
Pinning algorithms: ['RS256'] means a forged HS256 token is rejected before signature verification even runs, closing the confusion attack regardless of which key material an attacker manages to obtain.
A quick Dockerfile and license check for Node.js
None of the five snippets above matter much if the container shipping them is misconfigured, or if a transitive dependency's license creates its own liability. A minimal, secure Dockerfile for Node.js pins an exact digest rather than a floating tag, runs as a non-root user, and copies only the built artifact into the final stage:
FROM node:20.11.1-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM node:20.11.1-bookworm-slim
RUN useradd --user-group --create-home appuser
WORKDIR /app
COPY --from=build /app .
USER appuser
CMD ["node", "server.js"]
Pull your base image from Docker Hub's official node repository rather than an unverified third-party Node.js image on Docker Hub, and re-check the digest against what you last scanned before promoting it. Separately, Node.js itself ships under the permissive Node.js license (an MIT-derived license), but that says nothing about the hundreds of transitive packages in node_modules — an SCA scan that reports on package licenses, not just CVEs, is the only reliable way to know if one of them is GPL-licensed or otherwise incompatible with how you distribute your service.
How Safeguard Helps
Safeguard turns these patterns into continuous, prioritized findings instead of one-off audit notes. Reachability analysis confirms whether a vulnerable function — say, an unpatched jsonwebtoken verify path or a lodash merge call — is actually invoked in your running code paths, so teams stop triaging CVEs in dependencies that are present but never called. Griffin AI reviews the surrounding logic to flag custom code that reproduces these same bug classes even when no CVE exists yet, such as a hand-rolled merge function or a path.join() missing a boundary check. Safeguard generates and ingests SBOMs across your Node.js services to track exactly which package versions carry these advisories, and where a fix is available, auto-fix PRs open the dependency bump or the corrected snippet directly against your branch for review.