Input Validation and Sanitization¶
All input is evil until proven otherwise That query param , that JSON body , that header , that file upload - every byte your app receives is a potential injection vector waiting to happen Node.js doesn't protect you from bad input ; it just gives you tools to handle it right
Why Validation Matters¶
// Without validation - one injection away from disaster
app.post('/api/user', async (req, res) => {
const user = new User(req.body) // MongoDB - they can inject $where , $gt
await user.save()
res.json(user)
})
// With validation - controlled and safe
const validated = {
username: sanitizeString(req.body.username),
email: sanitizeEmail(req.body.email),
age: parseInt(req.body.age, 10) || 0
}
Every input is a vector : CLI args , file uploads , request headers , cookies , URL params , POST bodies All of it
Schema Validation with Zod¶
Zod is the modern choice - TypeScript-first schema validation that catches type errors at the boundary
const { z } = require('zod')
const UserSchema = z.object({
username: z.string()
.min(3, 'username too short')
.max(30, 'username too long')
.regex(/^[a-zA-Z0-9_]+$/, 'alphanumeric and underscores only'),
email: z.string()
.email('invalid email format'),
age: z.number()
.int('must be integer')
.positive('age must be positive')
.max(150, 'age seems unrealistic'),
role: z.enum(['user', 'admin', 'moderator'])
.default('user'),
bio: z.string()
.max(500, 'bio too long')
.optional()
})
// Validation middleware
function validate(schema) {
return (req, res, next) => {
try {
req.validated = schema.parse(req.body)
next()
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(400).json({
error: 'validation failed',
details: err.errors(e => ({
field: e.path.join('.'),
message: e.message
}))
})
}
next(err)
}
}
}
app.post('/api/user', validate(UserSchema), async (req, res) => {
// req.validated is safe
const user = await User.create(req.validated)
res.json(user)
})
Validation with Joi (Legacy Alternative)¶
Joi is the older guard - still solid , still widely used in existing codebases
const Joi = require('joi')
const schema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).pattern(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'must contain uppercase , lowercase , and number'
),
acceptTerms: Joi.boolean().valid(true).required()
})
const { error, value } = schema.validate(req.body)
if (error) {
return res.status(400).json({ error: error.details[0].message })
}
XSS Prevention: Escaping HTML¶
Node.js server-side rendering needs output escaping - never trust user-generated content
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
// In templates
app.get('/profile', (req, res) => {
const user = { bio: '<script>stealCookies()</script>' }
res.send(`<div>${escapeHtml(user.bio)}</div>`)
})
Use template engines with auto-escaping (EJS , Handlebars , Pug) : they handle HTML escaping by default if you use the correct syntax
SQL Injection Prevention¶
const { Pool } = require('pg')
const pool = new Pool()
// DON'T: string building
const query = `SELECT * FROM users WHERE email = '${email}' AND active = true`
// DO: parameterized queries
const { rows } = await pool.query(
'SELECT * FROM users WHERE email = $1 AND active = $2',
[email, true]
)
// DO: with named parameters (if using something like node-postgres-patterns)
// DO: use an ORM with parameterized queries built in
const user = await User.findOne({
where: { email, active: true }
})
For MySQL : use ? placeholders with mysql2 - same principle , different syntax
NoSQL Injection Prevention¶
MongoDB is especially vulnerable because objects are passed directly to queries
// VULNERABLE: attacker sends {"email": {"$gt": ""}, "password": {"$ne": ""}}
const user = await User.findOne({
email: req.body.email,
password: req.body.password
})
// This logs in as the first user in the database!
// SAFE: flatten the input - no object injection
function sanitize(obj) {
if (typeof obj !== 'object' || obj === null) return obj
if (Array.isArray(obj)) return obj(sanitize)
const safe = {}
for (const [key, val] of Object.entries(obj)) {
if (key.startsWith('$')) continue // block operators
if (key.includes('.')) continue // block dot notation
safe[key] = sanitize(val)
}
return safe
}
// SAFE: use Zod or similar to enforce shape
const LoginSchema = z.object({
email: z.string().email(),
password: z.string()
})
const { email, password } = LoginSchema.parse(req.body)
const token = jwt.sign({ email }, process.env.JWT_SECRET)
Never pass req.body directly to Mongoose or MongoDB queries : strip operators , use schema validation , or use mongodb-sanitize
SSRF Prevention: URL Validation¶
const { URL } = require('url')
const { lookup } = require('dns').promises
const net = require('net')
const BLOCKED_RANGES = [
'127.0.0.0/8', // loopback
'10.0.0.0/8', // private
'172.16.0.0/12', // private
'192.168.0.0/16', // private
'169.254.0.0/16', // link-local
'0.0.0.0/8', // current network
]
function isPrivateIP(ip) {
const parsed = net.isIPv4(ip) ? ip : net.isIPv6(ip)
if (!parsed) return true
// Simple check for common ranges
if (ip.startsWith('127.') || ip.startsWith('10.') ||
ip.startsWith('192.168.') || ip.startsWith('169.254.')) return true
if (ip.startsWith('172.') && parseInt(ip.split('.')[1], 10) >= 16 &&
parseInt(ip.split('.')[1], 10) <= 31) return true
return false
}
async function safeFetch(urlString) {
let parsed
try {
parsed = new URL(urlString)
} catch {
throw new Error('invalid URL')
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('only HTTP(S) allowed')
}
// Prevent DNS rebinding by resolving and checking IP
try {
const addresses = await lookup(parsed.hostname)
if (isPrivateIP(addresses)) {
throw new Error('internal IP blocked')
}
} catch (err) {
throw new Error('unreachable or forbidden host')
}
return fetch(urlString, { signal: AbortSignal.timeout(5000) })
}
Prerequisites¶
- sec_02_crypto.md - understand cryptographic primitives
next -> sec_04_auth.md