Input Sanitization
Validation checks that input has the right shape and type; sanitization transforms it to remove or neutralise dangerous content. Both are necessary — validation stops malformed data, sanitization stops data that is structurally valid but contains payloads like <script> tags or SQL fragments. This page covers the sanitization patterns that matter most in Node: HTML/XSS sanitization for user-generated content, path traversal prevention, and prototype pollution defense.
Validation vs sanitization — two different jobs
Validation | Sanitization | |
|---|---|---|
Question | Is this the right shape? | Is this safe to use? |
Action | Accept or reject | Transform — strip, escape, encode |
Tool | DOMPurify, validator.js, custom | |
Example | Is | Strip |
HTML sanitization for user-generated content
npm install isomorphic-dompurify # server-side DOMPurify
import DOMPurify from 'isomorphic-dompurify'
// User submits rich-text HTML (e.g. from a WYSIWYG editor):
const dirty = '<p>Hello <script>steal(document.cookie)</script></p>'
const clean = DOMPurify.sanitize(dirty)
// clean → '<p>Hello </p>' — script tag stripped
// Save the SANITIZED version; render it with dangerouslySetInnerHTML / {{{body}}} only when sanitized:
await db.posts.create({ body: clean })Escaping for plain-text contexts
// When you're rendering text (NOT HTML), escape before inserting into HTML templates:
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
// Modern template engines (EJS <%=, Pug =, Handlebars {{}}) auto-escape — USE them:
// <%= user.name %> ✅ auto-escaped
// <%- user.name %> ❌ raw HTML — only for pre-sanitized contentPath traversal prevention
import path from 'node:path'
// ❌ Path traversal — user controls the filename:
app.get('/files/:name', (req, res) => {
res.sendFile(`/app/uploads/${req.params.name}`)
// Attacker requests: ../../etc/passwd
})
// ✅ Resolve and verify it stays inside the allowed directory:
const BASE_DIR = path.resolve('/app/uploads')
app.get('/files/:name', (req, res) => {
const resolved = path.resolve(BASE_DIR, req.params.name)
if (!resolved.startsWith(BASE_DIR + path.sep)) {
return res.status(400).json({ error: 'Invalid file path' })
}
res.sendFile(resolved)
})Prototype pollution
// Attacker sends: { "__proto__": { "isAdmin": true } }
// A naive deep-merge can pollute Object.prototype:
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object') {
merge(target[key], source[key]) // ❌ recurses into __proto__
} else {
target[key] = source[key]
}
}
}
// ✅ Guard against __proto__, constructor, prototype keys:
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (['__proto__', 'constructor', 'prototype'].includes(key)) continue
// ...
}
}Mass assignment prevention
// ❌ Spreading req.body directly into a DB create — user can set any field:
await db.users.create({ ...req.body })
// Attacker sends: { "email": "x@x.com", "role": "admin", "verified": true }
// ✅ Explicitly pick only the fields you intend to accept:
const { email, name, password } = req.body
await db.users.create({ email, name, passwordHash: await hash(password) })General sanitization rules
Validate type and length first — reject before sanitizing; sanitization is not a substitute for validation.
Use allow-lists, not deny-lists — enumerate what's permitted, not what's forbidden.
Sanitize at the boundary — when input enters from the HTTP layer, before any processing.
Sanitize before the output context — HTML escaping at render time; SQL parameterization at query time.
Don't sanitize passwords — hash them as-is; stripping characters weakens entropy.