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 = 'UPLOADSHIELD_ALLOW_PEEK'; $PEEK_ALLOW_FILE = __DIR__ . '/.uploadshield_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-UploadShield-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('UPLOADSHIELD_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']; } } } // Load config (JSON) or fall back to inline defaults. // Config file path may be overridden with env `UPLOADSHIELD_CONFIG`. $CONFIG_FILE_DEFAULT = __DIR__ . '/uploadshield.json'; $CONFIG_FILE = getenv('UPLOADSHIELD_CONFIG') ?: $CONFIG_FILE_DEFAULT; // Default modules and settings $DEFAULT_CONFIG = [ 'modules' => [ 'flood' => true, 'filename' => true, 'mime_sniff' => true, 'hashing' => true, 'base64_detection' => true, 'raw_peek' => false, 'archive_inspect' => true, 'quarantine' => true, ], ]; $CONFIG_DATA = $DEFAULT_CONFIG; if (is_file($CONFIG_FILE)) { $rawCfg = @file_get_contents($CONFIG_FILE); $jsonCfg = @json_decode($rawCfg, true); if (is_array($jsonCfg)) { // Merge modules if present if (isset($jsonCfg['modules']) && is_array($jsonCfg['modules'])) { $CONFIG_DATA['modules'] = array_merge($CONFIG_DATA['modules'], $jsonCfg['modules']); } // Merge other top-level keys foreach ($jsonCfg as $k => $v) { if ($k === 'modules') continue; $CONFIG_DATA[$k] = $v; } } } $cfgLogFile = $CONFIG_DATA['paths']['log_file'] ?? null; if (is_string($cfgLogFile) && $cfgLogFile !== '') { $isAbs = preg_match('#^[A-Za-z]:[\/]#', $cfgLogFile) === 1 || (strlen($cfgLogFile) > 0 && ($cfgLogFile[0] === '/' || $cfgLogFile[0] === '\\')); if ($isAbs) { $logFile = $cfgLogFile; } else { $logFile = __DIR__ . '/' . ltrim($cfgLogFile, '/\\'); } } $BOOT_LOGGER = new \UploadLogger\Core\Services\LogService($logFile, []); $fileAllow = is_file($PEEK_ALLOW_FILE); $headerAllow = false; if (isset($_SERVER['HTTP_X_UPLOADSHIELD_PEEK']) && $_SERVER['HTTP_X_UPLOADSHIELD_PEEK'] === '1') { $clientIp = $REQ->getClientIp(); if (in_array($clientIp, $TRUSTED_PROXY_IPS, true)) { $headerAllow = true; } } if ($envAllow || $fileAllow || $headerAllow) { $PEEK_RAW_INPUT = true; $BOOT_LOGGER->logEvent('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 (isset($BOOT_LOGGER)) { $BOOT_LOGGER->logEvent('config_warning', [ 'msg' => 'quarantine_dir_perms_not_strict', 'path' => $QUARANTINE_DIR, 'perms_octal' => sprintf('%o', $mask), ]); } } } } else { if (isset($BOOT_LOGGER)) { $BOOT_LOGGER->logEvent('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)) { if (isset($BOOT_LOGGER)) { $BOOT_LOGGER->logEvent('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 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 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; } /* ---------- Context ---------- */ [$ip, $uri, $method, $ctype, $clen, $ua, $te] = $REQ->getRequestSummary($LOG_USER_AGENT); $userId = $REQ->getUserId(); $requestId = $REQ->generateRequestId(); $REQUEST_CTX = [ 'request_id' => $requestId, 'ip' => $ip, 'uri' => $uri, 'method' => $method, 'ctype' => $ctype, 'clen' => (int)$clen, 'user' => $userId, 'ua' => $ua, 'transfer_encoding' => $te, ]; // Logger instance for structured JSON output $CONFIG = new \UploadLogger\Core\Config($CONFIG_DATA); $LOGGER = new \UploadLogger\Core\Logger($logFile, $REQUEST_CTX, $CONFIG); /* * Map frequently-used legacy globals to values from `Config` so the rest of * the procedural helpers can continue to reference globals but operators * may control behavior via `uploadshield.json`. */ $BLOCK_SUSPICIOUS = $CONFIG->get('ops.block_suspicious', $BLOCK_SUSPICIOUS ?? false); $MAX_SIZE = (int)$CONFIG->get('limits.max_size', $MAX_SIZE ?? (50 * 1024 * 1024)); $RAW_BODY_MIN = (int)$CONFIG->get('limits.raw_body_min', $RAW_BODY_MIN ?? (500 * 1024)); $FLOOD_WINDOW_SEC = (int)$CONFIG->get('limits.flood_window_sec', $FLOOD_WINDOW_SEC ?? 60); $FLOOD_MAX_UPLOADS = (int)$CONFIG->get('limits.flood_max_uploads', $FLOOD_MAX_UPLOADS ?? 40); $SNIFF_MAX_BYTES = (int)$CONFIG->get('limits.sniff_max_bytes', $SNIFF_MAX_BYTES ?? 8192); $SNIFF_MAX_FILESIZE = (int)$CONFIG->get('limits.sniff_max_filesize', $SNIFF_MAX_FILESIZE ?? (2 * 1024 * 1024)); $LOG_USER_AGENT = (bool)$CONFIG->get('ops.log_user_agent', $LOG_USER_AGENT ?? true); // Determine whether peeking into php://input may be used (module + runtime allow) $PEEK_RAW_INPUT = ($CONFIG->isModuleEnabled('raw_peek') || ($PEEK_RAW_INPUT ?? false)) ? ($PEEK_RAW_INPUT ?? false) : ($PEEK_RAW_INPUT ?? false); $TRUSTED_PROXY_IPS = $CONFIG->get('ops.trusted_proxy_ips', $TRUSTED_PROXY_IPS ?? ['127.0.0.1', '::1']); $ALLOWLIST_FILE = $CONFIG->get('paths.allowlist_file', $ALLOWLIST_FILE ?? (__DIR__ . '/allowlist.json')); $STATE_DIR = $CONFIG->get('paths.state_dir', $STATE_DIR ?? (__DIR__ . '/state')); $HASH_MAX_FILESIZE = (int)$CONFIG->get('limits.hash_max_filesize', $HASH_MAX_FILESIZE ?? (10 * 1024 * 1024)); $QUARANTINE_ENABLED = $CONFIG->isModuleEnabled('quarantine') && ($CONFIG->get('ops.quarantine_enabled', $QUARANTINE_ENABLED ?? true)); $QUARANTINE_DIR = $CONFIG->get('paths.quarantine_dir', $QUARANTINE_DIR ?? (__DIR__ . '/quarantine')); $ARCHIVE_INSPECT = $CONFIG->isModuleEnabled('archive_inspect') || ($ARCHIVE_INSPECT ?? false); $ARCHIVE_BLOCK_ON_SUSPICIOUS = (bool)$CONFIG->get('ops.archive_block_on_suspicious', $ARCHIVE_BLOCK_ON_SUSPICIOUS ?? false); $ARCHIVE_MAX_ENTRIES = (int)$CONFIG->get('limits.archive_max_entries', $ARCHIVE_MAX_ENTRIES ?? 200); $ARCHIVE_MAX_INSPECT_SIZE = (int)$CONFIG->get('limits.archive_max_inspect_size', $ARCHIVE_MAX_INSPECT_SIZE ?? (50 * 1024 * 1024)); // Detector context and registry $CONTEXT = new \UploadLogger\Core\Context( $requestId, $ip, $uri, $method, $ctype, (int)$clen, $userId, $ua, $te ); $DETECTORS = [ new \UploadLogger\Detectors\FilenameDetector(), new \UploadLogger\Detectors\MimeDetector(), new \UploadLogger\Detectors\ContentDetector($CONFIG), ]; // Dispatch request processing $FLOOD_SERVICE = new \UploadLogger\Core\Services\FloodService($CONFIG); $SNIFFER_SERVICE = new \UploadLogger\Core\Services\SnifferService($CONFIG); $HASH_SERVICE = new \UploadLogger\Core\Services\HashService($CONFIG); $QUARANTINE_SERVICE = new \UploadLogger\Core\Services\QuarantineService($CONFIG); $DISPATCHER = new \UploadLogger\Core\Dispatcher($LOGGER, $CONTEXT, $DETECTORS, $CONFIG, $FLOOD_SERVICE, $SNIFFER_SERVICE, $HASH_SERVICE, $QUARANTINE_SERVICE); $DISPATCHER->dispatch($_FILES, $_SERVER);