import crypto from "crypto"; import { NextRequest } from "next/server"; export interface ImageValidationResult { valid: boolean; error?: string; mimeType?: "image/jpeg" | "image/png" | "image/webp"; extension?: "jpg" | "png" | "webp"; sanitizedBase64?: string; sizeBytes?: number; } /** * Known file signatures (magic bytes) for allowed image formats */ const MAGIC_SIGNATURES = { // JPEG: starts with FF D8 FF jpeg: [0xff, 0xd8, 0xff], // PNG: starts with 89 50 4E 47 0D 0A 1A 0A png: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], // WebP: starts with "RIFF" (52 49 46 46) and at offset 8 has "WEBP" (57 45 42 50) riff: [0x52, 0x49, 0x46, 0x46], webp: [0x57, 0x45, 0x42, 0x50], }; /** * Dangerous executable & script signatures to explicitly detect and reject */ const DANGEROUS_SIGNATURES = [ // Windows DOS/PE executable ('MZ' - 4D 5A) { name: "Windows Executable (PE/DOS)", bytes: [0x4d, 0x5a] }, // Linux ELF binary (7F 45 4C 46) { name: "Linux ELF Binary", bytes: [0x7f, 0x45, 0x4c, 0x46] }, // Mach-O binary (FE ED FA CE / FE ED FA CF / CE FA ED FE / CF FA ED FE) { name: "Mach-O Binary (32-bit)", bytes: [0xfe, 0xed, 0xfa, 0xce] }, { name: "Mach-O Binary (64-bit)", bytes: [0xfe, 0xed, 0xfa, 0xcf] }, { name: "Mach-O Binary (rev 32)", bytes: [0xce, 0xfa, 0xed, 0xfe] }, { name: "Mach-O Binary (rev 64)", bytes: [0xcf, 0xfa, 0xed, 0xfe] }, // Java class file (CA FE BA BE) { name: "Java Class File", bytes: [0xca, 0xfe, 0xba, 0xbe] }, // Shell script shebang ('#!' - 23 21) { name: "Shell Script", bytes: [0x23, 0x21] }, ]; /** * Matches bytes at specific offset */ function matchesBytes(buffer: Buffer, expected: number[], offset = 0): boolean { if (buffer.length < offset + expected.length) return false; for (let i = 0; i < expected.length; i++) { if (buffer[offset + i] !== expected[i]) return false; } return true; } /** * Validates a base64 string or data URL: * 1. Checks max size limits * 2. Decodes binary buffer * 3. Scans for known executable/script signatures * 4. Verifies genuine image magic bytes (JPEG, PNG, WebP) * 5. Rejects scripts / HTML / SVG content */ export function validateAndSanitizeImage( input: string, maxSizeBytes: number = 10 * 1024 * 1024 ): ImageValidationResult { if (!input || typeof input !== "string") { return { valid: false, error: "Missing or invalid image payload" }; } // Check approximate string size (base64 string is ~4/3 larger than binary) // 10MB binary ≈ 13.5MB base64 string const maxStringLen = Math.ceil(maxSizeBytes * 1.4) + 1024; if (input.length > maxStringLen) { return { valid: false, error: `Image exceeds maximum allowed size (${Math.round(maxSizeBytes / (1024 * 1024))}MB)` }; } // Strip possible Data URL prefix const dataUrlMatch = input.match(/^data:([a-zA-Z0-9/+-]+);base64,(.+)$/); const rawBase64 = dataUrlMatch ? dataUrlMatch[2] : input.trim(); // Basic Base64 character validation (safe regex pattern) if (!/^[A-Za-z0-9+/=]+$/.test(rawBase64)) { return { valid: false, error: "Invalid base64 encoding" }; } let buffer: Buffer; try { buffer = Buffer.from(rawBase64, "base64"); } catch { return { valid: false, error: "Failed to decode base64 payload" }; } if (buffer.length === 0) { return { valid: false, error: "Empty image payload" }; } if (buffer.length > maxSizeBytes) { return { valid: false, error: `Image binary size exceeds ${Math.round(maxSizeBytes / (1024 * 1024))}MB limit` }; } // 1. Explicitly check for known executable or script signatures for (const sig of DANGEROUS_SIGNATURES) { if (matchesBytes(buffer, sig.bytes)) { return { valid: false, error: `Executable or script payload detected (${sig.name}). Upload rejected.`, }; } } // 2. Check for script/text content (e.g. ) const headerPreview = buffer.slice(0, Math.min(buffer.length, 512)).toString("utf8").toLowerCase(); const dangerousPatterns = [ "(); export function checkAuthRateLimit( ip: string, maxFailures: number = 5, lockoutMinutes: number = 15 ): { allowed: boolean; remainingAttempts: number; lockoutSeconds: number } { const now = Date.now(); const record = authAttemptsStore.get(ip); if (!record) { return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 }; } // If currently locked out if (record.lockedUntil > now) { const lockoutSeconds = Math.ceil((record.lockedUntil - now) / 1000); return { allowed: false, remainingAttempts: 0, lockoutSeconds }; } // If lockout or window has expired (15 minutes), reset const windowMs = lockoutMinutes * 60 * 1000; if (now - record.firstAttemptAt > windowMs) { authAttemptsStore.delete(ip); return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 }; } const remainingAttempts = Math.max(0, maxFailures - record.failedAttempts); return { allowed: record.failedAttempts < maxFailures, remainingAttempts, lockoutSeconds: 0, }; } export function recordAuthFailure( ip: string, maxFailures: number = 5, lockoutMinutes: number = 15 ): void { const now = Date.now(); const record = authAttemptsStore.get(ip); if (!record) { authAttemptsStore.set(ip, { failedAttempts: 1, lockedUntil: 0, firstAttemptAt: now, }); } else { record.failedAttempts += 1; if (record.failedAttempts >= maxFailures) { record.lockedUntil = now + lockoutMinutes * 60 * 1000; } } } export function recordAuthSuccess(ip: string): void { authAttemptsStore.delete(ip); }