Node.js HTTP Server Security Audit
Audit Node.js backend code for vulnerabilities in HTTP servers built on Express, Koa, Hapi, or the standard library. Defensive find-and-fix.
When this skill applies
- Reviewing Express / Koa / Hapi route handlers and middleware
- Auditing middleware order and configuration
- Reviewing file upload pipelines
- Checking error handling for info disclosure
- Identifying Node-specific risks (prototype pollution, event loop blocking, ReDoS, path traversal)
- Reviewing third-party Node packages for known issues
Use other skills for: NestJS (nestjs-security), Fastify (fastify-security), Hono (hono-security), Next.js API routes (nextjs-security), ORM-specific concerns (prisma-orm-security, mongoose-mongodb-security), generic patterns (saas-security-pack/saas-code-security-review).
Workflow
Follow ../_shared/audit-workflow.md. Node-specific notes below.
Phase 1: Stack detection
# Identify framework
node -e "const p=require('./package.json'); console.log(Object.keys({...p.dependencies, ...p.devDependencies}).filter(k => /^(express|koa|hapi|fastify|nestjs|hono)$/.test(k)))"
# Node version
node --version
grep '"node":' package.json
Phase 2: Inventory
# Entry point
grep -E '"main"|"start"' package.json
# Route definitions
grep -rn 'app\.\(get\|post\|put\|delete\|patch\|use\)\|router\.\(get\|post\)' src/ | head -50
# Middleware chain (often in app.js / server.js / index.js)
grep -rn 'app\.use(' src/
# Body parsers
grep -n 'body-parser\|express.json\|express.urlencoded\|koa-bodyparser' src/
# Session middleware
grep -rn 'express-session\|cookie-session\|koa-session' src/
# File uploads
grep -rn 'multer\|busboy\|formidable\|@fastify/multipart' src/
# CORS config
grep -rn 'cors(\|app.use(cors' src/
# Helmet (security headers)
grep -rn 'helmet' src/
Phase 3: Detection — the checks
Middleware ordering
Order matters. Common bugs:
- NDE-MW-1
helmet() registered AFTER body parsers and route handlers — security headers don't apply consistently. Register helmet first.
- NDE-MW-2 Error handler not last — Express requires
(err, req, res, next) middleware as the final use(). If a route handler throws before reaching it, errors hit the default handler which leaks stack traces.
- NDE-MW-3 Auth middleware registered after routes that should be protected — those routes are unauthenticated.
// BAD
app.use('/api/admin', adminRouter);
app.use(requireAuth); // ← too late, adminRouter already mounted unprotected
// GOOD
app.use(requireAuth);
app.use('/api/admin', adminRouter);
- NDE-MW-4 Rate limiter only on a subset of routes when it should apply broadly. Mount the limiter as early app.use, before routes.
- NDE-MW-5 Body parser size limit too high (or default). Set explicitly:
app.use(express.json({ limit: '100kb' })); // not '50mb' unless intended
app.use(express.urlencoded({ extended: false, limit: '100kb' }));
extended: false uses querystring parser (safer), extended: true uses qs (prototype-pollution-vulnerable in old versions).
Helmet — what it sets
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: { /* see saas-frontend-hardening */ },
crossOriginEmbedderPolicy: { policy: 'require-corp' },
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-origin' },
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
// ...
}));
- NDE-HLM-1 Helmet installed and configured (not just imported).
- NDE-HLM-2 Helmet's default CSP is strict — if the app sets a custom CSP, verify it's not weaker than helmet's default.
- NDE-HLM-3 Helmet doesn't add headers if the response was already sent or piped (e.g., file streams). Confirm static file routes have headers too —
helmet.contentTypeOptions() etc.
CORS
- NDE-COR-1
cors() with no options uses origin: '*' — allows all origins, blocks credentials. Almost never what you want for an authenticated API.
- NDE-COR-2
origin: true reflects whatever Origin is sent — equivalent to * for non-credentialed, but with credentials: true enabled, this is a serious vulnerability.
- NDE-COR-3 Allowlist explicitly:
const allowList = ['https://app.yourorg.com', 'https://staging.yourorg.com'];
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowList.includes(origin)) cb(null, true);
else cb(new Error('CORS blocked'));
},
credentials: true,
}));
- NDE-COR-4 Subdomain wildcard regex carefully:
/^https:\/\/.*\.yourorg\.com$/ matches https://evil.yourorg.com.attacker.com too if not anchored properly.
Session management (express-session, koa-session, cookie-session)
- NDE-SES-1 Default session secret not used (
'keyboard cat' or empty). Use a strong secret from env.
- NDE-SES-2
cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: ... } set.
- NDE-SES-3 Session store is not the default MemoryStore in production (MemoryStore leaks memory, doesn't share across instances).
- NDE-SES-4
resave: false, saveUninitialized: false — don't write sessions for unauthenticated users.
- NDE-SES-5 Session regeneration on login (
req.session.regenerate(...) for express-session) — prevents session fixation.
- NDE-SES-6 Session destruction on logout (
req.session.destroy(...) AND clear cookie).
- NDE-SES-7 Use
cookie-session only for stateless tokens, not stateful sessions — it stores the full session in the cookie (size limit + tamper risk).
File uploads (multer, busboy, formidable)
- NDE-UPL-1
multer({ limits: { fileSize: ... } }) set to a reasonable max (avoids DoS via huge files).
- NDE-UPL-2
multer({ limits: { files: ..., fields: ... } }) — caps on file count and field count.
- NDE-UPL-3 File type validated by magic bytes (file-type library), not just by
mimetype or extension (both attacker-controlled).
- NDE-UPL-4
dest or storage not in a web-accessible path. If files must be served, serve through a route that does authz, not via static directory.
- NDE-UPL-5 Filenames sanitized —
path.basename + strip dangerous chars + prefix with UUID. No raw file.originalname as filesystem path.
- NDE-UPL-6 Disk storage temp files cleaned up on error.
- NDE-UPL-7 Memory storage limits accounted for —
multer.memoryStorage() loads entire file into memory.
import multer from 'multer';
import { fileTypeFromBuffer } from 'file-type';
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 3 },
});
app.post('/upload', upload.array('files', 3), async (req, res) => {
for (const f of req.files) {
const detected = await fileTypeFromBuffer(f.buffer);
if (!detected || !['image/png', 'image/jpeg', 'image/webp'].includes(detected.mime)) {
return res.status(400).json({ error: 'Unsupported type' });
}
}
// ... save with UUID filename, never `f.originalname` as path
});
Path traversal
Prototype pollution
- NDE-PP-1
qs (used by express.urlencoded({ extended: true })) in old versions had prototype pollution issues. Ensure express + qs are current.
- NDE-PP-2
lodash.merge, lodash.mergeWith, _.defaultsDeep with user input → prototype pollution. Use _.merge from current lodash; better, avoid these functions on untrusted input entirely.
- NDE-PP-3 Custom merge / extend functions verified to skip
__proto__, constructor, prototype keys.
ReDoS (Regular expression DoS)
- NDE-REDOS-1 User input matched against complex regex with backtracking is a DoS vector. Audit any
String.match / RegExp.test against user input where the pattern has nested quantifiers.
- NDE-REDOS-2 Use safe regex libraries (
safe-regex, re2 for re-implementing in Rust) or precompile and limit.
Event loop blocking
- NDE-LOOP-1 No synchronous file I/O (
readFileSync) in request handlers. Use async.
- NDE-LOOP-2 No synchronous crypto (
crypto.pbkdf2Sync on long-running passwords). Use async variant.
- NDE-LOOP-3 JSON.parse / stringify of large payloads — set body size limits.
- NDE-LOOP-4 CPU-heavy work (image processing, PDF generation) offloaded to worker threads or external services.
Error handling and info disclosure
- NDE-ERR-1 Catch-all error handler in production returns generic messages; stack traces logged server-side only.
app.use((err, req, res, next) => {
logger.error({ err, req: { method: req.method, url: req.url, id: req.id } });
res.status(err.status || 500).json({ error: 'Internal Server Error' });
});
- NDE-ERR-2 No
app.disable('etag') needed, but app.disable('x-powered-by') set (or rely on helmet to strip it).
- NDE-ERR-3 404 handler returns minimal info; doesn't echo back the requested path verbatim if not needed.
Dependency hygiene
- NDE-DEP-1
npm audit clean for --production deps OR exceptions documented.
- NDE-DEP-2 Dependency-graph awareness — many Express middleware packages haven't been updated in years. Replace unmaintained ones.
- NDE-DEP-3 Specific high-impact CVEs to check:
- Old
body-parser, qs, lodash, minimist, node-fetch versions
axios < 1.7 (various CVEs)
passport-* strategies with known weaknesses
Native modules and child_process
- NDE-CP-1
child_process.exec with user-controlled args — use execFile (no shell) with arg array.
- NDE-CP-2 Native modules from non-official sources audited; supply chain risk.
- NDE-CP-3
vm.runInNewContext with user code is NOT a sandbox (Node leaks easily) — use isolated-vm if truly needed, or refactor.
Phase 4: Triage
Critical class examples:
cors({ origin: true, credentials: true }) with auth cookies
multer accepting any file type, saved with originalname as path
- Session middleware with default secret
- Express version with CVEs in dependency chain
child_process.exec with user input
Phase 5: Report
Use ../_shared/findings-schema.md. Prefix IDs with NDE-.
References
references/middleware-order-pitfalls.md — Common middleware misordering, with diagnostic patterns
1---2name: nodejs-express-security3description: Security audit for Node.js HTTP servers using Express, Koa, Hapi, or plain http/https — covering middleware ordering, body parser config, helmet usage, session management with express-session, CORS configuration, error handling, file upload patterns (multer/busboy), and common Node-specific vulnerabilities like prototype pollution, ReDoS, and event loop blocking. Use this skill whenever the user mentions Express, Koa, Hapi, express-session, helmet, multer, body-parser, Node.js server, npm packages with known CVEs, or asks "audit my Express app", "Node.js security review", "is my Express middleware safe", "Koa security". Trigger when the codebase contains `require('express')`, `from 'express'`, `express()`, `new Koa()`, or similar Node HTTP server patterns.4---56# Node.js HTTP Server Security Audit78Audit Node.js backend code for vulnerabilities in HTTP servers built on Express, Koa, Hapi, or the standard library. Defensive find-and-fix.910## When this skill applies1112- Reviewing Express / Koa / Hapi route handlers and middleware13- Auditing middleware order and configuration14- Reviewing file upload pipelines15- Checking error handling for info disclosure16- Identifying Node-specific risks (prototype pollution, event loop blocking, ReDoS, path traversal)17- Reviewing third-party Node packages for known issues1819Use other skills for: NestJS (`nestjs-security`), Fastify (`fastify-security`), Hono (`hono-security`), Next.js API routes (`nextjs-security`), ORM-specific concerns (`prisma-orm-security`, `mongoose-mongodb-security`), generic patterns (`saas-security-pack/saas-code-security-review`).2021## Workflow2223Follow `../_shared/audit-workflow.md`. Node-specific notes below.2425### Phase 1: Stack detection2627```bash28# Identify framework29node -e "const p=require('./package.json'); console.log(Object.keys({...p.dependencies, ...p.devDependencies}).filter(k => /^(express|koa|hapi|fastify|nestjs|hono)$/.test(k)))"3031# Node version32node --version33grep '"node":' package.json34```3536### Phase 2: Inventory3738```bash39# Entry point40grep -E '"main"|"start"' package.json4142# Route definitions43grep -rn 'app\.\(get\|post\|put\|delete\|patch\|use\)\|router\.\(get\|post\)' src/ | head -504445# Middleware chain (often in app.js / server.js / index.js)46grep -rn 'app\.use(' src/4748# Body parsers49grep -n 'body-parser\|express.json\|express.urlencoded\|koa-bodyparser' src/5051# Session middleware52grep -rn 'express-session\|cookie-session\|koa-session' src/5354# File uploads55grep -rn 'multer\|busboy\|formidable\|@fastify/multipart' src/5657# CORS config58grep -rn 'cors(\|app.use(cors' src/5960# Helmet (security headers)61grep -rn 'helmet' src/62```6364### Phase 3: Detection — the checks6566#### Middleware ordering6768Order matters. Common bugs:6970- **NDE-MW-1** `helmet()` registered AFTER body parsers and route handlers — security headers don't apply consistently. Register helmet first.71- **NDE-MW-2** Error handler not last — Express requires `(err, req, res, next)` middleware as the final use(). If a route handler throws before reaching it, errors hit the default handler which leaks stack traces.72- **NDE-MW-3** Auth middleware registered after routes that should be protected — those routes are unauthenticated.73 ```js74 // BAD75 app.use('/api/admin', adminRouter);76 app.use(requireAuth); // ← too late, adminRouter already mounted unprotected77 78 // GOOD79 app.use(requireAuth);80 app.use('/api/admin', adminRouter);81 ```82- **NDE-MW-4** Rate limiter only on a subset of routes when it should apply broadly. Mount the limiter as early app.use, before routes.83- **NDE-MW-5** Body parser size limit too high (or default). Set explicitly:84 ```js85 app.use(express.json({ limit: '100kb' })); // not '50mb' unless intended86 app.use(express.urlencoded({ extended: false, limit: '100kb' }));87 ```88 `extended: false` uses querystring parser (safer), `extended: true` uses qs (prototype-pollution-vulnerable in old versions).8990#### Helmet — what it sets9192```js93import helmet from 'helmet';94app.use(helmet({95 contentSecurityPolicy: { /* see saas-frontend-hardening */ },96 crossOriginEmbedderPolicy: { policy: 'require-corp' },97 crossOriginOpenerPolicy: { policy: 'same-origin' },98 crossOriginResourcePolicy: { policy: 'same-origin' },99 hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },100 // ...101}));102```103104- **NDE-HLM-1** Helmet installed and configured (not just imported).105- **NDE-HLM-2** Helmet's default CSP is strict — if the app sets a custom CSP, verify it's not weaker than helmet's default.106- **NDE-HLM-3** Helmet doesn't add headers if the response was already sent or piped (e.g., file streams). Confirm static file routes have headers too — `helmet.contentTypeOptions()` etc.107108#### CORS109110- **NDE-COR-1** `cors()` with no options uses `origin: '*'` — allows all origins, blocks credentials. Almost never what you want for an authenticated API.111- **NDE-COR-2** `origin: true` reflects whatever Origin is sent — equivalent to `*` for non-credentialed, but with `credentials: true` enabled, this is a serious vulnerability.112- **NDE-COR-3** Allowlist explicitly:113 ```js114 const allowList = ['https://app.yourorg.com', 'https://staging.yourorg.com'];115 app.use(cors({116 origin: (origin, cb) => {117 if (!origin || allowList.includes(origin)) cb(null, true);118 else cb(new Error('CORS blocked'));119 },120 credentials: true,121 }));122 ```123- **NDE-COR-4** Subdomain wildcard regex carefully: `/^https:\/\/.*\.yourorg\.com$/` matches `https://evil.yourorg.com.attacker.com` too if not anchored properly.124125#### Session management (express-session, koa-session, cookie-session)126127- **NDE-SES-1** Default session secret not used (`'keyboard cat'` or empty). Use a strong secret from env.128- **NDE-SES-2** `cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: ... }` set.129- **NDE-SES-3** Session store is not the default MemoryStore in production (MemoryStore leaks memory, doesn't share across instances).130- **NDE-SES-4** `resave: false, saveUninitialized: false` — don't write sessions for unauthenticated users.131- **NDE-SES-5** Session regeneration on login (`req.session.regenerate(...)` for express-session) — prevents session fixation.132- **NDE-SES-6** Session destruction on logout (`req.session.destroy(...)` AND clear cookie).133- **NDE-SES-7** Use `cookie-session` only for stateless tokens, not stateful sessions — it stores the full session in the cookie (size limit + tamper risk).134135#### File uploads (multer, busboy, formidable)136137- **NDE-UPL-1** `multer({ limits: { fileSize: ... } })` set to a reasonable max (avoids DoS via huge files).138- **NDE-UPL-2** `multer({ limits: { files: ..., fields: ... } })` — caps on file count and field count.139- **NDE-UPL-3** File type validated by magic bytes (file-type library), not just by `mimetype` or extension (both attacker-controlled).140- **NDE-UPL-4** `dest` or storage not in a web-accessible path. If files must be served, serve through a route that does authz, not via static directory.141- **NDE-UPL-5** Filenames sanitized — `path.basename` + strip dangerous chars + prefix with UUID. No raw `file.originalname` as filesystem path.142- **NDE-UPL-6** Disk storage temp files cleaned up on error.143- **NDE-UPL-7** Memory storage limits accounted for — `multer.memoryStorage()` loads entire file into memory.144145```js146import multer from 'multer';147import { fileTypeFromBuffer } from 'file-type';148149const upload = multer({150 storage: multer.memoryStorage(),151 limits: { fileSize: 5 * 1024 * 1024, files: 3 },152});153154app.post('/upload', upload.array('files', 3), async (req, res) => {155 for (const f of req.files) {156 const detected = await fileTypeFromBuffer(f.buffer);157 if (!detected || !['image/png', 'image/jpeg', 'image/webp'].includes(detected.mime)) {158 return res.status(400).json({ error: 'Unsupported type' });159 }160 }161 // ... save with UUID filename, never `f.originalname` as path162});163```164165#### Path traversal166167- **NDE-PATH-1** `res.sendFile(path.join(__dirname, req.params.file))` — `req.params.file` could be `../../../etc/passwd`. Use `path.resolve` + check the result is under the intended root:168 ```js169 const root = path.resolve('./public');170 const file = path.resolve(root, req.params.file);171 if (!file.startsWith(root + path.sep)) return res.status(400).end();172 res.sendFile(file);173 ```174- **NDE-PATH-2** `express.static` correctly restricts to its root; custom file-serving routes often don't.175- **NDE-PATH-3** Archive extraction (zip, tar) — extract paths verified to stay within target dir (zip-slip vulnerability).176177#### Prototype pollution178179- **NDE-PP-1** `qs` (used by `express.urlencoded({ extended: true })`) in old versions had prototype pollution issues. Ensure express + qs are current.180- **NDE-PP-2** `lodash.merge`, `lodash.mergeWith`, `_.defaultsDeep` with user input → prototype pollution. Use `_.merge` from current lodash; better, avoid these functions on untrusted input entirely.181- **NDE-PP-3** Custom merge / extend functions verified to skip `__proto__`, `constructor`, `prototype` keys.182183#### ReDoS (Regular expression DoS)184185- **NDE-REDOS-1** User input matched against complex regex with backtracking is a DoS vector. Audit any `String.match` / `RegExp.test` against user input where the pattern has nested quantifiers.186- **NDE-REDOS-2** Use safe regex libraries (`safe-regex`, `re2` for re-implementing in Rust) or precompile and limit.187188#### Event loop blocking189190- **NDE-LOOP-1** No synchronous file I/O (`readFileSync`) in request handlers. Use async.191- **NDE-LOOP-2** No synchronous crypto (`crypto.pbkdf2Sync` on long-running passwords). Use async variant.192- **NDE-LOOP-3** JSON.parse / stringify of large payloads — set body size limits.193- **NDE-LOOP-4** CPU-heavy work (image processing, PDF generation) offloaded to worker threads or external services.194195#### Error handling and info disclosure196197- **NDE-ERR-1** Catch-all error handler in production returns generic messages; stack traces logged server-side only.198 ```js199 app.use((err, req, res, next) => {200 logger.error({ err, req: { method: req.method, url: req.url, id: req.id } });201 res.status(err.status || 500).json({ error: 'Internal Server Error' });202 });203 ```204- **NDE-ERR-2** No `app.disable('etag')` needed, but `app.disable('x-powered-by')` set (or rely on helmet to strip it).205- **NDE-ERR-3** 404 handler returns minimal info; doesn't echo back the requested path verbatim if not needed.206207#### Dependency hygiene208209- **NDE-DEP-1** `npm audit` clean for `--production` deps OR exceptions documented.210- **NDE-DEP-2** Dependency-graph awareness — many Express middleware packages haven't been updated in years. Replace unmaintained ones.211- **NDE-DEP-3** Specific high-impact CVEs to check:212 - Old `body-parser`, `qs`, `lodash`, `minimist`, `node-fetch` versions213 - `axios < 1.7` (various CVEs)214 - `passport-*` strategies with known weaknesses215216#### Native modules and child_process217218- **NDE-CP-1** `child_process.exec` with user-controlled args — use `execFile` (no shell) with arg array.219- **NDE-CP-2** Native modules from non-official sources audited; supply chain risk.220- **NDE-CP-3** `vm.runInNewContext` with user code is NOT a sandbox (Node leaks easily) — use `isolated-vm` if truly needed, or refactor.221222### Phase 4: Triage223224Critical class examples:225- `cors({ origin: true, credentials: true })` with auth cookies226- `multer` accepting any file type, saved with `originalname` as path227- Session middleware with default secret228- Express version with CVEs in dependency chain229- `child_process.exec` with user input230231### Phase 5: Report232233Use `../_shared/findings-schema.md`. Prefix IDs with `NDE-`.234235## References236237- `references/middleware-order-pitfalls.md` — Common middleware misordering, with diagnostic patterns