Files

269 lines
7.9 KiB
TypeScript

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. <?php, <script, <svg, <html>)
const headerPreview = buffer.slice(0, Math.min(buffer.length, 512)).toString("utf8").toLowerCase();
const dangerousPatterns = [
"<?php",
"<% ",
"<script",
"<html",
"<body",
"<svg",
"#!/",
"eval(",
"system(",
"exec(",
];
for (const pattern of dangerousPatterns) {
if (headerPreview.includes(pattern)) {
return {
valid: false,
error: "Script or HTML content detected in image payload. Upload rejected.",
};
}
}
// 3. Verify legitimate image magic bytes
let detectedType: "image/jpeg" | "image/png" | "image/webp" | null = null;
let extension: "jpg" | "png" | "webp" = "jpg";
if (matchesBytes(buffer, MAGIC_SIGNATURES.jpeg)) {
detectedType = "image/jpeg";
extension = "jpg";
} else if (matchesBytes(buffer, MAGIC_SIGNATURES.png)) {
detectedType = "image/png";
extension = "png";
} else if (
matchesBytes(buffer, MAGIC_SIGNATURES.riff, 0) &&
matchesBytes(buffer, MAGIC_SIGNATURES.webp, 8)
) {
detectedType = "image/webp";
extension = "webp";
}
if (!detectedType) {
return {
valid: false,
error: "Unsupported or invalid image format. Only authentic JPEG, PNG, or WebP images are permitted.",
};
}
return {
valid: true,
mimeType: detectedType,
extension,
sanitizedBase64: `data:${detectedType};base64,${buffer.toString("base64")}`,
sizeBytes: buffer.length,
};
}
/**
* Extracts and normalizes the client IP address from request headers
*/
export function getClientIp(req: NextRequest): string {
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) {
const firstIp = forwardedFor.split(",")[0].trim();
if (firstIp) return firstIp;
}
const realIp = req.headers.get("x-real-ip");
if (realIp && realIp.trim()) {
return realIp.trim();
}
return "127.0.0.1";
}
/**
* Constant-time string comparison using SHA-256 digests to prevent timing attacks
*/
export function constantTimeCompare(a: string, b: string): boolean {
if (typeof a !== "string" || typeof b !== "string") return false;
const hashA = crypto.createHash("sha256").update(a).digest();
const hashB = crypto.createHash("sha256").update(b).digest();
return crypto.timingSafeEqual(hashA, hashB);
}
/**
* In-memory brute-force protection for sensitive endpoints (e.g. portal login)
*/
interface AuthAttemptRecord {
failedAttempts: number;
lockedUntil: number;
firstAttemptAt: number;
}
const authAttemptsStore = new Map<string, AuthAttemptRecord>();
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);
}