Security — Input Validation & Sanitization
Input validation is the first line of defense against injection attacks, data corruption, and business logic abuse. Every piece of data that enters your application from an external source — request bodies, query parameters, headers, file uploads, WebSocket messages — must be validated before use.
Validation answers two questions: "Is this the right type of data?" (type checking) and "Is this an acceptable value?" (business rules). Client-side validation improves UX but provides zero security — always validate on the server.
Allowlists (whitelists) define what is permitted and reject everything else. Blocklists (blacklists) define what is forbidden and allow everything else. Allowlists are always safer because they handle unknown attack vectors automatically.
| 1 | // Allowlist approach — define what's allowed |
| 2 | // GOOD: only accepts known-good values |
| 3 | function validateUserRole(role: string): boolean { |
| 4 | const allowed = ['admin', 'editor', 'viewer', 'moderator']; |
| 5 | return allowed.includes(role); |
| 6 | } |
| 7 | |
| 8 | // GOOD: regex allowlist for usernames |
| 9 | function isValidUsername(username: string): boolean { |
| 10 | // Only alphanumeric, hyphens, underscores, 3-30 chars |
| 11 | return /^[a-zA-Z0-9_-]{3,30}$/.test(username); |
| 12 | } |
| 13 | |
| 14 | // GOOD: type-safe enum validation |
| 15 | type Status = 'active' | 'inactive' | 'pending'; |
| 16 | function validateStatus(status: string): status is Status { |
| 17 | return ['active', 'inactive', 'pending'].includes(status); |
| 18 | } |
| 19 | |
| 20 | // ──────────────────────────────────────────── |
| 21 | |
| 22 | // Blocklist approach — define what to reject |
| 23 | // BAD: tries to catch known-bad patterns (misses new ones) |
| 24 | function isSafeInput(input: string): boolean { |
| 25 | const blocked = ['<script>', 'javascript:', 'onerror=', 'onload=']; |
| 26 | return !blocked.some(pattern => |
| 27 | input.toLowerCase().includes(pattern) |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | // Why blocklists fail: |
| 32 | // 1. New attack vectors appear constantly |
| 33 | // 2. Case variations: <Script>, <SCRIPT>, <script> |
| 34 | // 3. Encoding tricks: <script>, %3Cscript%3E |
| 35 | // 4. Nested bypass: <scr<script>ipt> |
| 36 | // 5. null bytes: <scr\0ipt> |
| 37 | // An allowlist for "alphanumeric only" blocks ALL of these automatically |
| 38 | |
| 39 | // Allowlist for file uploads |
| 40 | const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp']; |
| 41 | const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp']; |
| 42 | const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB |
| 43 | |
| 44 | function validateFileUpload(file: File): { valid: boolean; error?: string } { |
| 45 | if (!ALLOWED_MIMES.includes(file.type)) { |
| 46 | return { valid: false, error: 'Invalid file type' }; |
| 47 | } |
| 48 | if (file.size > MAX_FILE_SIZE) { |
| 49 | return { valid: false, error: 'File too large' }; |
| 50 | } |
| 51 | return { valid: true }; |
| 52 | } |
danger
Zod is a TypeScript-first schema validation library that provides type inference, detailed error messages, and composable schemas. It is the most popular choice for validating API inputs in TypeScript projects.
| 1 | // Zod schema validation — comprehensive example |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | // User registration schema |
| 5 | const RegisterSchema = z.object({ |
| 6 | name: z |
| 7 | .string() |
| 8 | .min(1, 'Name is required') |
| 9 | .max(100, 'Name too long') |
| 10 | .regex(/^[a-zA-Z\s'-]+$/, 'Name contains invalid characters'), |
| 11 | |
| 12 | email: z |
| 13 | .string() |
| 14 | .email('Invalid email format') |
| 15 | .max(255, 'Email too long') |
| 16 | .toLowerCase(), |
| 17 | |
| 18 | password: z |
| 19 | .string() |
| 20 | .min(12, 'Password must be at least 12 characters') |
| 21 | .max(128, 'Password too long') |
| 22 | .regex(/[A-Z]/, 'Must contain uppercase letter') |
| 23 | .regex(/[a-z]/, 'Must contain lowercase letter') |
| 24 | .regex(/[0-9]/, 'Must contain number') |
| 25 | .regex(/[^A-Za-z0-9]/, 'Must contain special character'), |
| 26 | |
| 27 | age: z |
| 28 | .number() |
| 29 | .int('Age must be a whole number') |
| 30 | .min(13, 'Must be at least 13') |
| 31 | .max(150, 'Invalid age') |
| 32 | .optional(), |
| 33 | |
| 34 | role: z.enum(['user', 'editor']).default('user'), |
| 35 | |
| 36 | website: z |
| 37 | .string() |
| 38 | .url('Invalid URL') |
| 39 | .max(200) |
| 40 | .regex(/^https:\/\//, 'Must use HTTPS') |
| 41 | .optional() |
| 42 | .or(z.literal('')), |
| 43 | }); |
| 44 | |
| 45 | // Express route with Zod validation |
| 46 | app.post('/api/register', async (req, res) => { |
| 47 | const result = RegisterSchema.safeParse(req.body); |
| 48 | |
| 49 | if (!result.success) { |
| 50 | return res.status(400).json({ |
| 51 | error: 'Validation failed', |
| 52 | details: result.error.issues.map(issue => ({ |
| 53 | field: issue.path.join('.'), |
| 54 | message: issue.message, |
| 55 | })), |
| 56 | }); |
| 57 | } |
| 58 | |
| 59 | // result.data is fully typed and validated |
| 60 | const { name, email, password, age, role, website } = result.data; |
| 61 | |
| 62 | // Now safe to use — all values are validated |
| 63 | await createUser({ name, email, password: await hash(password), age, role, website }); |
| 64 | res.status(201).json({ message: 'Account created' }); |
| 65 | }); |
| 66 | |
| 67 | // Nested object validation |
| 68 | const UpdateProfileSchema = z.object({ |
| 69 | name: z.string().min(1).max(100), |
| 70 | address: z.object({ |
| 71 | street: z.string().min(1).max(200), |
| 72 | city: z.string().min(1).max(100), |
| 73 | zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'), |
| 74 | country: z.enum(['US', 'CA', 'UK', 'DE', 'FR']), |
| 75 | }).optional(), |
| 76 | preferences: z.object({ |
| 77 | newsletter: z.boolean().default(false), |
| 78 | theme: z.enum(['light', 'dark', 'system']).default('system'), |
| 79 | }).optional(), |
| 80 | }); |
| 81 | |
| 82 | // Array validation |
| 83 | const BatchUpdateSchema = z.object({ |
| 84 | items: z.array(z.object({ |
| 85 | id: z.string().uuid(), |
| 86 | action: z.enum(['update', 'delete']), |
| 87 | data: z.record(z.string(), z.unknown()).optional(), |
| 88 | })).min(1).max(100), // Limit array size |
| 89 | }); |
info
Joi is a widely-used validation library with a chainable API. It is common in Express projects and provides powerful validation, sanitization, and error reporting.
| 1 | // Joi schema validation — Express middleware |
| 2 | import Joi from 'joi'; |
| 3 | import express from 'express'; |
| 4 | |
| 5 | // Validation middleware factory |
| 6 | function validate(schema: Joi.ObjectSchema) { |
| 7 | return (req, res, next) => { |
| 8 | const { error, value } = schema.validate(req.body, { |
| 9 | abortEarly: false, // Return all errors, not just the first |
| 10 | stripUnknown: true, // Remove fields not in the schema |
| 11 | }); |
| 12 | |
| 13 | if (error) { |
| 14 | return res.status(400).json({ |
| 15 | error: 'Validation failed', |
| 16 | details: error.details.map(d => ({ |
| 17 | field: d.path.join('.'), |
| 18 | message: d.message, |
| 19 | })), |
| 20 | }); |
| 21 | } |
| 22 | |
| 23 | req.body = value; // Use sanitized/validated values |
| 24 | next(); |
| 25 | }; |
| 26 | } |
| 27 | |
| 28 | // User update schema |
| 29 | const updateUserSchema = Joi.object({ |
| 30 | name: Joi.string() |
| 31 | .min(1) |
| 32 | .max(100) |
| 33 | .pattern(/^[a-zA-Z\s'-]+$/) |
| 34 | .messages({ |
| 35 | 'string.pattern.base': 'Name contains invalid characters', |
| 36 | }), |
| 37 | |
| 38 | email: Joi.string() |
| 39 | .email({ tlds: { allow: true } }) |
| 40 | .max(255) |
| 41 | .lowercase(), |
| 42 | |
| 43 | bio: Joi.string() |
| 44 | .max(5000) |
| 45 | .allow('') |
| 46 | .optional(), |
| 47 | |
| 48 | age: Joi.number() |
| 49 | .integer() |
| 50 | .min(13) |
| 51 | .max(150) |
| 52 | .optional(), |
| 53 | |
| 54 | role: Joi.string() |
| 55 | .valid('user', 'editor', 'admin') |
| 56 | .default('user'), |
| 57 | }); |
| 58 | |
| 59 | // Password schema with strong rules |
| 60 | const changePasswordSchema = Joi.object({ |
| 61 | currentPassword: Joi.string().min(1).required(), |
| 62 | newPassword: Joi.string() |
| 63 | .min(12) |
| 64 | .max(128) |
| 65 | .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d])/) |
| 66 | .required() |
| 67 | .invalid(Joi.ref('currentPassword')) |
| 68 | .messages({ |
| 69 | 'any.invalid': 'New password must differ from current', |
| 70 | 'string.pattern.base': 'Password must include uppercase, lowercase, number, and special character', |
| 71 | }), |
| 72 | confirmPassword: Joi.any() |
| 73 | .valid(Joi.ref('newPassword')) |
| 74 | .required() |
| 75 | .messages({ 'any.only': 'Passwords do not match' }), |
| 76 | }); |
| 77 | |
| 78 | // Apply validation middleware |
| 79 | app.put('/api/profile', validate(updateUserSchema), (req, res) => { |
| 80 | // req.body is validated and sanitized |
| 81 | res.json({ success: true }); |
| 82 | }); |
| 83 | |
| 84 | app.post('/api/change-password', validate(changePasswordSchema), (req, res) => { |
| 85 | // Passwords validated, stripUnknown removed confirmPassword |
| 86 | res.json({ success: true }); |
| 87 | }); |
Sanitization cleans input by removing or modifying dangerous characters. Escaping transforms special characters into safe representations. They serve different purposes: sanitization modifies data before storage, escaping protects data during rendering.
| 1 | // HTML escaping — prevent XSS in output |
| 2 | import escapeHtml from 'escape-html'; |
| 3 | |
| 4 | // Characters that are dangerous in HTML |
| 5 | // < → < > → > & → & " → " ' → ' |
| 6 | |
| 7 | const userInput = '<script>alert("XSS")</script>'; |
| 8 | const safe = escapeHtml(userInput); |
| 9 | // Result: <script>alert("XSS")</script> |
| 10 | |
| 11 | // ──────────────────────────────────────────── |
| 12 | |
| 13 | // SQL escaping — NEVER use string interpolation |
| 14 | // BAD — SQL injection |
| 15 | const query = `SELECT * FROM users WHERE name = '${name}'`; |
| 16 | |
| 17 | // GOOD — parameterized queries (the DB driver handles escaping) |
| 18 | await db.query('SELECT * FROM users WHERE name = $1', [name]); |
| 19 | |
| 20 | // ──────────────────────────────────────────── |
| 21 | |
| 22 | // URL encoding |
| 23 | const unsafe = 'https://example.com/search?q=hello world&lang=en'; |
| 24 | const safe = encodeURIComponent(unsafe); |
| 25 | // Result: https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%20world%26lang%3Den |
| 26 | |
| 27 | // ──────────────────────────────────────────── |
| 28 | |
| 29 | // JavaScript string escaping |
| 30 | const userInput = '</script><script>alert(1)</script>'; |
| 31 | const safe = JSON.stringify(userInput); |
| 32 | // Result: "<\/script><script>alert(1)<\/script>" |
| 33 | // JSON.stringify escapes < / properly for script contexts |
| 34 | |
| 35 | // ──────────────────────────────────────────── |
| 36 | |
| 37 | // CSS escaping |
| 38 | function escapeCssValue(value: string): string { |
| 39 | // Only allow alphanumeric, hyphens, and underscores |
| 40 | return value.replace(/[^a-zA-Z0-9_-]/g, ''); |
| 41 | } |
| 42 | |
| 43 | // ──────────────────────────────────────────── |
| 44 | |
| 45 | // Shell command escaping |
| 46 | import { execFile } from 'child_process'; |
| 47 | |
| 48 | // BAD — shell injection |
| 49 | exec(`ping -c 4 ${userInput}`); // userInput: ; rm -rf / |
| 50 | |
| 51 | // GOOD — use execFile with array arguments (no shell interpretation) |
| 52 | execFile('ping', ['-c', '4', userInput], (err, stdout) => { |
| 53 | // userInput is passed as a single argument, not parsed by shell |
| 54 | }); |
| 55 | |
| 56 | // BEST — avoid shell commands entirely, use Node.js APIs |
danger
JavaScript's loose type coercion can be exploited to bypass validation. An attacker can send a string where a number is expected, an array where a string is expected, or an object where a primitive is expected.
| 1 | // Type coercion vulnerabilities |
| 2 | // JavaScript: "1" == 1 is true, [] == false is true |
| 3 | |
| 4 | // BAD — loose equality allows bypass |
| 5 | function isValidAge(value: any): boolean { |
| 6 | if (value == null) return false; // null == undefined passes! |
| 7 | if (value == false) return false; // 0, "", null, undefined all pass! |
| 8 | return value >= 13 && value <= 150; |
| 9 | } |
| 10 | |
| 11 | isValidAge(null); // false (correct) |
| 12 | isValidAge(undefined); // false (correct) |
| 13 | isValidAge(''); // false (correct, but by accident) |
| 14 | isValidAge([]); // false (correct, but by accident) |
| 15 | |
| 16 | // BAD — array bypass |
| 17 | function validateEmail(email: string): boolean { |
| 18 | return typeof email === 'string' && email.includes('@'); |
| 19 | } |
| 20 | // Attacker sends: email[]=admin@example.com |
| 21 | // req.body.email is an array, not a string |
| 22 | // typeof [] === 'object', so it fails... but typeof ['@'] === 'object' |
| 23 | |
| 24 | // GOOD — explicit type checking with Zod |
| 25 | const Schema = z.object({ |
| 26 | email: z.string().email(), // Rejects arrays, objects, numbers |
| 27 | age: z.number().int().min(13).max(150), // Rejects strings |
| 28 | name: z.string().min(1).max(100), |
| 29 | }); |
| 30 | |
| 31 | // GOOD — check constructor |
| 32 | function safeNumber(value: unknown): number | null { |
| 33 | if (typeof value === 'number' && Number.isFinite(value)) { |
| 34 | return value; |
| 35 | } |
| 36 | if (typeof value === 'string') { |
| 37 | const parsed = Number(value); |
| 38 | if (Number.isFinite(parsed)) return parsed; |
| 39 | } |
| 40 | return null; |
| 41 | } |
| 42 | |
| 43 | // GOOD — reject unexpected types early |
| 44 | app.post('/api/data', (req, res) => { |
| 45 | if (typeof req.body.amount !== 'number') { |
| 46 | return res.status(400).json({ error: 'Amount must be a number' }); |
| 47 | } |
| 48 | if (typeof req.body.name !== 'string') { |
| 49 | return res.status(400).json({ error: 'Name must be a string' }); |
| 50 | } |
| 51 | // Now safe to use |
| 52 | }); |
| 53 | |
| 54 | // Array.prototype pollution protection |
| 55 | // Express parses: ?ids[]=1&ids[]=2 as { ids: ['1', '2'] } |
| 56 | // Attack: ?__proto__[isAdmin]=true → pollutes Object.prototype |
| 57 | // Defense: use Object.create(null) or check for prototype keys |
Regular expressions are powerful for validation but can introduce vulnerabilities. ReDoS (Regular Expression Denial of Service) occurs when a crafted input causes catastrophic backtracking, freezing the server.
| 1 | // Safe regex patterns for common validations |
| 2 | |
| 3 | // Email — simple but sufficient for most cases |
| 4 | const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; |
| 5 | |
| 6 | // URL — HTTPS only |
| 7 | const httpsUrlRegex = /^https:\/\/[^\s/$.?#].[^\s]*$/i; |
| 8 | |
| 9 | // Phone number (US format) |
| 10 | const phoneRegex = /^\+?1?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/; |
| 11 | |
| 12 | // UUID |
| 13 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; |
| 14 | |
| 15 | // Date (YYYY-MM-DD) |
| 16 | const dateRegex = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/; |
| 17 | |
| 18 | // ──────────────────────────────────────────── |
| 19 | |
| 20 | // ReDoS vulnerability — catastrophic backtracking |
| 21 | // BAD — nested quantifiers cause exponential time |
| 22 | const badEmailRegex = /^([a-zA-Z0-9]+)+@[a-zA-Z0-9]+\.[a-zA-Z]+$/; |
| 23 | // Input: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa! |
| 24 | // This causes 2^n backtracking steps, freezing the server |
| 25 | |
| 26 | // GOOD — use atomic groups or non-backtracking regex |
| 27 | // Node.js 16+: use the 'd' flag and limit repetition |
| 28 | const safeRegex = /^[a-zA-Z0-9]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/; |
| 29 | |
| 30 | // Test your regex for ReDoS vulnerability |
| 31 | import { execSync } from 'child_process'; |
| 32 | |
| 33 | function testReDoS(pattern: string, testInput: string, timeoutMs = 1000): boolean { |
| 34 | try { |
| 35 | execSync( |
| 36 | `node -e "new RegExp('${pattern}').test('${testInput}')"`, |
| 37 | { timeout: timeoutMs } |
| 38 | ); |
| 39 | return false; // No ReDoS |
| 40 | } catch { |
| 41 | return true; // ReDoS detected (timeout) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // Use libraries with built-in ReDoS protection |
| 46 | // email-regex-safe, re2 (Google's RE2), safe-regex |
| 47 | |
| 48 | // Length limits — always enforce BEFORE regex |
| 49 | // BAD: regex on unbounded input |
| 50 | const result = input.match(/^[a-zA-Z0-9]+$/); // Attacker sends 1GB of data |
| 51 | |
| 52 | // GOOD: length check first |
| 53 | if (input.length > 100) return false; |
| 54 | const result = input.match(/^[a-zA-Z0-9]+$/); |
warning
Validate at every trust boundary — where data crosses from untrusted to trusted space. This includes API endpoints, database queries, file system operations, and third-party API responses.
| 1 | // Validation at every boundary |
| 2 | |
| 3 | // 1. API input boundary (most critical) |
| 4 | app.post('/api/orders', validate(orderSchema), async (req, res) => { |
| 5 | const { items, shippingAddress } = req.body; |
| 6 | |
| 7 | // 2. Database query boundary — validate IDs |
| 8 | for (const item of items) { |
| 9 | if (!isValidUUID(item.productId)) { |
| 10 | return res.status(400).json({ error: 'Invalid product ID' }); |
| 11 | } |
| 12 | const product = await db.products.findById(item.productId); |
| 13 | if (!product) { |
| 14 | return res.status(404).json({ error: 'Product not found' }); |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | // 3. Third-party API boundary — validate responses |
| 19 | const paymentResult = await stripe.charges.create({ |
| 20 | amount: calculateTotal(items), |
| 21 | currency: 'usd', |
| 22 | }); |
| 23 | |
| 24 | // Validate the response from Stripe (never trust external APIs) |
| 25 | if (typeof paymentResult.id !== 'string' || !paymentResult.paid) { |
| 26 | throw new Error('Payment failed'); |
| 27 | } |
| 28 | |
| 29 | // 4. File system boundary — validate file paths |
| 30 | const filePath = path.join(UPLOAD_DIR, userProvidedFilename); |
| 31 | const resolved = path.resolve(filePath); |
| 32 | if (!resolved.startsWith(UPLOAD_DIR)) { |
| 33 | return res.status(400).json({ error: 'Invalid file path' }); // Path traversal |
| 34 | } |
| 35 | |
| 36 | // 5. WebSocket message boundary |
| 37 | ws.on('message', (data) => { |
| 38 | const result = WebSocketMessageSchema.safeParse( |
| 39 | JSON.parse(data.toString()) |
| 40 | ); |
| 41 | if (!result.success) { |
| 42 | ws.close(1008, 'Invalid message'); |
| 43 | return; |
| 44 | } |
| 45 | handleMessage(result.data); |
| 46 | }); |
| 47 | }); |
| 48 | |
| 49 | // 6. Query parameter boundary |
| 50 | app.get('/api/search', (req, res) => { |
| 51 | const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); |
| 52 | const limit = Math.min(100, Math.max(1, Math.floor(Number(req.query.limit) || 20))); |
| 53 | const q = String(req.query.q || '').slice(0, 200); |
| 54 | // Now safe to use page, limit, q |
| 55 | }); |
| 56 | |
| 57 | // 7. Environment variable boundary |
| 58 | const port = parseInt(process.env.PORT || '3000', 10); |
| 59 | if (isNaN(port) || port < 1 || port > 65535) { |
| 60 | throw new Error('Invalid PORT environment variable'); |
| 61 | } |
File uploads are a high-risk input vector. Attackers can upload executable scripts, oversized files to cause DoS, or files with malicious content disguised by extension. Validate on multiple levels.
| 1 | // Secure file upload validation |
| 2 | import multer from 'multer'; |
| 3 | import path from 'path'; |
| 4 | import crypto from 'crypto'; |
| 5 | |
| 6 | const ALLOWED_TYPES = { |
| 7 | 'image/jpeg': ['.jpg', '.jpeg'], |
| 8 | 'image/png': ['.png'], |
| 9 | 'image/webp': ['.webp'], |
| 10 | 'application/pdf': ['.pdf'], |
| 11 | }; |
| 12 | |
| 13 | const MAX_SIZE = 5 * 1024 * 1024; // 5MB |
| 14 | |
| 15 | const upload = multer({ |
| 16 | storage: multer.diskStorage({ |
| 17 | destination: '/tmp/uploads', |
| 18 | filename: (req, file, cb) => { |
| 19 | // Random filename — never use the original name |
| 20 | const ext = path.extname(file.originalname).toLowerCase(); |
| 21 | const randomName = crypto.randomBytes(32).toString('hex'); |
| 22 | cb(null, `${randomName}${ext}`); |
| 23 | }, |
| 24 | }), |
| 25 | limits: { |
| 26 | fileSize: MAX_SIZE, |
| 27 | files: 5, |
| 28 | }, |
| 29 | fileFilter: (req, file, cb) => { |
| 30 | // 1. Check MIME type |
| 31 | const allowedExtensions = ALLOWED_TYPES[file.mimetype]; |
| 32 | if (!allowedExtensions) { |
| 33 | return cb(new Error('Invalid file type')); |
| 34 | } |
| 35 | |
| 36 | // 2. Check extension |
| 37 | const ext = path.extname(file.originalname).toLowerCase(); |
| 38 | if (!allowedExtensions.includes(ext)) { |
| 39 | return cb(new Error('Invalid file extension')); |
| 40 | } |
| 41 | |
| 42 | // 3. Check for double extensions (malware trick) |
| 43 | const basename = path.basename(file.originalname, ext); |
| 44 | if (basename.includes('.')) { |
| 45 | return cb(new Error('Invalid filename')); |
| 46 | } |
| 47 | |
| 48 | cb(null, true); |
| 49 | }, |
| 50 | }); |
| 51 | |
| 52 | // Apply to route |
| 53 | app.post('/api/upload', upload.single('file'), (req, res) => { |
| 54 | const file = req.file; |
| 55 | |
| 56 | // 4. Verify file content matches declared type |
| 57 | // (MIME type can be spoofed — check magic bytes) |
| 58 | // Use file-type or FileType library |
| 59 | |
| 60 | // 5. Scan for malware (integrate with ClamAV or similar) |
| 61 | // await scanFile(file.path); |
| 62 | |
| 63 | res.json({ |
| 64 | filename: file.filename, |
| 65 | size: file.size, |
| 66 | type: file.mimetype, |
| 67 | }); |
| 68 | }); |
danger
- Always validate on the server — client-side validation is UX, not security.
- Use allowlists over blocklists — define what is accepted, reject everything else.
- Validate type, length, format, and range — not just presence.
- Enforce maximum length limits on all string inputs before regex or DB queries.
- Use schema libraries (Zod, Joi) instead of manual validation — they handle edge cases.
- Strip unknown fields with stripUnknown (Joi) or .pick() (Zod) to prevent mass assignment.
- Validate at every trust boundary: API, DB, file system, third-party APIs, WebSocket.
- Sanitize for context — HTML escaping does not protect SQL, and vice versa.
- Test for ReDoS vulnerabilities and enforce input length before regex.
- Log validation failures for security monitoring — they indicate attack attempts.
best practice