
JavaScript on the server has the same bug classes as any backend: injection, XSS in templates, and missing object checks.
Express and Node make it easy to eval a string, to spread req.body into a query, and to ship a prototype-pollution helper you did not review. The language is not the reason. The sinks are.
The usual mistake is ‘Node is a frontend tool’ as an excuse for skipping the same reviews you would give Django.
This page is the Node-specific sinks that keep showing up and the tests that belong in an Express app.
Express 5.2.1 published on 1 December 2025. As of 22 August 2026, npm page and it is still latest. The Node vm page still opens with: The node:vm module is not a security mechanism. Do not use it to run untrusted code. A settings merge that copies every key from req.body is the other miss that still lands in 5.x.
The control is a typed copy, a host allowlist, a process that never evaluates client text, and an error handler that does not print err.stack. Keep the Helmet guide for the header pack. Read XSS when the sink is HTML, injection when the string reaches SQL or a shell, and Node error handling for the longer handler story.
Prototype pollution is a merge bug
CWE-1321 is assigning into Object.prototype through a key the client chose. After that, every ordinary object in the process can grow a property you never set on it. A later if (user.isAdmin) that forgot Object.hasOwn then reads the inherited value. The write is usually a recursive merge, a defaults helper, or lodash.merge on req.body.
Express 5.2.1 still hands you req.body as a plain object. Close the merge hatch with mergeOwn, the user URL with hostAllowed, and mark eval never.
SecureCoding
JSON.parse itself does not set the instance prototype from a __proto__ key in modern Node. The miss is the next function that copies that key with bracket assignment. Express 5.2.1 does not change that. Your merge does.
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
function mergeOwn(target, src) {
if (src === null || typeof src !== "object" || Array.isArray(src)) {
return target;
}
for (const key of Object.keys(src)) {
if (BLOCKED.has(key)) continue;
target[key] = src[key];
}
return target;
}
function emptyMap() {
return Object.create(null);
}
mergeOwn is the named helper. Object.keys already skips inherited names. The blocked set is the leftover for a source that used a null-prototype object to smuggle those strings. emptyMap is the dictionary you use when the keys are data, not a class. Do not Object.assign(settings, req.body). Do not _.merge({}, req.body) for a config object.
Node also ships --disable-proto=throw. Node CLI docs. That flag makes the __proto__ accessor throw. It does not replace mergeOwn. It is a process-level belt for a host you control. Pair it with --frozen-intrinsics only after you have tested the app: some libraries still write the built-ins.
SSRF starts when you fetch a user URL
CWE-918 is the server requesting a URL the client picked. Node 18 and later give you global fetch through undici. Express 5 does not wrap it. If a webhook field, an avatar field, or an “import from URL” box reaches fetch(req.body.url), the process is the client.
The control is an allowlist of hostnames you own or have contracted, HTTPS only, and no follow-the-redirect onto a host you did not list. DNS rebinding and decimal IPs are why a regex on the string is weaker than parsing, then comparing hostname.
const ALLOWED = new Set(["hooks.partner.example", "api.partner.example"]);
function hostAllowed(raw) {
let parsed;
try {
parsed = new URL(raw);
} catch (err) {
return false;
}
if (parsed.protocol !== "https:") return false;
if (parsed.username || parsed.password) return false;
return ALLOWED.has(parsed.hostname);
}
async function fetchHook(raw) {
if (!hostAllowed(raw)) {
const err = new Error("host rejected");
err.status = 400;
throw err;
}
return fetch(raw, { redirect: "manual", signal: AbortSignal.timeout(3000) });
}
hostAllowed and fetchHook are the named pair. A failed parse is a reject, not a fallback to http.get. redirect: "manual" stops undici from following a 302 to a host you never listed. If you must follow one hop, run hostAllowed on the Location header before the next request. Block link-local and RFC1918 only after the allowlist: a listed partner is enough for most apps, and a denylist of private IPs is the leftover for an open-redirector you do not control.
Do not pass user strings as socketPath, host, or path on http.request. Those options are how a polluted object becomes a second request.
eval and vm are not a sandbox
As of 22 August 2026, Node 22 and current vm docs. The warning is the first paragraph. vm.createContext gives a different global object. It shares the isolate. It is for a REPL or a test helper, not a tenant script.
Grep the names and delete the path:
rg -n "\\beval\\undoes both.Express 5 also forwards a rejected promise from an async route into that handler. That is the upgrade win. It is not a reason to echo a Postgres detail or a file path.
function sendError(err, req, res, next) { if (res.headersSent) return next(err); const raw = Number(err.status || err.statusCode || 500); const status = raw >= 400 && raw < 600 ? raw : 500; const client = status < 500 ? err.message : "request failed"; console.error(err); res.status(status).json({ error: client }); } app.use(sendError);
sendErroris the named helper. It must be the lastapp.use. Four arguments are how Express recognizes it. A 4xx you threw on purpose may keeperr.messageif you wrote that string. A 500 never includes the stack, the SQL, or the query. SetNODE_ENV=productionin the process supervisor even if this function is mounted, so the default handler stays quiet if yours forgetsheadersSent.The longer patterns, including operational versus programmer errors, live on the Node error handling page this article already linked.
Helmet is a header pack
helmet 8.3.0 is still the header middleware.
app.use(helmet())sets CSP, HSTS,X-Content-Type-Options, and the rest of that pack. It does not rewriteres.send. It does not stop a merge. It does not validate a URL. Mount it, then write a real CSP on the Helmet page. This page will not re-teach nonces.const helmet = require("helmet"); const express = require("express"); const app = express(); app.disable("x-powered-by"); app.use(helmet()); app.use(express.json({ limit: "32kb" }));Cap the JSON body. A 2 MB merge is a memory bug before it is a pollution bug. Express 5's
express.jsontakeslimit. Set it. If you also parse urlencoded bodies, set the same cap there. Two parsers on one route is how a large form bypasses the JSON limit you thought you set.Prove the merge, the fetch, and the handler
You are not walking an exploit. You are proving
mergeOwnignored a blocked key,hostAllowedreturned false on a host you do not list, andsendErroromitted the stack.const assert = require("node:assert/strict"); const poisoned = { constructor: { name: "nope" } }; const out = mergeOwn({ name: "ada" }, poisoned); assert.equal(out.name, "ada"); assert.equal(Object.hasOwn(out, "constructor"), false); assert.equal(hostAllowed("https://hooks.partner.example/x"), true); assert.equal(hostAllowed("https://127.0.0.1/latest"), false); assert.equal(hostAllowed("http://hooks.partner.example/x"), false);For the handler, throw from a route you own and read the JSON. Expect
{"error":"request failed"}whenNODE_ENV=productionand the status is 500. Expect nostackkey.curl -sS -D - "https://your-app.example/this-route-500s" # Expect: HTTP/2 500 # Expect body: {"error":"request failed"}Then run the greps this page already named for
eval,vm.runIn,_.merge, andfetch(req.. A hit is a review, not a pass.Set
NODE_ENV=productionin the process supervisor, not in a route. A Docker image that forgets the variable still serveserr.stackthrough the default handler ifsendErroris missing. A laptop that exportsproductionby accident hides the stack you wanted while debugging. Keep the variable next to the service file, and keepsendErrormounted either way.Questions we keep getting
Does Express 5 block prototype pollution?
No. 5.2.1 still hands you
req.bodyas a plain object. The merge you write is the control. UsemergeOwnor do not merge.Is vm safe if I pass an empty sandbox object?
No. The Node docs say the module is not a security mechanism. An empty object does not make a tenant boundary.
Can I echo err.message in production?
On a 4xx you constructed, yes if the text is yours. On a 500, no. Log the stack. Send
request failed.



