Skip to content

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, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')
}

// 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

next -> sec_04_auth.md