Input Validation and Sanitization: A Complete Security Guide

Input Validation and Sanitization: A Complete Security Guide

Whitespots Team ·
input-validation
web
best-practices

Introduction

Input validation is the first line of defense against numerous security vulnerabilities including SQL injection, XSS, command injection, and path traversal. Despite its importance, input validation is often implemented incorrectly or inconsistently. This article provides a comprehensive guide to proper input validation and sanitization.

The Golden Rule: Never Trust User Input

All data coming from users, APIs, files, databases, or any external source should be considered untrusted and potentially malicious. This includes:

  • Form inputs
  • URL parameters
  • Request headers
  • Cookies
  • File uploads
  • API payloads
  • Database values (if they can be modified by users)

Input Validation vs. Sanitization

Validation: Checking if input meets expected criteria (type, format, length, range) Sanitization: Modifying input to remove or encode dangerous content

Both are necessary for comprehensive security.

Common Input Validation Mistakes

1. Client-Side Only Validation

javascript
// VULNERABLE: Client-side validation only // HTML form <form onsubmit="return validateForm()"> <input type="email" id="email" required> <button type="submit">Submit</button> </form> <script> function validateForm() { const email = document.getElementById('email').value; if (!email.includes('@')) { alert('Invalid email'); return false; } return true; } // Attacker can bypass this by disabling JavaScript or sending direct API requests </script> // SECURE: Always validate on the server const express = require('express'); const { body, validationResult } = require('express-validator'); app.post('/register', // Server-side validation body('email').isEmail().normalizeEmail(), body('username').trim().isLength({ min: 3, max: 30 }), (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } // Process valid input } );

2. Blacklist Filtering

javascript
// VULNERABLE: Blacklist approach (trying to block bad inputs) function sanitizeInput(input) { // Blacklist approach - easy to bypass const blocked = ['<script>', 'javascript:', 'onerror=', 'onclick=']; let sanitized = input; blocked.forEach(pattern => { sanitized = sanitized.replace(new RegExp(pattern, 'gi'), ''); }); return sanitized; } // Bypass examples: // <scr<script>ipt>alert('xss')</script> // <img src=x onerror=alert(1)> (onerror without space) // JAVA&#x09;SCRIPT:alert(1) // SECURE: Whitelist approach (only allow known good inputs) function validateUsername(username) { // Whitelist: only allow alphanumeric and underscore const pattern = /^[a-zA-Z0-9_]{3,30}$/; return pattern.test(username); }

Comprehensive Validation Examples

Email Validation

javascript
const validator = require('validator'); function validateEmail(email) { // Basic format check if (!validator.isEmail(email)) { throw new Error('Invalid email format'); } // Additional checks const [localPart, domain] = email.split('@'); // Length limits if (localPart.length > 64 || domain.length > 255) { throw new Error('Email too long'); } // Normalize const normalized = validator.normalizeEmail(email, { all_lowercase: true, gmail_remove_dots: true, gmail_remove_subaddress: true }); return normalized; } // Usage try { const email = validateEmail(req.body.email); // Use validated email } catch (error) { res.status(400).json({ error: error.message }); }

Phone Number Validation

javascript
const { parsePhoneNumber } = require('libphonenumber-js'); function validatePhoneNumber(phoneNumber, country = 'US') { try { const parsed = parsePhoneNumber(phoneNumber, country); if (!parsed.isValid()) { throw new Error('Invalid phone number'); } return { formatted: parsed.formatInternational(), e164: parsed.format('E.164'), country: parsed.country, type: parsed.getType() }; } catch (error) { throw new Error('Invalid phone number format'); } } // Usage try { const phone = validatePhoneNumber('+1-555-123-4567'); console.log(phone.e164); // +15551234567 } catch (error) { console.error(error.message); }

URL Validation

javascript
function validateURL(url, options = {}) { const { allowedProtocols = ['http:', 'https:'], allowedDomains = null, // null = allow all requireTLS = true } = options; try { const parsed = new URL(url); // Check protocol if (!allowedProtocols.includes(parsed.protocol)) { throw new Error(`Protocol ${parsed.protocol} not allowed`); } // Require HTTPS if (requireTLS && parsed.protocol !== 'https:') { throw new Error('HTTPS required'); } // Check domain whitelist if (allowedDomains && !allowedDomains.includes(parsed.hostname)) { throw new Error('Domain not allowed'); } // Prevent localhost/private IPs in production if (process.env.NODE_ENV === 'production') { const hostname = parsed.hostname.toLowerCase(); const privatePatterns = [ /^localhost$/, /^127\./, /^192\.168\./, /^10\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^::1$/, /^fc00:/, /^fe80:/ ]; if (privatePatterns.some(pattern => pattern.test(hostname))) { throw new Error('Private URLs not allowed'); } } return parsed.toString(); } catch (error) { throw new Error('Invalid URL: ' + error.message); } } // Usage try { const url = validateURL(req.body.callback_url, { allowedDomains: ['example.com', 'api.example.com'], requireTLS: true }); } catch (error) { res.status(400).json({ error: error.message }); }

File Upload Validation

javascript
const multer = require('multer'); const path = require('path'); const crypto = require('crypto'); // Whitelist of allowed MIME types const ALLOWED_TYPES = { 'image/jpeg': ['.jpg', '.jpeg'], 'image/png': ['.png'], 'image/gif': ['.gif'], 'application/pdf': ['.pdf'] }; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, '/tmp/uploads'); }, filename: (req, file, cb) => { // Generate safe filename const uniqueName = crypto.randomBytes(16).toString('hex'); const ext = ALLOWED_TYPES[file.mimetype]?.[0] || ''; cb(null, uniqueName + ext); } }); const upload = multer({ storage: storage, limits: { fileSize: MAX_FILE_SIZE, files: 1 }, fileFilter: (req, file, cb) => { // Check MIME type if (!ALLOWED_TYPES[file.mimetype]) { return cb(new Error('Invalid file type'), false); } // Check file extension const ext = path.extname(file.originalname).toLowerCase(); const allowedExts = ALLOWED_TYPES[file.mimetype]; if (!allowedExts.includes(ext)) { return cb(new Error('Invalid file extension'), false); } cb(null, true); } }); app.post('/upload', upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } // Additional validation: Check file signature (magic bytes) const fileSignature = await getFileSignature(req.file.path); const validSignatures = { 'image/jpeg': ['ffd8ff'], 'image/png': ['89504e47'], 'image/gif': ['474946383761', '474946383961'], 'application/pdf': ['25504446'] }; const expectedSignatures = validSignatures[req.file.mimetype]; const isValid = expectedSignatures.some(sig => fileSignature.toLowerCase().startsWith(sig) ); if (!isValid) { // Delete invalid file fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'File content does not match type' }); } res.json({ success: true, filename: req.file.filename }); }); async function getFileSignature(filePath) { const buffer = Buffer.alloc(8); const fd = await fs.promises.open(filePath, 'r'); await fd.read(buffer, 0, 8, 0); await fd.close(); return buffer.toString('hex'); }

Numeric Input Validation

javascript
function validateInteger(value, options = {}) { const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER, allowNegative = true } = options; // Parse as integer const num = parseInt(value, 10); // Check if valid number if (isNaN(num) || !Number.isInteger(num)) { throw new Error('Must be a valid integer'); } // Check range if (num < min || num > max) { throw new Error(`Must be between ${min} and ${max}`); } // Check sign if (!allowNegative && num < 0) { throw new Error('Negative numbers not allowed'); } return num; } function validateDecimal(value, options = {}) { const { min = -Infinity, max = Infinity, decimalPlaces = null } = options; const num = parseFloat(value); if (isNaN(num) || !isFinite(num)) { throw new Error('Must be a valid number'); } if (num < min || num > max) { throw new Error(`Must be between ${min} and ${max}`); } // Validate decimal places if (decimalPlaces !== null) { const parts = value.toString().split('.'); if (parts.length > 1 && parts[1].length > decimalPlaces) { throw new Error(`Maximum ${decimalPlaces} decimal places allowed`); } } return num; } // Usage app.post('/product', (req, res) => { try { const quantity = validateInteger(req.body.quantity, { min: 1, max: 1000, allowNegative: false }); const price = validateDecimal(req.body.price, { min: 0.01, max: 999999.99, decimalPlaces: 2 }); // Process order } catch (error) { res.status(400).json({ error: error.message }); } });

Sanitization Techniques

HTML Sanitization

javascript
const DOMPurify = require('isomorphic-dompurify'); function sanitizeHTML(dirty, options = {}) { const config = { ALLOWED_TAGS: options.allowedTags || ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], ALLOWED_ATTR: options.allowedAttr || ['href'], ALLOW_DATA_ATTR: false, ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, ...options.config }; return DOMPurify.sanitize(dirty, config); } // Usage const userBio = sanitizeHTML(req.body.bio, { allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li'], allowedAttr: [] });

SQL Parameter Sanitization

javascript
const mysql = require('mysql2/promise'); // WRONG: String concatenation async function getUser(username) { const query = `SELECT * FROM users WHERE username = '${username}'`; // Vulnerable to SQL injection! return await db.query(query); } // CORRECT: Parameterized queries async function getUserSecure(username) { const query = 'SELECT * FROM users WHERE username = ?'; const [rows] = await db.query(query, [username]); return rows; } // For dynamic queries, use query builder const knex = require('knex')({ client: 'mysql2', connection: { /* config */ } }); async function searchUsers(filters) { let query = knex('users').select('id', 'username', 'email'); // Safely add dynamic filters if (filters.role) { query = query.where('role', filters.role); } if (filters.status) { query = query.where('status', filters.status); } if (filters.search) { query = query.where('username', 'like', `%${filters.search}%`); } return await query; }

Complete Validation Middleware

javascript
const { body, query, param, validationResult } = require('express-validator'); // Reusable validation chains const validators = { email: body('email') .trim() .isEmail() .normalizeEmail() .withMessage('Invalid email address'), username: body('username') .trim() .isLength({ min: 3, max: 30 }) .matches(/^[a-zA-Z0-9_]+$/) .withMessage('Username must be 3-30 alphanumeric characters'), password: body('password') .isLength({ min: 12 }) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) .withMessage('Password must be 12+ characters with uppercase, lowercase, number, and symbol'), id: param('id') .isInt({ min: 1 }) .toInt() .withMessage('Invalid ID'), pagination: [ query('page') .optional() .isInt({ min: 1 }) .toInt() .withMessage('Page must be positive integer'), query('limit') .optional() .isInt({ min: 1, max: 100 }) .toInt() .withMessage('Limit must be between 1 and 100') ] }; // Error handling middleware function handleValidationErrors(req, res, next) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Validation failed', details: errors.array().map(err => ({ field: err.path, message: err.msg })) }); } next(); } // Usage in routes app.post('/register', validators.email, validators.username, validators.password, handleValidationErrors, registerUser ); app.get('/users/:id', validators.id, handleValidationErrors, getUser ); app.get('/users', validators.pagination, handleValidationErrors, listUsers );

Input Validation Best Practices

  1. Validate on Server-Side - Never rely solely on client-side validation
  2. Use Whitelist Approach - Define what’s allowed, not what’s blocked
  3. Validate Type, Format, Length, Range - Apply multiple validation layers
  4. Sanitize for Context - Different sanitization for HTML, SQL, URL, etc.
  5. Fail Securely - Reject invalid input, don’t try to “fix” it
  6. Provide Clear Error Messages - Help users fix issues without revealing system details
  7. Validate Early - Check input as soon as it enters the application
  8. Use Established Libraries - Don’t roll your own validation
  9. Log Validation Failures - Monitor for attack patterns
  10. Regular Expression Safety - Beware of ReDoS attacks with complex regex

Conclusion

Input validation and sanitization are critical security controls that prevent numerous vulnerabilities. By validating all input on the server-side, using whitelist approaches, and applying context-appropriate sanitization, you can significantly reduce your application’s attack surface.

Remember: never trust user input, validate everything, and use established validation libraries. For comprehensive application security assessments including input validation reviews, contact the Whitespots team for professional security consulting.

Cookie Consent

Our website uses cookies to ensure the best user experience. Cookies help us to:

  • Authorize you

By clicking "Accept All Cookies", you consent to our use of cookies. You can also manage your preferences at any time by visiting our Cookie Settings page.

Learn More Manage Preferences