50MB $MAX_SIZE = 50 * 1024 * 1024; // Treat payload > 500KB with no $_FILES as suspicious "raw upload" $RAW_BODY_MIN = 500 * 1024; // Flood detection (per-IP uploads per window) $FLOOD_WINDOW_SEC = 60; $FLOOD_MAX_UPLOADS = 40; // Content sniffing: scan first N bytes for PHP/shell patterns (keep small for performance) $SNIFF_MAX_BYTES = 8192; // 8KB $SNIFF_MAX_FILESIZE = 2 * 1024 * 1024; // only sniff files up to 2MB // If true, also log request headers that are useful in forensics (careful with privacy) $LOG_USER_AGENT = true; // Whether the logger may peek into php://input for a small head scan. // WARNING: reading php://input can consume the request body for the application. // Keep this false unless you accept the risk or run behind a proxy that buffers request bodies. $PEEK_RAW_INPUT = false; // Trusted proxy IPs that may set a header to indicate the request body was buffered $TRUSTED_PROXY_IPS = ['127.0.0.1', '::1']; // Environment variable name or marker file to explicitly allow peeking $ALLOW_PEEK_ENV = 'UPLOAD_LOGGER_ALLOW_PEEK'; $PEEK_ALLOW_FILE = __DIR__ . '/.upload_logger_allow_peek'; // Auto-enable peek only when explicitly allowed by environment/file or when a // trusted frontend indicates the body was buffered via header `X-Upload-Logger-Peek: 1`. // This avoids consuming request bodies unexpectedly. try { $envAllow = getenv($ALLOW_PEEK_ENV) === '1'; } catch (Throwable $e) { $envAllow = false; } // Base64/JSON detection thresholds $BASE64_MIN_CHARS = 200; // minimum base64 chars to consider a blob $BASE64_DECODE_CHUNK = 1024; // how many base64 chars to decode for fingerprinting $BASE64_FINGERPRINT_BYTES = 128; // bytes of decoded head to hash for fingerprint // Allowlist for known benign base64 sources. Patterns can be simple substrings // (checked with `strpos`) or PCRE regex when wrapped with '#', e.g. '#^/internal/webhook#'. // Default in-code allowlist (used if no allowlist file is present) $BASE64_ALLOWLIST_URI = [ '/api/uploads/avatars', '/api/v1/avatars', '/user/avatar', '/media/upload', '/api/media', '/api/uploads', '/api/v1/uploads', '/attachments/upload', '/upload', '#^/internal/webhook#', '#/hooks/(github|gitlab|stripe|slack)#', '/services/avatars', '/api/profile/photo' ]; // Optional allowlist of content-types (exact match, without params) $BASE64_ALLOWLIST_CTYPE = []; // Allowlist file location and environment override $ALLOWLIST_FILE_DEFAULT = __DIR__ . '/allowlist.json'; $ALLOWLIST_FILE = getenv('UPLOAD_LOGGER_ALLOWLIST') ?: $ALLOWLIST_FILE_DEFAULT; if (is_file($ALLOWLIST_FILE)) { $raw = @file_get_contents($ALLOWLIST_FILE); $json = @json_decode($raw, true); if (is_array($json)) { if (!empty($json['uris']) && is_array($json['uris'])) { $BASE64_ALLOWLIST_URI = $json['uris']; } if (!empty($json['ctypes']) && is_array($json['ctypes'])) { $BASE64_ALLOWLIST_CTYPE = $json['ctypes']; } } } function base64_is_allowlisted(string $uri, string $ctype): bool { global $BASE64_ALLOWLIST_URI, $BASE64_ALLOWLIST_CTYPE; foreach ($BASE64_ALLOWLIST_URI as $p) { if (strlen($p) > 1 && $p[0] === '#' && substr($p, -1) === '#') { // regex if (@preg_match($p, $uri)) return true; } else { if (strpos($uri, $p) !== false) return true; } } if (!empty($BASE64_ALLOWLIST_CTYPE) && $ctype !== '') { $base = explode(';', $ctype, 2)[0]; foreach ($BASE64_ALLOWLIST_CTYPE as $ct) { if (strtolower(trim($ct)) === strtolower(trim($base))) return true; } } return false; } $fileAllow = is_file($PEEK_ALLOW_FILE); $headerAllow = false; if (isset($_SERVER['HTTP_X_UPLOAD_LOGGER_PEEK']) && $_SERVER['HTTP_X_UPLOAD_LOGGER_PEEK'] === '1') { $clientIp = get_client_ip(); if (in_array($clientIp, $TRUSTED_PROXY_IPS, true)) { $headerAllow = true; } } if ($envAllow || $fileAllow || $headerAllow) { $PEEK_RAW_INPUT = true; if (function_exists('log_event')) { log_event('config_info', ['msg' => 'peek_enabled', 'env' => $envAllow, 'file' => $fileAllow, 'header' => $headerAllow]); } } // Store flood counters in a protected directory (avoid /tmp tampering) $STATE_DIR = __DIR__ . '/state'; // Hash files up to this size for forensics $HASH_MAX_FILESIZE = 10 * 1024 * 1024; // 10MB // Quarantine suspicious uploads (move outside webroot, restrictive perms) $QUARANTINE_ENABLED = true; // enabled by default for hardened deployments $QUARANTINE_DIR = __DIR__ . '/quarantine'; // Archive inspection $ARCHIVE_INSPECT = true; // inspect archives moved to quarantine $ARCHIVE_BLOCK_ON_SUSPICIOUS = false; // optionally block request when archive contains suspicious entries $ARCHIVE_MAX_ENTRIES = 200; // max entries to inspect in an archive // Max archive file size to inspect (bytes). Larger archives will be skipped to avoid CPU/IO costs. $ARCHIVE_MAX_INSPECT_SIZE = 50 * 1024 * 1024; // 50 MB /* ========================================== */ // Ensure log dir $logDir = dirname($logFile); if (!is_dir($logDir)) { @mkdir($logDir, 0750, true); } // Ensure state dir if (!is_dir($STATE_DIR)) { @mkdir($STATE_DIR, 0750, true); } // Ensure quarantine dir if enabled and enforce strict permissions if ($QUARANTINE_ENABLED) { if (!is_dir($QUARANTINE_DIR)) { @mkdir($QUARANTINE_DIR, 0700, true); } if (is_dir($QUARANTINE_DIR)) { // attempt to enforce strict permissions (owner only) @chmod($QUARANTINE_DIR, 0700); // verify perms: group/other bits must be zero $perms = @fileperms($QUARANTINE_DIR); if ($perms !== false) { // mask to rwxrwxrwx (lower 9 bits) $mask = $perms & 0x1FF; // if any group/other bits set, warn if (($mask & 0o077) !== 0) { if (function_exists('log_event')) { log_event('config_warning', [ 'msg' => 'quarantine_dir_perms_not_strict', 'path' => $QUARANTINE_DIR, 'perms_octal' => sprintf('%o', $mask), ]); } } } } else { if (function_exists('log_event')) { log_event('config_error', ['msg' => 'quarantine_dir_missing', 'path' => $QUARANTINE_DIR]); } } } // Attempt to enforce owner:group for quarantine directory when possible $DESIRED_QUARANTINE_OWNER = 'root'; $DESIRED_QUARANTINE_GROUP = 'www-data'; if ($QUARANTINE_ENABLED && is_dir($QUARANTINE_DIR)) { // If running as root, attempt to chown/chgrp to desired values if (function_exists('posix_geteuid') && posix_geteuid() === 0) { @chown($QUARANTINE_DIR, $DESIRED_QUARANTINE_OWNER); @chgrp($QUARANTINE_DIR, $DESIRED_QUARANTINE_GROUP); } elseif (function_exists('posix_getegid') && function_exists('posix_getgrgid')) { // Not root: try at least to set group to the process group $egid = posix_getegid(); $gr = posix_getgrgid($egid); if ($gr && isset($gr['name'])) { @chgrp($QUARANTINE_DIR, $gr['name']); } } // Verify owner/group and log if not matching desired values $ownerOk = false; $groupOk = false; $statUid = @fileowner($QUARANTINE_DIR); $statGid = @filegroup($QUARANTINE_DIR); if ($statUid !== false && function_exists('posix_getpwuid')) { $pw = posix_getpwuid($statUid); if ($pw && isset($pw['name']) && $pw['name'] === $DESIRED_QUARANTINE_OWNER) { $ownerOk = true; } } if ($statGid !== false && function_exists('posix_getgrgid')) { $gg = posix_getgrgid($statGid); if ($gg && isset($gg['name']) && $gg['name'] === $DESIRED_QUARANTINE_GROUP) { $groupOk = true; } } if (!($ownerOk && $groupOk)) { log_event('config_warning', [ 'msg' => 'quarantine_owner_group_mismatch', 'path' => $QUARANTINE_DIR, 'desired_owner' => $DESIRED_QUARANTINE_OWNER, 'desired_group' => $DESIRED_QUARANTINE_GROUP, 'current_uid' => $statUid, 'current_gid' => $statGid, ]); } } /* ---------- Utils ---------- */ function upload_clean($str): string { return str_replace(["\n", "\r", "\t"], '_', (string)$str); } function log_normalize_value($value) { if (is_array($value)) { $out = []; foreach ($value as $k => $v) { $out[$k] = log_normalize_value($v); } return $out; } if (is_bool($value) || is_int($value) || is_float($value) || $value === null) { return $value; } $str = (string)$value; return preg_replace('/[\x00-\x1F\x7F]/', '_', $str); } function generate_request_id(): string { try { return bin2hex(random_bytes(8)); } catch (Throwable $e) { return uniqid('req', true); } } function log_event(string $event, array $data = []): void { global $logFile, $REQUEST_CTX; $payload = array_merge( ['ts' => gmdate('c'), 'event' => $event], is_array($REQUEST_CTX) ? $REQUEST_CTX : [], $data ); $payload = log_normalize_value($payload); $json = json_encode($payload, JSON_UNESCAPED_SLASHES); if ($json === false) { $json = json_encode([ 'ts' => gmdate('c'), 'event' => 'log_error', 'error' => json_last_error_msg(), ], JSON_UNESCAPED_SLASHES); } @file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX); } function get_client_ip(): string { // Prefer REMOTE_ADDR (trusted), but log proxy headers separately if needed. return $_SERVER['REMOTE_ADDR'] ?? 'unknown'; } function get_user_id(): string { // Avoid assuming session is started. // If you have app-specific auth headers, extend here. if (isset($_SESSION) && is_array($_SESSION) && isset($_SESSION['user_id'])) { return (string)$_SESSION['user_id']; } if (!empty($_SERVER['PHP_AUTH_USER'])) { return (string)$_SERVER['PHP_AUTH_USER']; } return 'guest'; } function get_request_summary(): array { global $LOG_USER_AGENT; $ip = get_client_ip(); $uri = $_SERVER['REQUEST_URI'] ?? 'unknown'; $method = $_SERVER['REQUEST_METHOD'] ?? 'unknown'; $ctype = $_SERVER['CONTENT_TYPE'] ?? ''; $clen = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); $ua = $LOG_USER_AGENT ? ($_SERVER['HTTP_USER_AGENT'] ?? '') : ''; $te = $_SERVER['HTTP_TRANSFER_ENCODING'] ?? ''; return [$ip, $uri, $method, $ctype, $clen, $ua, $te]; } /** * Simple per-IP flood counter in /tmp with TTL window. * This is lightweight and avoids dependencies. */ function flood_check(string $ip): int { global $FLOOD_WINDOW_SEC, $STATE_DIR; $key = $STATE_DIR . '/upl_' . md5('v3|' . $ip); $now = time(); $count = 0; $start = $now; $fh = @fopen($key, 'c+'); if ($fh === false) { return 1; } if (flock($fh, LOCK_EX)) { $raw = stream_get_contents($fh); if ($raw !== false) { if (preg_match('/^(\d+):(\d+)$/', trim($raw), $m)) { $start = (int)$m[1]; $count = (int)$m[2]; } } if (($now - $start) > $FLOOD_WINDOW_SEC) { $start = $now; $count = 0; } $count++; rewind($fh); ftruncate($fh, 0); fwrite($fh, $start . ':' . $count); fflush($fh); flock($fh, LOCK_UN); } fclose($fh); return $count; } function is_suspicious_filename(string $name): bool { $n = strtolower($name); // Path traversal / weird separators in filename if (strpos($n, '../') !== false || strpos($n, '..\\') !== false || strpos($n, "\0") !== false) { return true; } // Dangerous extensions (final) if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $n)) { return true; } // Double-extension tricks anywhere (e.g., image.php.jpg or image.jpg.php) if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)\./i', $n)) { return true; } // Hidden dotfile php-like names if (preg_match('/^\.(php|phtml|phar|php\d)/i', $n)) { return true; } return false; } function sniff_file_for_php_payload(string $tmpPath): bool { global $SNIFF_MAX_BYTES, $SNIFF_MAX_FILESIZE; if (!is_uploaded_file($tmpPath)) return false; $sz = @filesize($tmpPath); if ($sz === false) return false; if ($sz <= 0) return false; if ($sz > $SNIFF_MAX_FILESIZE) return false; $bytes = min($SNIFF_MAX_BYTES, $sz); $head = @file_get_contents($tmpPath, false, null, 0, $bytes); if ($head === false) return false; // Pass the detected real mime to the scanner so it can relax JS-specific // rules for SVG/XML payloads (avoids false positives on benign SVGs). $realMime = detect_real_mime($tmpPath); if (payload_contains_php_markers($head, $realMime)) { return true; } return false; } function payload_contains_php_markers(string $text, string $contentType = ''): bool { // Determine if content-type suggests XML/SVG so we can be permissive $isXmlLike = false; if ($contentType !== '') { $isXmlLike = (bool)preg_match('/xml|svg/i', $contentType); } // Always detect explicit PHP tags or short-open tags (but avoid '= 200 chars) if (preg_match('/"(?:file|data|payload|content)"\s*:\s*"(?:data:[^,]+,)?([A-Za-z0-9+\/=]{200,})"/i', $head, $m)) { $b64 = $m[1]; // Decode only the first N characters of base64 string safely (rounded up to multiple of 4) $chunk = substr($b64, 0, 1024); $pad = 4 - (strlen($chunk) % 4); if ($pad < 4) $chunk .= str_repeat('=', $pad); $decoded = @base64_decode($chunk, true); if ($decoded === false) return ['found' => true, 'decoded_head' => null, 'reason' => 'base64_decode_failed']; $decoded_head = substr($decoded, 0, $maxDecoded); return ['found' => true, 'decoded_head' => $decoded_head, 'reason' => null]; } // Also detect raw base64 body start (no JSON): long base64 string at start if (preg_match('/^\s*([A-Za-z0-9+\/=]{400,})/s', $head, $m2)) { $b64 = $m2[1]; $chunk = substr($b64, 0, 1024); $pad = 4 - (strlen($chunk) % 4); if ($pad < 4) $chunk .= str_repeat('=', $pad); $decoded = @base64_decode($chunk, true); if ($decoded === false) return ['found' => true, 'decoded_head' => null, 'reason' => 'base64_decode_failed']; return ['found' => true, 'decoded_head' => substr($decoded, 0, $maxDecoded), 'reason' => null]; } return ['found' => false, 'decoded_head' => null, 'reason' => null]; } function detect_real_mime(string $tmpPath): string { $real = 'unknown'; if (is_uploaded_file($tmpPath) && function_exists('finfo_open')) { $f = @finfo_open(FILEINFO_MIME_TYPE); if ($f) { $m = @finfo_file($f, $tmpPath); if (is_string($m) && $m !== '') { $real = $m; } @finfo_close($f); } } return $real; } function is_fake_image(string $name, string $realMime): bool { // If filename looks like image but real mime is not image/* if (preg_match('/\.(png|jpe?g|gif|webp|bmp|ico|svg)$/i', $name)) { // SVG often returns image/svg+xml; still image/* if (!preg_match('/^image\//', $realMime)) { return true; } } return false; } function is_archive(string $name, string $realMime): bool { // Archives often used to smuggle payloads if (preg_match('/\.(zip|rar|7z|tar|gz|tgz)$/i', $name)) return true; if (preg_match('/(zip|x-7z-compressed|x-rar|x-tar|gzip)/i', $realMime)) return true; return false; } function compute_hashes(string $tmpPath, int $size): array { global $HASH_MAX_FILESIZE; if (!is_uploaded_file($tmpPath)) return []; if ($size <= 0 || $size > $HASH_MAX_FILESIZE) return []; $sha1 = @hash_file('sha1', $tmpPath); $md5 = @hash_file('md5', $tmpPath); $out = []; if (is_string($sha1)) $out['sha1'] = $sha1; if (is_string($md5)) $out['md5'] = $md5; return $out; } function quarantine_file(string $tmpPath, string $origName, array $hashes): array { global $QUARANTINE_ENABLED, $QUARANTINE_DIR; if (!$QUARANTINE_ENABLED) return ['ok' => false, 'path' => '']; if (!is_uploaded_file($tmpPath)) return ['ok' => false, 'path' => '']; if (!is_dir($QUARANTINE_DIR)) return ['ok' => false, 'path' => '']; $ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION)); if (!preg_match('/^[a-z0-9]{1,10}$/', $ext)) { $ext = ''; } $base = $hashes['sha1'] ?? ''; if ($base === '') { try { $base = bin2hex(random_bytes(16)); } catch (Throwable $e) { $base = uniqid('q', true); } } $dest = rtrim($QUARANTINE_DIR, '/\\') . '/' . $base . ($ext ? '.' . $ext : ''); $ok = @move_uploaded_file($tmpPath, $dest); if ($ok) { @chmod($dest, 0600); return ['ok' => true, 'path' => $dest]; } return ['ok' => false, 'path' => $dest]; } /** * Inspect archive file in quarantine without extracting. * Supports ZIP via ZipArchive and TAR (.tar, .tar.gz) via PharData if available. * Returns summary array: ['entries'=>N, 'suspicious_entries'=> [...], 'unsupported'=>bool] */ function inspect_archive_quarantine(string $path): array { global $ARCHIVE_MAX_ENTRIES; global $ARCHIVE_MAX_INSPECT_SIZE; // Avoid inspecting extremely large archives $fsz = @filesize($path); if ($fsz !== false && $fsz > $ARCHIVE_MAX_INSPECT_SIZE) { return ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => false, 'too_large' => true]; } $out = ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => false]; if (!is_file($path)) { $out['unsupported'] = true; return $out; } $lower = strtolower($path); // ZIP if (class_exists('ZipArchive') && preg_match('/\.zip$/i', $lower)) { $za = new ZipArchive(); if ($za->open($path) === true) { $cnt = $za->numFiles; $out['entries'] = min($cnt, $ARCHIVE_MAX_ENTRIES); $limit = $out['entries']; for ($i = 0; $i < $limit; $i++) { $stat = $za->statIndex($i); if ($stat && isset($stat['name'])) { $name = $stat['name']; $entry = ['name' => $name, 'suspicious' => false, 'reason' => null]; // traversal or absolute path if (strpos($name, '../') !== false || strpos($name, '..\\') !== false || strpos($name, '/') === 0 || strpos($name, '\\') === 0) { $entry['suspicious'] = true; $entry['reason'] = 'path_traversal'; } // suspicious extension if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $name)) { $entry['suspicious'] = true; $entry['reason'] = ($entry['reason'] ? $entry['reason'] . ',ext' : 'ext'); } if ($entry['suspicious']) $out['suspicious_entries'][] = $entry; } } $za->close(); } else { $out['unsupported'] = true; } return $out; } // TAR (including .tar.gz) via PharData if available if (class_exists('PharData') && preg_match('/\.(tar|tar\.gz|tgz|tar\.bz2)$/i', $lower)) { try { $ph = new PharData($path); $it = new RecursiveIteratorIterator($ph); $count = 0; foreach ($it as $file) { if ($count++ >= $ARCHIVE_MAX_ENTRIES) break; $name = (string)$file; $entry = ['name' => $name, 'suspicious' => false, 'reason' => null]; if (strpos($name, '../') !== false || strpos($name, '..\\') !== false || strpos($name, '/') === 0 || strpos($name, '\\') === 0) { $entry['suspicious'] = true; $entry['reason'] = 'path_traversal'; } if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $name)) { $entry['suspicious'] = true; $entry['reason'] = ($entry['reason'] ? $entry['reason'] . ',ext' : 'ext'); } if ($entry['suspicious']) $out['suspicious_entries'][] = $entry; } $out['entries'] = $count; } catch (Exception $e) { $out['unsupported'] = true; } return $out; } // unsupported archive type $out['unsupported'] = true; return $out; } /* ---------- Context ---------- */ [$ip, $uri, $method, $ctype, $clen, $ua, $te] = get_request_summary(); $userId = get_user_id(); $requestId = generate_request_id(); $REQUEST_CTX = [ 'request_id' => $requestId, 'ip' => $ip, 'uri' => $uri, 'method' => $method, 'ctype' => $ctype, 'clen' => (int)$clen, 'user' => $userId, 'ua' => $ua, 'transfer_encoding' => $te, ]; // Only upload-capable methods if (!in_array($method, ['POST', 'PUT', 'PATCH'], true)) { return; } // Log suspicious raw-body uploads that bypass $_FILES // (Do this early so we capture endpoints that stream content into file_put_contents) if (empty($_FILES)) { $rawSuspicious = false; if ($clen >= $RAW_BODY_MIN) $rawSuspicious = true; if ($te !== '') $rawSuspicious = true; if (stripos($ctype, 'application/octet-stream') !== false) $rawSuspicious = true; if (stripos($ctype, 'application/json') !== false) $rawSuspicious = true; // Guarded peek into php://input for JSON/base64 payload detection. // Only perform when explicitly enabled and when CONTENT_LENGTH is small enough // to avoid consuming large bodies or affecting application behavior. global $PEEK_RAW_INPUT, $SNIFF_MAX_FILESIZE, $SNIFF_MAX_BYTES; if ($PEEK_RAW_INPUT && $clen > 0 && $clen <= $SNIFF_MAX_FILESIZE) { $peek = ''; $in = @fopen('php://input', 'r'); if ($in !== false) { // read a small head only $peek = @stream_get_contents($in, $SNIFF_MAX_BYTES); @fclose($in); } if ($peek !== false && $peek !== '') { // Detect JSON-embedded base64 and inspect decoded head $b = detect_json_base64_head($peek, 1024); if (!empty($b['found'])) { // skip fingerprinting/inspection for allowlisted URIs/CTypes if (base64_is_allowlisted($uri, $ctype)) { log_event('raw_body_base64_ignored', ['uri' => $uri, 'ctype' => $ctype]); // mark suspicious only if other raw indicators exist // continue without further decoding/fingerprinting $rawSuspicious = $rawSuspicious || false; } else { // log base64 blob detected; include fingerprint of decoded head when available $fingerprints = []; if (!empty($b['decoded_head'])) { $decoded_head = $b['decoded_head']; $sample = substr($decoded_head, 0, $BASE64_FINGERPRINT_BYTES); $fingerprints['sha1'] = @sha1($sample); $fingerprints['md5'] = @md5($sample); if (payload_contains_php_markers($decoded_head, $ctype)) { $rawSuspicious = true; log_event('raw_body_php_payload', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => $b['reason'] ?? 'base64_embedded', 'fingerprints' => $fingerprints, ]); } else { log_event('raw_body_base64', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => $b['reason'] ?? 'base64_embedded', 'fingerprints' => $fingerprints, ]); } } else { log_event('raw_body_base64', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => $b['reason'] ?? 'base64_embedded', ]); } } } else { // Also scan the raw head itself for PHP markers (text/plain, octet-stream, etc.) if (payload_contains_php_markers($peek, $ctype)) { log_event('raw_body_php_payload', [ 'len' => (int)$clen, 'ctype' => $ctype, 'reason' => 'head_php_markers', ]); $rawSuspicious = true; } } } } if ($rawSuspicious) { log_event('raw_body', [ 'len' => (int)$clen, 'ctype' => $ctype, ]); } } // multipart/form-data but no $_FILES if ( empty($_FILES) && $ctype && stripos($ctype, 'multipart/form-data') !== false ) { log_event('multipart_no_files', []); } /* ---------- Upload Handling ---------- */ if (!empty($_FILES)) { // Per request flood check: count each file below too // (Optional: log the current counter at request-level) $reqCount = flood_check($ip); if ($reqCount > $GLOBALS['FLOOD_MAX_UPLOADS']) { log_event('flood_alert', ['count' => (int)$reqCount]); // Don't block purely on this here unless you want to // if ($BLOCK_SUSPICIOUS) { http_response_code(429); exit('Too many uploads'); } } foreach ($_FILES as $file) { if (!isset($file['name'])) continue; // Multi upload field: name[] if (is_array($file['name'])) { $count = count($file['name']); for ($i = 0; $i < $count; $i++) { handle_file_v3( $ip, $uri, $userId, $ua, $file['name'][$i] ?? '', $file['type'][$i] ?? '', $file['size'][$i] ?? 0, $file['tmp_name'][$i] ?? '', $file['error'][$i] ?? UPLOAD_ERR_NO_FILE ); } } else { handle_file_v3( $ip, $uri, $userId, $ua, $file['name'] ?? '', $file['type'] ?? '', $file['size'] ?? 0, $file['tmp_name'] ?? '', $file['error'] ?? UPLOAD_ERR_NO_FILE ); } } } /* ---------- Core ---------- */ function handle_file_v3($ip, $uri, $user, $ua, $name, $type, $size, $tmp, $err): void { global $BLOCK_SUSPICIOUS, $MAX_SIZE, $FLOOD_MAX_UPLOADS; if ($err !== UPLOAD_ERR_OK) { // Log non-OK upload errors for forensics log_event('upload_error', [ 'name' => $name, 'err' => (int)$err, ]); return; } $origName = (string)$name; $name = basename($origName); $type = (string)$type; $size = (int)$size; $tmp = (string)$tmp; // Flood count per file (stronger) $count = flood_check($ip); if ($count > $FLOOD_MAX_UPLOADS) { log_event('flood_alert', ['count' => (int)$count]); // Optional blocking: // if ($BLOCK_SUSPICIOUS) { http_response_code(429); exit('Too many uploads'); } } // Real MIME $real = detect_real_mime($tmp); /* Detection */ $suspicious = false; $reasons = []; // Path components or modified basename if ($origName !== $name || strpos($origName, '/') !== false || strpos($origName, '\\') !== false) { $suspicious = true; $reasons[] = 'bad_name'; } // Dangerous / tricky filename if (is_suspicious_filename($name)) { $suspicious = true; $reasons[] = 'bad_name'; } // Fake images (name says image, MIME isn't) if (is_fake_image($name, $real)) { $suspicious = true; $reasons[] = 'fake_image'; } // Very large file if ($size > $MAX_SIZE) { log_event('big_upload', [ 'name' => $name, 'size' => (int)$size, ]); $reasons[] = 'big_file'; // (Not automatically suspicious; depends on your app) } // Archive uploads are higher risk (often used to smuggle payloads) if (is_archive($name, $real)) { $reasons[] = 'archive'; // Move to quarantine and inspect archive contents safely (no extraction) log_event('archive_upload', [ 'name' => $name, 'real_mime' => $real, ]); if ($QUARANTINE_ENABLED) { $qres = quarantine_file($tmp, $origName, $hashes ?? []); if ($qres['ok']) { $qpath = $qres['path']; log_event('archive_quarantined', ['path' => $qpath]); if ($ARCHIVE_INSPECT) { $inspect = inspect_archive_quarantine($qpath); log_event('archive_inspect', ['path' => $qpath, 'summary' => $inspect]); if (!empty($inspect['suspicious_entries'])) { $suspicious = true; $reasons[] = 'archive_contains_suspicious'; if ($ARCHIVE_BLOCK_ON_SUSPICIOUS && $BLOCK_SUSPICIOUS) { http_response_code(403); exit('Upload blocked - suspicious archive'); } } } } else { log_event('archive_quarantine_failed', ['tmp' => $tmp, 'dest' => $qres['path']]); } } } // Content sniffing for PHP payload (fast head scan, only for small files) if (sniff_file_for_php_payload($tmp)) { $suspicious = true; $reasons[] = 'php_payload'; } /* Logging */ $hashes = compute_hashes($tmp, $size); log_event('upload', [ 'name' => $name, 'orig_name' => $origName, 'size' => (int)$size, 'type' => $type, 'real_mime' => $real, 'tmp' => $tmp, 'hashes' => $hashes, 'flags' => $reasons, ]); /* Alert / Block */ if ($suspicious) { $q = quarantine_file($tmp, $origName, $hashes); log_event('suspicious', [ 'name' => $name, 'orig_name' => $origName, 'real_mime' => $real, 'reasons' => $reasons, 'quarantine_ok' => $q['ok'], 'quarantine_path' => $q['path'], ]); if ($BLOCK_SUSPICIOUS) { http_response_code(403); exit('Upload blocked'); } } }