MongoDB 1,815 has no string query language to concatenate, so classic SQL injection has no equivalent. What replaces it is operator injection: a query value arrives as an object instead of a string, and { email: { $ne: null } } matches every user. Express 5 24,430 closed the common route to that almost by accident, switching the default query parser from extended to simple so bracket syntax no longer becomes nested objects.
app.get('/q', (req, res) => res.json({ query: req.query, emailType: typeof req.query.email }));
// app.set('query parser', 'extended'); // opt back in to qs and nested objectsGET /q?email[$ne]=null&__proto__[polluted]=yes
simple {"query":{"email[$ne]":"null","__proto__[polluted]":"yes"},"emailType":"undefined"}
extended {"query":{"email":{"$ne":"null"}},"emailType":"object"}Under simple the bracket text is part of the key, so an operator can never appear; under extended it becomes a live object. Object.prototype.polluted stayed undefined either way, because qs refuses to write prototype keys. A JSON body is always parsed by JSON.parse, though, so bodies hold objects under any parser. The real fix is validation: z.string() rejects an object, z.strictObject() rejects unexpected fields (Schema Validation with Zod).
const newUser = z.strictObject({ email: z.string().email(), password: z.string().min(12) });
app.use(express.json({ limit: '10kb' })); // cap the body before parsing it
app.post('/safe/users', (req, res) => {
const p = newUser.safeParse(req.body);
if (!p.success)
return res.status(400).json({ error: 'invalid_body', issues: p.error.issues });
res.status(201).json({ saved: { id: 'u_77', email: p.data.email, role: 'reader' } });
});POST /naive/users {"email":"mallory@example.com","password":"...","role":"admin"}
201 {"saved":{"id":"u_77","email":"mallory@example.com","password":"...","role":"admin"}}
POST /safe/users (same body)
400 {"error":"invalid_body","issues":[{"code":"unrecognized_keys",
"message":"Unrecognized key: \"role\""}]}
POST /safe/users {"email":"a@example.com","password":"<20 kB of x>"}
413 text/html PayloadTooLargeError: request entity too largeThe naive route is a mass assignment bug: { ...req.body } handed the client a promotion to admin. A non-strict schema would have stripped role silently, which hides the attempt; strictObject turns it into a 400 you can alert on. Never spread a request body into a persisted object - list the fields you accept. The 413, meanwhile, returned text/html with a stack trace: a centralized error handler (Error Middleware) is part of hardening too.
Two surfaces stay yours: a route fetching a client-supplied URL is server-side request forgery, so check its host against an allowlist, and a path built from req.params must be resolved (Untrusted Input).