Harden quarantine provisioning; enforce strict permissions and update Ansible and docs

This commit is contained in:
2026-02-12 07:47:48 +01:00
parent 037b176892
commit 1768f61da1
44 changed files with 2587 additions and 698 deletions

View 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;
}
}