NodeJSInput Sanitization

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 email a valid email format?

Strip <script> tags from a bio field

Validate first to reject bad shapes; sanitize second to neutralise dangerous content
A common mistake is treating them as the same operation. Validation says "this string doesn't look like an email — reject it." Sanitization says "this string is a valid name but contains HTML — strip the tags before storing it." You often need both: validate that a field is a non-empty string of reasonable length, then sanitize it by escaping HTML characters before rendering it. Libraries like express-validator can do both, but it's worth understanding the separation.
HTML sanitization for user-generated content

Bash
npm install isomorphic-dompurify   # server-side DOMPurify

JS
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 })
Never render user-submitted HTML without sanitizing it first — instant XSS
If you store a user's bio or post body as-is and render it as HTML, any user can inject `<script>` tags that run in every viewer's browser — stealing session cookies, redirecting to phishing pages, or defacing your site. Always sanitize HTML with a purpose-built library (DOMPurify, sanitize-html) before storing or rendering it. Escaping (replacing `<` with `&lt;`) is sufficient if you're rendering plain text; for rich HTML you need a proper allow-list sanitizer.
Escaping for plain-text contexts

JS
// When you're rendering text (NOT HTML), escape before inserting into HTML templates:
function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
}

// Modern template engines (EJS <%=, Pug =, Handlebars {{}}) auto-escape — USE them:
// <%= user.name %>      ✅ auto-escaped
// <%- user.name %>      ❌ raw HTML — only for pre-sanitized content
Path traversal prevention

JS
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)
})
Always resolve and scope file paths — `../` sequences escape intended directories
A filename like `../../etc/passwd` or `..%2F..%2Fetc%2Fpasswd` (URL-encoded) traverses parent directories and can read arbitrary files on the server. Always call `path.resolve()` to normalise the path, then check that the result still starts with the intended base directory. The `path.sep` suffix prevents a base of `/app/uploads` matching `/app/uploads-extra/...`. Never concatenate user-supplied strings directly into file paths.
Prototype pollution

JS
// 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
    // ...
  }
}
Never merge user-supplied objects recursively without blocking `__proto__` keys
Prototype pollution lets an attacker mutate `Object.prototype` — adding or overwriting properties that appear on *every* object in the process. Common attack vector: a recursive merge or clone of JSON input that walks into the `__proto__` key. Defences: block the dangerous keys in any custom merge function, use `Object.create(null)` for dictionaries keyed by user input, validate JSON structure with a schema before processing, and keep dependencies updated (`npm audit` catches many known prototype-pollution CVEs).
Mass assignment prevention

JS
// ❌ 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.

Next
The specific injection threat for databases: [Preventing SQL/NoSQL Injection](/nodejs/sql-injection).