Harden quarantine provisioning; enforce strict permissions and update Ansible and docs
This commit is contained in:
153
core/Services/SnifferService.php
Normal file
153
core/Services/SnifferService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace UploadLogger\Core\Services;
|
||||
|
||||
use UploadLogger\Core\Config;
|
||||
|
||||
final class SnifferService
|
||||
{
|
||||
private Config $config;
|
||||
|
||||
public function __construct(?Config $config = null)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user