Express / NestJS audit patterns
TL;DR: Express’s minimalism means every security check is opt-in — audit middleware order, missing helmet/csurf, route-level auth gaps, and prototype pollution surfaces (
body-parserextended:true,qs). NestJS adds DI + decorators that look secure but require guard/pipe wiring to actually be; verify@UseGuards/@UsePipeson every controller method, not just the class.
Express bug patterns
1. Middleware ordering
- Auth middleware after a route registers = auth bypass for that route.
- Logging or error-handler before auth means logs see secrets.
app.use(express.static('public'))before auth = public files reachable; combined with predictable upload paths → IDOR.
2. Route-level auth gaps
app.get('/admin/:id', adminOnly, handler)— every admin route must include the middleware. Forgetting one = unauthenticated admin endpoint.- Wildcard routes (
app.get('/api/*', ...)) at the bottom of the file catch unintended paths.
3. Prototype pollution funnel
body-parserextended:true(default!) usesqslibrary — parses?a[__proto__][admin]=1into{a: {__proto__: {admin: 1}}}. If downstream merges that into another object → prototype-pollution.app.use(express.json())withObject.assign(target, req.body)recursive variants — same risk.- Audit any
_.merge,_.set, custom recursive copy that touchesreq.body.
4. CSRF / cookies
csurfdeprecated in 2022 — replacement options: SameSite=strict + double-submit pattern, orcsrf-csrfnpm package.cookie-parserwith nosecretarg = unsigned cookies (tamperable).express-sessionwith defaultMemoryStorein prod = process-local + memory leak; clusters break.
5. SSRF
app.post('/fetch', (req, res) => fetch(req.body.url))— classic SSRF entry.request/axios/node-fetchwith no allowlist or IP filter.- Internal
IMDS/ metadata pivot via redirect-follow (default true).
6. Path traversal
app.get('/file/:name', (req, res) => res.sendFile(path.join(root, req.params.name)))—..innametraverses.res.sendFilewith{root: '/var/www'}arg AND adotfiles:'deny'is correct; absent → traversal.
7. SQLi via raw queries
db.query('SELECT * FROM users WHERE id=' + req.params.id)— raw concat.mysql2connection.query(sql, [params])parameterised when?placeholders used.pgclient.query('... $1', [val])parameterised;client.query(\… ${val}`)` is SQLi.
8. Template engines
app.set('view engine', 'ejs')+res.render(req.params.template, ...)— template-name injection → render attacker-controlled file → SSTI/LFI.- Handlebars
compile(userInput)= RCE-equivalent.
9. NoSQL injection
User.find({name: req.body.name})safe.User.find(req.body)— attacker sends{name: {$ne: null}}to match all rows. See nosql-injection.
10. JWT
jsonwebtoken.verify(token, secret)with noalgorithmsoption = allowsnone. Always specifyalgorithms: ['HS256'].jwt.decodereturns payload without verifying signature; common dev mistake.
NestJS bug patterns
1. Guards per-method vs per-class
@UseGuards(JwtAuthGuard)on class is fine for full coverage; per-method only protects that method. Mixed → easy to miss.@Public()decorator (custom) often used to opt-out; audit every@Public()for whether the route really should be unauth.
2. ValidationPipe + class-validator
- Global
app.useGlobalPipes(new ValidationPipe({whitelist: true, forbidNonWhitelisted: true}))blocks mass-assignment via DTO whitelisting. Withoutwhitelist: true, extra fields pass through → mass-assignment viaObject.assign(entity, dto). - DTO must use
class-validatordecorators; plain types are unvalidated.
3. Interceptors vs guards order
- Guards run before interceptors. Auth must be a guard, not an interceptor.
@UseInterceptors(LoggingInterceptor)running before@UseGuards= logging fires on unauth requests too (sometimes desired, sometimes a secret leak).
4. TypeORM/Prisma raw query
userRepo.query(SELECT * FROM users WHERE name=’${name}’)→ SQLi.- TypeORM
QueryBuilder.where("name = '" + name + "'")→ SQLi. - Prisma
$queryRaw\… ${val}`parameterises (Prisma tagged template);$queryRawUnsafe(query, …args)` does not unless you pass args.
5. GraphQL resolvers (NestJS + Apollo)
@ResolveFieldwithout@UseGuardsallows nested data leak even if parent query is gated.- N+1 query through resolvers → DoS;
@nestjs/graphqlhasDataLoaderintegration — audit usage. - Query depth/complexity not capped → DoS.
6. WebSocket / Microservice transports
@WebSocketGatewayhandlers — auth typically done inhandleConnection, but per-message guards are opt-in (@UseGuardson@SubscribeMessage).- Microservice
@MessagePatternhandlers — same trust assumption that may not hold cross-network.
7. Common CVEs
@nestjs/swagger<recent — XSS via spec generation.cookie-signatureparsing CVE chain.mongoosequery selector injection CVEs (pre-7).
Grep starter
1
2
3
4
5
6
7
8
9
10
11
# Express
rg -n 'app\.use\(express\.static' src
rg -n 'app\.get|app\.post|app\.use' -A1 src | grep -v -E 'auth|require' # routes lacking auth
rg -n 'extended:\s*true' src
rg -n 'jwt\.verify\([^,]+,[^,]+\)\s*[,)]' src # missing algorithms
# NestJS
rg -n '@UseGuards\(' src/**/*.ts | wc -l
rg -n '@Controller' src/**/*.ts | wc -l # compare counts
rg -n '@Public\(\)' src
rg -n '\$queryRawUnsafe|\.query\(.*\$' src
rg -n '@WebSocketGateway|@MessagePattern' src
Tooling
eslint-plugin-security+eslint-plugin-no-unsanitized.npm audit/pnpm audit/ Snyk / Socket.- Semgrep
javascript.express.security,javascript.nestjs.security. - NestJS:
@nestjs/throttlerfor rate-limiting, must be applied per controller.