
Express 5.2.1 published on 1 December 2025. I opened the npm page on 22 August 2026 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. I opened the 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
I opened the Node 22 and current vm docs on 22 August 2026. 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\\(|new Function\\(|vm\\.runIn|runInNewContext|runInThisContext|require\\(['\\\"]vm2" \
--glob '!node_modules'
A formula feature belongs in a parser you wrote, or in a library that evaluates an AST and has no Function constructor on the path. A template feature belongs in a file on disk, not res.render of markup taken from the body. That second case is SSTI and is covered on the injection page.
Express 5 still will not save you here. The framework never evaluates a body. Your route does.
Error bodies still leak the tree
I opened the Express error handling guide. The default handler writes err.stack to the client when NODE_ENV is not production. In production it writes the HTML status text. A JSON API that installs its own four-argument middleware and then does res.json({ error: err.message, stack: err.stack }) 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);
sendError is the named helper. It must be the last app.use. Four arguments are how Express recognizes it. A 4xx you threw on purpose may keep err.message if you wrote that string. A 500 never includes the stack, the SQL, or the query. Set NODE_ENV=production in the process supervisor even if this function is mounted, so the default handler stays quiet if yours forgets headersSent.
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 rewrite res.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.json takes limit. 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 mergeOwn ignored a blocked key, hostAllowed returned false on a host you do not list, and sendError omitted 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"} when NODE_ENV=production and the status is 500. Expect no stack key.
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, and fetch(req.. A hit is a review, not a pass.
Set NODE_ENV=production in the process supervisor, not in a route. A Docker image that forgets the variable still serves err.stack through the default handler if sendError is missing. A laptop that exports production by accident hides the stack you wanted while debugging. Keep the variable next to the service file, and keep sendError mounted either way.
Questions we keep getting
Does Express 5 block prototype pollution?
No. 5.2.1 still hands you req.body as a plain object. The merge you write is the control. Use mergeOwn or 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.



