config = $config ?? new Config(['modules' => []]); } public function detectRealMime(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; } public function payloadContainsPhpMarkers(string $text, string $contentType = ''): bool { $isXmlLike = false; if ($contentType !== '') { $isXmlLike = (bool)preg_match('/xml|svg/i', $contentType); } if (preg_match('/<\?php|<\?=|<\?(?!xml)/i', $text)) { return true; } if (preg_match('/base64_decode\s*\(|gzinflate\s*\(|shell_exec\s*\(|passthru\s*\(|system\s*\(|proc_open\s*\(|popen\s*\(|exec\s*\(/i', $text)) { return true; } if (!$isXmlLike && preg_match('/\beval\s*\(/i', $text)) { return true; } return false; } public function sniffFileForPhpPayload(string $tmpPath): bool { $maxBytes = (int)$this->config->get('limits.sniff_max_bytes', 8192); $maxFilesize = (int)$this->config->get('limits.sniff_max_filesize', 2 * 1024 * 1024); if (!is_uploaded_file($tmpPath)) return false; $sz = @filesize($tmpPath); if ($sz === false) return false; if ($sz <= 0) return false; if ($sz > $maxFilesize) return false; $bytes = min($maxBytes, $sz); $maxlen = $bytes > 0 ? $bytes : null; $head = @file_get_contents($tmpPath, false, null, 0, $maxlen); if ($head === false) return false; $realMime = $this->detectRealMime($tmpPath); if ($this->payloadContainsPhpMarkers($head, $realMime)) { return true; } return false; } /** * @param string $head * @param int $maxDecoded * @return array{found:bool,decoded_head:?string,reason:?string} */ public function detectJsonBase64Head(string $head, int $maxDecoded = 1024): array { if (preg_match('/"(?:file|data|payload|content)"\s*:\s*"(?:data:[^,]+,)?([A-Za-z0-9+\/=]{200,})"/i', $head, $m)) { $b64 = $m[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']; $decoded_head = substr($decoded, 0, $maxDecoded); return ['found' => true, 'decoded_head' => $decoded_head, 'reason' => null]; } 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]; } public function base64IsAllowlisted(string $uri, string $ctype): bool { $uris = (array)$this->config->get('allowlists.base64_uris', []); $ctypes = (array)$this->config->get('allowlists.ctypes', []); if (!empty($uris)) { foreach ($uris as $p) { if (strlen($p) > 1 && $p[0] === '#' && substr($p, -1) === '#') { if (@preg_match($p, $uri)) return true; } else { if (strpos($uri, $p) !== false) return true; } } } if (!empty($ctypes) && $ctype !== '') { $base = explode(';', $ctype, 2)[0]; foreach ($ctypes as $ct) { if (strtolower(trim($ct)) === strtolower(trim($base))) return true; } } return false; } public function isFakeImage(string $name, string $realMime): bool { if (preg_match('/\.(png|jpe?g|gif|webp|bmp|ico|svg)$/i', $name)) { if (!preg_match('/^image\//', $realMime)) { return true; } } return false; } public function isArchive(string $name, string $realMime): bool { 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; } }