Loading learning content...
Every security vulnerability, at its core, involves untrusted input being processed in an unintended way. SQL injection, cross-site scripting (XSS), command injection, path traversal—all exploit the gap between what a system expects and what an attacker provides.
Input sanitization is the practice of validating, transforming, and encoding external data before it reaches your application's core logic. It's the first—and often last—line of defense against injection attacks that consistently rank among the most dangerous vulnerabilities.
This page provides comprehensive coverage of input sanitization at the class and object level. We'll examine validation strategies, encoding and escaping techniques, context-specific sanitization, and how to build robust input handling into your object-oriented designs.
By the end of this page, you will understand the difference between validation and sanitization, how to implement multi-layer input validation, context-specific encoding strategies, prevention of major injection attacks (SQL, XSS, command), and how to design input-handling services that are secure by default.
Input handling involves multiple distinct operations, each serving a different purpose in the security pipeline:
Validation vs Sanitization vs Encoding:
| Operation | Purpose | Action | When to Use |
|---|---|---|---|
| Validation | Verify input meets requirements | Accept or reject | Always, on all input |
| Canonicalization | Convert to standard form | Transform to canonical form | Before validation (URLs, paths, Unicode) |
| Sanitization | Remove or neutralize dangerous content | Clean/modify input | When rejection isn't feasible |
| Encoding/Escaping | Prepare for safe output in context | Encode special characters | Before output (HTML, SQL, URLs) |
The Golden Rule: Validate Input, Encode Output
This principle captures the essence of secure input handling:
Trust Boundaries:
Input must be validated at trust boundaries—where data crosses from untrusted to trusted context:
Client-side validation is for user experience only. It can be bypassed trivially. Always re-validate on the server. Attackers don't use your UI—they send HTTP requests directly.
Effective input validation employs multiple strategies depending on the type and nature of the data being validated.
Allowlist vs Denylist:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
// Base validator interfaceinterface Validator<T> { validate(input: unknown): ValidationResult<T>;} // Validated result type - forces handling of validation stateclass ValidationResult<T> { private constructor( private readonly _isValid: boolean, private readonly _value: T | null, private readonly _errors: ValidationError[] ) {} static success<T>(value: T): ValidationResult<T> { return new ValidationResult(true, value, []); } static failure<T>(errors: ValidationError[]): ValidationResult<T> { return new ValidationResult(false, null, errors); } get isValid(): boolean { return this._isValid; } get errors(): ValidationError[] { return [...this._errors]; } // Force caller to handle validation getOrThrow(): T { if (!this._isValid || this._value === null) { throw new ValidationException(this._errors); } return this._value; } // Functional composition map<U>(fn: (value: T) => U): ValidationResult<U> { if (!this._isValid || this._value === null) { return ValidationResult.failure(this._errors); } return ValidationResult.success(fn(this._value)); } flatMap<U>(fn: (value: T) => ValidationResult<U>): ValidationResult<U> { if (!this._isValid || this._value === null) { return ValidationResult.failure(this._errors); } return fn(this._value); }} // String validatorsclass StringValidator implements Validator<string> { private readonly minLength: number; private readonly maxLength: number; private readonly pattern: RegExp | null; private readonly allowedChars: Set<string> | null; private readonly trimmed: boolean; constructor(options: StringValidatorOptions = {}) { this.minLength = options.minLength ?? 0; this.maxLength = options.maxLength ?? 10000; this.pattern = options.pattern ?? null; this.allowedChars = options.allowedChars ? new Set(options.allowedChars) : null; this.trimmed = options.trimmed ?? true; } validate(input: unknown): ValidationResult<string> { const errors: ValidationError[] = []; // Type check if (typeof input !== 'string') { return ValidationResult.failure([ new ValidationError('type', 'Input must be a string') ]); } let value = input; // Trim if configured if (this.trimmed) { value = value.trim(); } // Length checks if (value.length < this.minLength) { errors.push(new ValidationError( 'minLength', `Minimum length is ${this.minLength} characters` )); } if (value.length > this.maxLength) { errors.push(new ValidationError( 'maxLength', `Maximum length is ${this.maxLength} characters` )); } // Pattern check (allowlist approach) if (this.pattern && !this.pattern.test(value)) { errors.push(new ValidationError( 'pattern', 'Input contains invalid characters' )); } // Character allowlist check if (this.allowedChars) { for (const char of value) { if (!this.allowedChars.has(char)) { errors.push(new ValidationError( 'allowedChars', `Character '${char}' is not allowed` )); break; } } } return errors.length > 0 ? ValidationResult.failure(errors) : ValidationResult.success(value); }} // Common validators as reusable instancesclass Validators { static readonly username = new StringValidator({ minLength: 3, maxLength: 30, pattern: /^[a-zA-Z][a-zA-Z0-9_-]*$/, // Start with letter, then alphanumeric/-/_ }); static readonly email = { validate(input: unknown): ValidationResult<string> { if (typeof input !== 'string') { return ValidationResult.failure([ new ValidationError('type', 'Email must be a string') ]); } const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const value = input.toLowerCase().trim(); if (value.length > 254) { return ValidationResult.failure([ new ValidationError('maxLength', 'Email too long') ]); } if (!emailRegex.test(value)) { return ValidationResult.failure([ new ValidationError('format', 'Invalid email format') ]); } return ValidationResult.success(value); } }; static readonly slug = new StringValidator({ minLength: 1, maxLength: 100, pattern: /^[a-z0-9]+(?:-[a-z0-9]+)*$/, }); static readonly uuid = { validate(input: unknown): ValidationResult<string> { if (typeof input !== 'string') { return ValidationResult.failure([ new ValidationError('type', 'UUID must be a string') ]); } const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; if (!uuidRegex.test(input)) { return ValidationResult.failure([ new ValidationError('format', 'Invalid UUID format') ]); } return ValidationResult.success(input.toLowerCase()); } }; static integer(options: { min?: number; max?: number } = {}) { return { validate(input: unknown): ValidationResult<number> { if (typeof input !== 'number' || !Number.isInteger(input)) { return ValidationResult.failure([ new ValidationError('type', 'Must be an integer') ]); } if (options.min !== undefined && input < options.min) { return ValidationResult.failure([ new ValidationError('min', `Minimum value is ${options.min}`) ]); } if (options.max !== undefined && input > options.max) { return ValidationResult.failure([ new ValidationError('max', `Maximum value is ${options.max}`) ]); } return ValidationResult.success(input); } }; }}Consider creating validated types like 'ValidatedEmail' or 'SafeHtml' that can only be constructed through validation. This uses the type system to ensure validation cannot be bypassed.
SQL injection remains one of the most dangerous and common vulnerabilities. It occurs when untrusted input is concatenated into SQL queries, allowing attackers to execute arbitrary SQL.
The Vulnerability:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// VULNERABLE: String concatenationclass VulnerableUserRepository { async findByUsername(username: string): Promise<User | null> { // DANGEROUS! Never do this! const query = `SELECT * FROM users WHERE username = '${username}'`; return this.db.query(query); }} // Attack: username = "admin' OR '1'='1"// Results in: SELECT * FROM users WHERE username = 'admin' OR '1'='1'// This returns ALL users! // Even worse: username = "'; DROP TABLE users; --"// Results in: SELECT * FROM users WHERE username = ''; DROP TABLE users; --'// Database destroyed! // SECURE: Parameterized queriesclass SecureUserRepository { async findByUsername(username: string): Promise<User | null> { // Parameters are handled safely by the database driver const query = 'SELECT * FROM users WHERE username = $1'; const result = await this.db.query(query, [username]); return result.rows[0] || null; } async findByFilters(filters: UserFilters): Promise<User[]> { // Building dynamic queries safely const conditions: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (filters.username) { conditions.push(`username = $${paramIndex++}`); params.push(filters.username); } if (filters.email) { conditions.push(`email = $${paramIndex++}`); params.push(filters.email.toLowerCase()); } if (filters.role) { conditions.push(`role = $${paramIndex++}`); params.push(filters.role); } if (filters.createdAfter) { conditions.push(`created_at > $${paramIndex++}`); params.push(filters.createdAfter); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const query = `SELECT * FROM users ${whereClause} ORDER BY created_at DESC`; const result = await this.db.query(query, params); return result.rows; } async findWithOrdering( orderBy: string, orderDir: 'ASC' | 'DESC' = 'ASC' ): Promise<User[]> { // Column names and SQL keywords cannot be parameterized // Must use allowlist validation const allowedColumns = new Set(['username', 'email', 'created_at', 'updated_at']); if (!allowedColumns.has(orderBy)) { throw new ValidationError(`Invalid order column: ${orderBy}`); } // orderDir is typed to only allow ASC/DESC, but validate anyway const safeDir = orderDir === 'DESC' ? 'DESC' : 'ASC'; const query = `SELECT * FROM users ORDER BY ${orderBy} ${safeDir}`; const result = await this.db.query(query); return result.rows; }} // Using an ORM (e.g., Prisma, TypeORM) - parameterization built-inclass OrmUserRepository { constructor(private readonly prisma: PrismaClient) {} async findByUsername(username: string): Promise<User | null> { // Prisma handles parameterization automatically return this.prisma.user.findUnique({ where: { username }, }); } async findByFilters(filters: UserFilters): Promise<User[]> { // Type-safe query building return this.prisma.user.findMany({ where: { ...(filters.username && { username: filters.username }), ...(filters.email && { email: filters.email.toLowerCase() }), ...(filters.role && { role: filters.role }), ...(filters.createdAfter && { createdAt: { gte: filters.createdAfter } }), }, orderBy: { createdAt: 'desc' }, }); }}Do not attempt to escape user input for SQL. There are always edge cases (Unicode, encoding, database-specific quirks) that can be exploited. Parameterized queries are the only reliable solution.
Cross-Site Scripting (XSS) occurs when untrusted data is included in web output without proper encoding, allowing attackers to inject scripts that execute in users' browsers.
XSS Types:
| Type | Vector | Persistence | Example |
|---|---|---|---|
| Reflected | URL parameters, form input | None (single request) | Search query reflected in results page |
| Stored | Database, file storage | Persistent | Comment with script saved to database |
| DOM-based | Client-side JavaScript | None | Script reads from URL and writes to DOM |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
// Context-aware encoding is critical// Different contexts require different encoding class HtmlEncoder { // For HTML text content static encodeForHtml(input: string): string { return input .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // For HTML attribute values static encodeForAttribute(input: string): string { // More extensive encoding for attributes return input.replace(/[^\w\s-]/g, (char) => { return `&#x${char.charCodeAt(0).toString(16).padStart(2, '0')};`; }); } // For JavaScript string literals static encodeForJavaScript(input: string): string { return input.replace(/[\\'"<>&]/g, (char) => { return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`; }); } // For URLs static encodeForUrl(input: string): string { return encodeURIComponent(input); } // For CSS values static encodeForCss(input: string): string { return input.replace(/[^\w-]/g, (char) => { return `\\${char.charCodeAt(0).toString(16)} `; }); }} // Secure HTML rendering serviceclass SecureHtmlRenderer { // Render user content safely renderUserContent(content: string): SafeHtml { // For plain text content - always encode return new SafeHtml(HtmlEncoder.encodeForHtml(content)); } // Render rich text with selective sanitization renderRichText(html: string): SafeHtml { // Use a robust HTML sanitizer library const sanitized = this.htmlSanitizer.sanitize(html, { allowedTags: ['p', 'br', 'b', 'i', 'u', 'a', 'ul', 'ol', 'li', 'code', 'pre'], allowedAttributes: { 'a': ['href', 'title'], }, allowedSchemes: ['http', 'https', 'mailto'], // Strip all event handlers, scripts, etc. disallowedTagsMode: 'discard', }); return new SafeHtml(sanitized); } // Render data into HTML template renderTemplate(template: string, data: Record<string, string>): SafeHtml { let result = template; for (const [key, value] of Object.entries(data)) { // Determine context from template markers // {{html:key}} - for HTML content // {{attr:key}} - for attribute values // {{js:key}} - for JavaScript values // {{url:key}} - for URL components result = result.replace( new RegExp(`\{\{html:${key}\}\}`, 'g'), HtmlEncoder.encodeForHtml(value) ); result = result.replace( new RegExp(`\{\{attr:${key}\}\}`, 'g'), HtmlEncoder.encodeForAttribute(value) ); result = result.replace( new RegExp(`\{\{js:${key}\}\}`, 'g'), HtmlEncoder.encodeForJavaScript(value) ); result = result.replace( new RegExp(`\{\{url:${key}\}\}`, 'g'), HtmlEncoder.encodeForUrl(value) ); } return new SafeHtml(result); }} // SafeHtml type to mark sanitized contentclass SafeHtml { private readonly _value: string; private static readonly marker = Symbol('SafeHtml'); constructor(value: string) { this._value = value; } toHtml(): string { return this._value; } toString(): string { return this._value; } // Concatenate SafeHtml values append(other: SafeHtml): SafeHtml { return new SafeHtml(this._value + other._value); } static fromLiteral(strings: TemplateStringsArray, ...values: SafeHtml[]): SafeHtml { // Template literal that only accepts SafeHtml values let result = strings[0]; for (let i = 0; i < values.length; i++) { result += values[i].toHtml() + strings[i + 1]; } return new SafeHtml(result); }} // URL validation to prevent javascript: and data: XSSclass SafeUrl { private readonly _value: string; private constructor(value: string) { this._value = value; } static validate(url: string): SafeUrl | null { try { const parsed = new URL(url); // Only allow safe protocols const safeProtocols = ['http:', 'https:', 'mailto:']; if (!safeProtocols.includes(parsed.protocol)) { return null; } return new SafeUrl(parsed.href); } catch { // Relative URLs if (url.startsWith('/') && !url.startsWith('//')) { return new SafeUrl(url); } return null; } } toHref(): string { return this._value; } toEncodedAttribute(): string { return HtmlEncoder.encodeForAttribute(this._value); }}Complement input sanitization with Content Security Policy headers. CSP restricts what scripts can execute, providing defense-in-depth even if XSS sanitization fails. Use strict CSP with nonces for inline scripts.
Command injection allows attackers to execute system commands, while path traversal attacks access files outside intended directories. Both are critical vulnerabilities in applications that interact with the file system or execute external programs.
Command Injection Prevention:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
// VULNERABLE: Shell command with user inputclass VulnerableImageProcessor { async resize(filename: string, width: number): Promise<void> { // DANGEROUS! Never do this! const command = `convert ${filename} -resize ${width}x output.jpg`; await exec(command); }} // Attack: filename = "image.jpg; rm -rf /"// Executes: convert image.jpg; rm -rf / -resize 100x output.jpg // SECURE: Avoid shell, validate inputs, use arraysclass SecureImageProcessor { private readonly allowedExtensions = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']); private readonly imageMagickPath = '/usr/bin/convert'; private readonly uploadsDir: string; private readonly outputDir: string; constructor(uploadsDir: string, outputDir: string) { // Resolve to absolute paths this.uploadsDir = path.resolve(uploadsDir); this.outputDir = path.resolve(outputDir); } async resize(filename: string, width: number, height: number): Promise<string> { // Validate width and height if (!Number.isInteger(width) || width < 1 || width > 10000) { throw new ValidationError('Invalid width'); } if (!Number.isInteger(height) || height < 1 || height > 10000) { throw new ValidationError('Invalid height'); } // Validate and sanitize filename const safePath = this.validateAndResolvePath(filename); // Generate safe output filename const outputFilename = `${crypto.randomUUID()}.jpg`; const outputPath = path.join(this.outputDir, outputFilename); // Use execFile with argument array - no shell interpretation await execFile(this.imageMagickPath, [ safePath, '-resize', `${width}x${height}`, '-strip', // Remove metadata outputPath ]); return outputFilename; } private validateAndResolvePath(filename: string): string { // Check extension const ext = path.extname(filename).toLowerCase(); if (!this.allowedExtensions.has(ext)) { throw new ValidationError(`File type ${ext} not allowed`); } // Prevent directory traversal // Get just the filename, no directory components const basename = path.basename(filename); // Construct full path within allowed directory const fullPath = path.resolve(this.uploadsDir, basename); // Verify path is still within uploads directory if (!fullPath.startsWith(this.uploadsDir + path.sep)) { throw new SecurityError('Path traversal attempt detected'); } // Verify file exists if (!fs.existsSync(fullPath)) { throw new NotFoundError('File not found'); } return fullPath; }} // Path traversal prevention for file accessclass SecureFileService { private readonly baseDir: string; constructor(baseDir: string) { this.baseDir = path.resolve(baseDir); } async readFile(relativePath: string): Promise<Buffer> { const safePath = this.resolveSafePath(relativePath); return fs.readFile(safePath); } async writeFile(relativePath: string, content: Buffer): Promise<void> { const safePath = this.resolveSafePath(relativePath); // Ensure directory exists await fs.mkdir(path.dirname(safePath), { recursive: true }); await fs.writeFile(safePath, content); } async listFiles(relativePath: string): Promise<string[]> { const safePath = this.resolveSafePath(relativePath); const stats = await fs.stat(safePath); if (!stats.isDirectory()) { throw new ValidationError('Not a directory'); } const entries = await fs.readdir(safePath); return entries; } private resolveSafePath(relativePath: string): string { // Canonicalize the path const normalized = path.normalize(relativePath); // Check for obvious traversal attempts if (normalized.includes('..') || path.isAbsolute(normalized)) { throw new SecurityError('Path traversal attempt detected'); } // Resolve to full path const fullPath = path.resolve(this.baseDir, normalized); // Critical: Verify the resolved path is within base directory // This catches encoded traversal sequences like %2e%2e%2f if (!fullPath.startsWith(this.baseDir + path.sep) && fullPath !== this.baseDir) { throw new SecurityError('Path traversal attempt detected'); } return fullPath; }} // Filename sanitization for uploadsclass FilenameSanitizer { private static readonly maxLength = 100; private static readonly allowedChars = /^[a-zA-Z0-9._-]+$/; static sanitize(filename: string): string { // Get just the basename let safe = path.basename(filename); // Replace unsafe characters safe = safe.replace(/[^a-zA-Z0-9._-]/g, '_'); // Limit length if (safe.length > this.maxLength) { const ext = path.extname(safe); const name = path.basename(safe, ext).substring(0, this.maxLength - ext.length); safe = name + ext; } // Prevent empty or dot-only names if (!safe || safe === '.' || safe === '..') { safe = 'file'; } // Prevent extension-only names if (safe.startsWith('.')) { safe = 'file' + safe; } return safe; }}Never use shell execution (exec, system, popen) with user input. Use execFile or spawn with argument arrays. If shell commands are unavoidable, use extremely strict allowlists and consider sandboxing.
A well-designed input sanitization service centralizes validation logic, provides consistent error handling, and makes it easy to apply appropriate sanitization throughout the application.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
// Schema-based validation for complex inputsinterface Schema<T> { readonly fields: FieldDefinitions<T>; validate(input: unknown): ValidationResult<T>;} type FieldDefinitions<T> = { [K in keyof T]: FieldValidator<T[K]>;}; interface FieldValidator<T> { required?: boolean; validator: Validator<T>; sanitizer?: (value: T) => T;} class SchemaValidator<T> implements Schema<T> { constructor(readonly fields: FieldDefinitions<T>) {} validate(input: unknown): ValidationResult<T> { if (!input || typeof input !== 'object') { return ValidationResult.failure([ new ValidationError('input', 'Input must be an object') ]); } const obj = input as Record<string, unknown>; const result: Partial<T> = {}; const errors: ValidationError[] = []; for (const [fieldName, definition] of Object.entries(this.fields)) { const fieldDef = definition as FieldValidator<unknown>; const value = obj[fieldName]; // Check required fields if (value === undefined || value === null) { if (fieldDef.required !== false) { errors.push(new ValidationError(fieldName, `${fieldName} is required`)); } continue; } // Validate field const fieldResult = fieldDef.validator.validate(value); if (!fieldResult.isValid) { for (const error of fieldResult.errors) { errors.push(new ValidationError( `${fieldName}.${error.field}`, error.message )); } } else { // Apply sanitizer if present let sanitizedValue = fieldResult.getOrThrow(); if (fieldDef.sanitizer) { sanitizedValue = fieldDef.sanitizer(sanitizedValue); } (result as Record<string, unknown>)[fieldName] = sanitizedValue; } } // Check for unexpected fields const allowedFields = new Set(Object.keys(this.fields)); for (const key of Object.keys(obj)) { if (!allowedFields.has(key)) { errors.push(new ValidationError(key, `Unexpected field: ${key}`)); } } return errors.length > 0 ? ValidationResult.failure(errors) : ValidationResult.success(result as T); }} // Example: User registration request schemainterface CreateUserRequest { username: string; email: string; password: string; displayName: string; bio?: string;} const createUserSchema = new SchemaValidator<CreateUserRequest>({ username: { validator: Validators.username, sanitizer: (v) => v.toLowerCase(), }, email: { validator: Validators.email, sanitizer: (v) => v.toLowerCase().trim(), }, password: { validator: new StringValidator({ minLength: 12, maxLength: 128 }), // Note: No sanitizer for passwords - preserve exactly what user entered }, displayName: { validator: new StringValidator({ minLength: 1, maxLength: 50, pattern: /^[\p{L}\p{N}\s._-]+$/u, // Unicode letters, numbers, spaces, punctuation }), sanitizer: (v) => v.trim(), }, bio: { required: false, validator: new StringValidator({ maxLength: 500 }), sanitizer: (v) => v.trim(), },}); // Input sanitization serviceclass InputSanitizationService { constructor( private readonly logger: SecurityLogger ) {} // Validate and sanitize using schema validateRequest<T>(schema: Schema<T>, input: unknown, context: string): T { const result = schema.validate(input); if (!result.isValid) { this.logger.logValidationFailure(context, result.errors); throw new ValidationException(result.errors); } return result.getOrThrow(); } // Common sanitization utilities sanitizeSearchQuery(query: string): string { // Remove potentially dangerous characters for search // Allow alphanumeric, spaces, and common punctuation return query .trim() .replace(/[<>'"`; \\] / g, '') .substring(0, 200); // Limit length} sanitizeHtml(html: string): SafeHtml { // Use robust HTML sanitizer const clean = sanitizeHtml(html, { allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br'], allowedAttributes: { 'a': ['href', 'title'] }, allowedSchemes: ['https', 'mailto'], }); return new SafeHtml(clean);} validateId(id: string, type: string = 'id'): string { const result = Validators.uuid.validate(id); if (!result.isValid) { throw new ValidationError(`Invalid ${type}`); } return result.getOrThrow(); } validatePagination(page: number, limit: number): { page: number; limit: number } { const pageResult = Validators.integer({ min: 1, max: 10000 }).validate(page); const limitResult = Validators.integer({ min: 1, max: 100 }).validate(limit); return { page: pageResult.isValid ? pageResult.getOrThrow() : 1, limit: limitResult.isValid ? limitResult.getOrThrow() : 20, }; }} // Usage in controllerclass UserController { constructor( private readonly inputService: InputSanitizationService, private readonly userService: UserService ) {} async createUser(requestBody: unknown): Promise<UserResponse> { // Validate and sanitize input const validatedInput = this.inputService.validateRequest( createUserSchema, requestBody, 'createUser' ); // Now validatedInput is fully typed and sanitized const user = await this.userService.create(validatedInput); return user.toPublicDto(); }}Centralizing validation in schemas and services ensures consistency, simplifies testing, and makes it easy to update validation rules across the application. Schemas also serve as documentation for API contracts.
No single security measure is infallible. Defense in depth layers multiple controls so that failure of one doesn't compromise the system.
Layered Input Security:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
// Layer 2: API middleware for request validationclass RequestValidationMiddleware { constructor( private readonly config: ValidationConfig, private readonly logger: SecurityLogger ) {} handle(req: Request, res: Response, next: NextFunction): void { // Content-Type validation if (req.method !== 'GET' && !this.isValidContentType(req)) { this.logger.logSuspiciousRequest(req, 'Invalid content type'); return res.status(415).json({ error: 'Unsupported media type' }); } // Request size limits (backup to infrastructure limits) if (req.headers['content-length']) { const size = parseInt(req.headers['content-length'], 10); if (size > this.config.maxRequestSize) { this.logger.logSuspiciousRequest(req, 'Request too large'); return res.status(413).json({ error: 'Request too large' }); } } // Basic input sanitization at middleware level if (typeof req.body === 'object' && req.body !== null) { req.body = this.deepSanitize(req.body); } next(); } private isValidContentType(req: Request): boolean { const contentType = req.headers['content-type'] || ''; return contentType.includes('application/json') || contentType.includes('multipart/form-data'); } private deepSanitize(obj: unknown, depth = 0): unknown { if (depth > 10) { throw new ValidationError('Object nesting too deep'); } if (obj === null || obj === undefined) { return obj; } if (Array.isArray(obj)) { if (obj.length > 1000) { throw new ValidationError('Array too large'); } return obj.map(item => this.deepSanitize(item, depth + 1)); } if (typeof obj === 'object') { const keys = Object.keys(obj); if (keys.length > 100) { throw new ValidationError('Object has too many properties'); } const result: Record<string, unknown> = {}; for (const key of keys) { // Reject dangerous keys if (key === '__proto__' || key === 'constructor' || key === 'prototype') { this.logger.logPrototypePollutionAttempt(key); continue; } result[key] = this.deepSanitize((obj as Record<string, unknown>)[key], depth + 1); } return result; } if (typeof obj === 'string') { // Remove null bytes and other control characters return obj.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); } return obj; }} // Layer 3: Application service with comprehensive validationclass DocumentService { constructor( private readonly documentRepository: DocumentRepository, private readonly inputService: InputSanitizationService, private readonly htmlSanitizer: HtmlSanitizer, private readonly logger: AuditLogger ) {} async createDocument(userId: string, input: unknown): Promise<Document> { // Validate input structure const validated = this.inputService.validateRequest( createDocumentSchema, input, 'createDocument' ); // Sanitize HTML content const safeContent = this.htmlSanitizer.sanitize(validated.content); // Business validation await this.validateDocumentQuota(userId); // Create with sanitized data const document = await this.documentRepository.create({ id: crypto.randomUUID(), title: validated.title, content: safeContent.toHtml(), // Already sanitized ownerId: userId, createdAt: new Date(), }); await this.logger.log('document.created', { documentId: document.id, userId }); return document; }} // Layer 4: Repository with parameterized queriesclass DocumentRepository { constructor(private readonly db: Database) {} async create(document: CreateDocumentDto): Promise<Document> { // Always parameterized - defense even if application layer fails const result = await this.db.query( `INSERT INTO documents (id, title, content, owner_id, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [document.id, document.title, document.content, document.ownerId, document.createdAt] ); return result.rows[0]; } async findById(id: string): Promise<Document | null> { // Validate ID format even in repository if (!this.isValidUuid(id)) { return null; } const result = await this.db.query( 'SELECT * FROM documents WHERE id = $1', [id] ); return result.rows[0] || null; } private isValidUuid(id: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id); }} // Layer 6: Output encodingclass DocumentController { constructor( private readonly documentService: DocumentService, private readonly renderer: SecureHtmlRenderer ) {} async getDocument(documentId: string): Promise<Response> { const document = await this.documentService.findById(documentId); // When rendering to HTML, encode based on context // In JSON API responses, return data and let frontend encode return { id: document.id, title: document.title, // Will be encoded by React/Vue/etc content: document.content, // Already sanitized HTML // For direct HTML rendering: // titleHtml: this.renderer.renderUserContent(document.title), }; }}Each security layer should work independently. Don't assume previous layers caught everything. Parameterize queries even if input was validated. Encode output even if input was sanitized. This redundancy catches edge cases and defense gaps.
Input sanitization is the first line of defense against injection attacks. Let's consolidate the key principles:
Module Complete:
With input sanitization, we've completed our exploration of Security Patterns. You now have comprehensive knowledge of authentication design, authorization models, secure object design, and input sanitization—the foundational security patterns for building robust, attack-resistant systems.
You now understand comprehensive input sanitization strategies for preventing injection attacks. Apply validation at trust boundaries, use appropriate encoding for output contexts, and layer defenses throughout your application to build systems that resist attack.