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,58 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class FloodService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
public function check(string $ip): int
{
$window = (int)$this->config->get('limits.flood_window_sec', 60);
$stateDir = (string)$this->config->get('paths.state_dir', __DIR__ . '/../../state');
$key = rtrim($stateDir, '/\\') . '/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) > $window)) {
$start = $now;
$count = 0;
}
$count++;
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, $start . ':' . $count);
fflush($fh);
flock($fh, LOCK_UN);
}
fclose($fh);
return $count;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class HashService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
/**
* @param string $tmpPath
* @param int $size
* @return array<string,string>
*/
public function computeHashes(string $tmpPath, int $size): array
{
$max = (int)$this->config->get('limits.hash_max_filesize', 10 * 1024 * 1024);
if (!is_uploaded_file($tmpPath)) return [];
if ($size <= 0 || $size > $max) 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;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
final class LogService
{
private string $logFile;
/** @var array<string,mixed> */
private array $ctx;
/**
* @param string $logFile
* @param array<string,mixed> $ctx
*/
public function __construct(string $logFile, array $ctx = [])
{
$this->logFile = $logFile;
$this->ctx = $ctx;
}
/**
* @param string $event
* @param array<string,mixed> $data
*/
public function logEvent(string $event, array $data = []): void
{
$payload = array_merge(['ts' => gmdate('c'), 'event' => $event], $this->ctx, $data);
$payload = $this->normalize($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($this->logFile, $json . "\n", FILE_APPEND | LOCK_EX);
}
private function normalize(mixed $value): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = $this->normalize($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);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class QuarantineService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
/**
* @param string $tmpPath
* @param string $origName
* @param array<string,string> $hashes
* @return array<string,mixed>
*/
public function quarantineFile(string $tmpPath, string $origName, array $hashes): array
{
$enabled = $this->config->isModuleEnabled('quarantine') && (bool)$this->config->get('ops.quarantine_enabled', true);
$quarantineDir = (string)$this->config->get('paths.quarantine_dir', __DIR__ . '/../../quarantine');
if (!$enabled) return ['ok' => false, 'path' => ''];
if (!is_uploaded_file($tmpPath)) return ['ok' => false, 'path' => ''];
if (!is_dir($quarantineDir)) 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($quarantineDir, '/\\') . '/' . $base . ($ext ? '.' . $ext : '');
$ok = @move_uploaded_file($tmpPath, $dest);
if ($ok) {
@chmod($dest, 0600);
return ['ok' => true, 'path' => $dest];
}
return ['ok' => false, 'path' => $dest];
}
/**
* @param string $path
* @return array<string,mixed>
*/
public function inspectArchiveQuarantine(string $path): array
{
$maxEntries = (int)$this->config->get('limits.archive_max_entries', 200);
$maxInspectSize = (int)$this->config->get('limits.archive_max_inspect_size', 50 * 1024 * 1024);
$fsz = @filesize($path);
if ($fsz !== false && $fsz > $maxInspectSize) {
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);
if (class_exists('ZipArchive') && preg_match('/\.zip$/i', $lower)) {
$za = new \ZipArchive();
if ($za->open($path) === true) {
$cnt = $za->numFiles;
$out['entries'] = min($cnt, $maxEntries);
$limit = $out['entries'];
for ($i = 0; $i < $limit; $i++) {
$stat = $za->statIndex($i);
if (is_array($stat)) {
$name = $stat['name'];
$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;
}
}
$za->close();
} else {
$out['unsupported'] = true;
}
return $out;
}
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++ >= $maxEntries) 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;
}
$out['unsupported'] = true;
return $out;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
final class RequestService
{
public function uploadClean(string $str): string
{
return str_replace(["\n", "\r", "\t"], '_', (string)$str);
}
public function normalizeValue(mixed $value): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = $this->normalizeValue($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);
}
public function generateRequestId(): string
{
try {
return bin2hex(random_bytes(8));
} catch (\Throwable $e) {
return uniqid('req', true);
}
}
public function getClientIp(): string
{
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
public function getUserId(): string
{
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';
}
/**
* @return array{0:string,1:string,2:string,3:string,4:int,5:string,6:string}
*/
public function getRequestSummary(bool $logUserAgent = true): array
{
$ip = $this->getClientIp();
$uri = $_SERVER['REQUEST_URI'] ?? 'unknown';
$method = $_SERVER['REQUEST_METHOD'] ?? 'unknown';
$ctype = $_SERVER['CONTENT_TYPE'] ?? '';
$clen = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
$ua = $logUserAgent ? ($_SERVER['HTTP_USER_AGENT'] ?? '') : '';
$te = $_SERVER['HTTP_TRANSFER_ENCODING'] ?? '';
return [$ip, $uri, $method, $ctype, $clen, $ua, $te];
}
}

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