269 lines
7.9 KiB
TypeScript
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);
|
|
}
|