Node.js code auditing
TL;DR: Node audits hit a different sink set than PHP/Java: prototype pollution, command injection via
child_process, server-side template engines (EJS/Pug/Handlebars), unsafeeval/vm, deserialization inserialize-javascript/node-serialize, and the__proto__/constructor.prototypelattice. Single-threaded event loop means race-conditions cluster aroundawaitboundaries.
What it is
A modern Node app is express/Fastify/NestJS + middleware + ORM (Prisma / TypeORM / Mongoose) + a frontend bundler. The audit splits into: HTTP entry → middleware chain → handler → ORM → response. JavaScript’s weak type system means prototype pollution often crosses subsystem boundaries silently.
Preconditions / where it applies
- Source (preferred) or
node_modules/tree package.json— runtime version, framework, key deps- Build tooling — TypeScript adds compile-time but not runtime safety
- Knowledge of the engine (V8) and event-loop model
Technique
- Map entry points.
- Express:
app.get/post/put/delete,app.use,router.*. - Fastify:
fastify.route,fastify.get/post. - NestJS:
@Controller,@Get/@Post/...,@Body/@Query/@Param. - Native:
http.createServer((req,res)=>...). - WS:
socket.ioon('message'),wsconnection.
- Express:
- Trace sources.
req.body,req.query,req.params,req.cookies,req.headers. NestJS DTOs viaclass-validator(check for missing decorators). GraphQL resolver args. Watch forreq.bodygoing toObject.assign({}, req.body)— that’s a prototype-pollution funnel. - Sink catalogue — see dangerous-nodejs-sinks:
1 2 3 4 5 6 7 8 9
rg -n 'child_process\.(exec|execSync|spawn|spawnSync)\(' . rg -n '\beval\(|\bnew Function\(|\bvm\.(runIn|createScript)' . rg -n 'require\(\s*[^\'"]*\$' . # dynamic require rg -n 'fs\.(readFile|writeFile|createReadStream|createWriteStream)\(\s*req\.' . rg -n 'res\.sendFile\(\s*req\.' . # path traversal rg -n 'ejs\.render|pug\.render|handlebars\.compile' . # SSTI vectors rg -n 'unserialize\(|node-serialize|serialize-javascript' . rg -n 'JSON\.parse\(.*req\.' . # not a sink alone, but with reviver rg -n 'mongoose\..*\.find\(\s*req\.|\.findOne\(\s*req\.' . # NoSQL inj
- Prototype pollution audit. Any
merge/extend/assignrecursive on user JSON that doesn’t reject__proto__/constructor/prototypekeys. lodash<4.17.21, jQuery<3.4, hoek, just-extend. See prototype-pollution and nodejs-prototype-pollution-audit. - Command injection.
child_process.exec(ls ${userInput})is RCE.spawn('ls', [userInput])is safe arg-wise but still risky ifuserInput.startsWith('-')(flag injection). Audit any concat with&&,||,;,$(), backticks. - Path traversal.
path.join(root, req.params.file)does not strip... Usepath.resolve+startsWithcheck orpath.normalize+ check. - SSRF.
fetch(req.body.url),axios.get(req.body.url)with no allowlist. Common because internal services use IP-based URLs. Pair with ssrf. - NoSQL injection (Mongo).
User.find({ name: req.body.name })is safe;User.find(req.body)lets{name: {$ne: null}}match all. Audit any spread of user input into Mongoose queries. - Server-side template injection. EJS
<%- include(userInput) %>, Handlebarscompile(userInput), Pugpug.render(userInput). All RCE-equivalent. See ssti. - Deserialization.
node-serialize.unserialize(str)accepts_$$ND_FUNC$$_IIFE = RCE.serialize-javascriptis safer but{deserialize: true}re-evaluates functions. - JWT / auth. Check
jsonwebtokenusage:jwt.verify(token, secret, { algorithms: ['HS256'] })— without explicit algorithms list, attacker can downgrade tonone. See jwt. - Race conditions via async. Two parallel
awaits on a stateful service (e.g. counter, balance) without locking → TOCTOU. Single-threaded ≠ serial within a request.
Detection and defence
eslint-plugin-security+eslint-plugin-no-unsanitizedin CI.- Semgrep
javascript.express.security,nodejs.security. - For prototype pollution: use
Object.create(null)for maps, freeze prototypes (Object.freeze(Object.prototype)), or migrate toMap. - Replace
execwithexecFileand explicit arg arrays. helmetfor response headers;csurffor CSRF (deprecated, prefer SameSite cookies).- Snyk/
npm audit/Socket for dep CVEs.