Harden quarantine provisioning; enforce strict permissions and update Ansible and docs
This commit is contained in:
340
core/Dispatcher.php
Normal file
340
core/Dispatcher.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace UploadLogger\Core;
|
||||
|
||||
use UploadLogger\Core\Context;
|
||||
use UploadLogger\Core\DetectorInterface;
|
||||
use UploadLogger\Core\Logger;
|
||||
use UploadLogger\Core\Services\FloodService;
|
||||
use UploadLogger\Core\Services\SnifferService;
|
||||
use UploadLogger\Core\Services\HashService;
|
||||
use UploadLogger\Core\Services\QuarantineService;
|
||||
|
||||
/**
|
||||
* Dispatches request handling, detector execution, and logging.
|
||||
*/
|
||||
final class Dispatcher
|
||||
{
|
||||
private Logger $logger;
|
||||
private Context $context;
|
||||
private ?Config $config = null;
|
||||
|
||||
/** @var DetectorInterface[] */
|
||||
private array $detectors;
|
||||
|
||||
private ?FloodService $floodService = null;
|
||||
private ?SnifferService $snifferService = null;
|
||||
private ?HashService $hashService = null;
|
||||
private ?QuarantineService $quarantineService = null;
|
||||
|
||||
/**
|
||||
* @param DetectorInterface[] $detectors
|
||||
*/
|
||||
public function __construct(Logger $logger, Context $context, array $detectors = [], ?Config $config = null, ?FloodService $floodService = null, ?SnifferService $snifferService = null, ?HashService $hashService = null, ?QuarantineService $quarantineService = null)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
$this->context = $context;
|
||||
$this->detectors = $detectors;
|
||||
$this->config = $config;
|
||||
$this->floodService = $floodService;
|
||||
$this->snifferService = $snifferService;
|
||||
$this->hashService = $hashService;
|
||||
$this->quarantineService = $quarantineService;
|
||||
}
|
||||
|
||||
private function isModuleEnabled(string $name): bool
|
||||
{
|
||||
if ($this->config instanceof Config) {
|
||||
return $this->config->isModuleEnabled($name);
|
||||
}
|
||||
|
||||
// Enforce config-only behavior: no config supplied => module disabled
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $files
|
||||
* @param array<string, mixed> $server
|
||||
*/
|
||||
public function dispatch(array $files, array $server): void
|
||||
{
|
||||
$method = $this->context->getMethod();
|
||||
if (!in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ctype = $this->context->getContentType();
|
||||
$clen = $this->context->getContentLength();
|
||||
$te = $this->context->getTransferEncoding();
|
||||
|
||||
// Raw-body uploads with no multipart
|
||||
if (empty($files)) {
|
||||
$this->handleRawBody($ctype, $clen, $te);
|
||||
}
|
||||
|
||||
// multipart/form-data but no $_FILES
|
||||
if (empty($files) && $ctype && stripos($ctype, 'multipart/form-data') !== false) {
|
||||
$this->logger->logEvent('multipart_no_files', []);
|
||||
}
|
||||
|
||||
if (empty($files)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per request flood check
|
||||
if ($this->isModuleEnabled('flood') && $this->floodService !== null) {
|
||||
$reqCount = $this->floodService->check($this->context->getIp());
|
||||
$floodMax = 40;
|
||||
if ($this->config instanceof Config) {
|
||||
$floodMax = (int)$this->config->get('limits.flood_max_uploads', $floodMax);
|
||||
}
|
||||
if ($reqCount > $floodMax) {
|
||||
$this->logger->logEvent('flood_alert', ['count' => (int)$reqCount]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!isset($file['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi upload field: name[]
|
||||
if (is_array($file['name'])) {
|
||||
$count = count($file['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->handleFileEntry(
|
||||
(string)($file['name'][$i] ?? ''),
|
||||
(string)($file['type'][$i] ?? ''),
|
||||
(int)($file['size'][$i] ?? 0),
|
||||
(string)($file['tmp_name'][$i] ?? ''),
|
||||
(int)($file['error'][$i] ?? UPLOAD_ERR_NO_FILE)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$this->handleFileEntry(
|
||||
(string)$file['name'],
|
||||
(string)($file['type'] ?? ''),
|
||||
(int)($file['size'] ?? 0),
|
||||
(string)($file['tmp_name'] ?? ''),
|
||||
(int)($file['error'] ?? UPLOAD_ERR_NO_FILE)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleRawBody(string $ctype, int $clen, string $te): void
|
||||
{
|
||||
global $RAW_BODY_MIN, $PEEK_RAW_INPUT, $SNIFF_MAX_FILESIZE, $SNIFF_MAX_BYTES, $BASE64_FINGERPRINT_BYTES;
|
||||
|
||||
$rawSuspicious = false;
|
||||
if ($clen >= $RAW_BODY_MIN) $rawSuspicious = true;
|
||||
if ($te !== '') $rawSuspicious = true;
|
||||
if (stripos($ctype, 'application/octet-stream') !== false) $rawSuspicious = true;
|
||||
if (stripos($ctype, 'application/json') !== false) $rawSuspicious = true;
|
||||
|
||||
// Guarded peek into php://input for JSON/base64 payload detection.
|
||||
if ($this->isModuleEnabled('raw_peek') && $PEEK_RAW_INPUT && $clen > 0 && $clen <= $SNIFF_MAX_FILESIZE) {
|
||||
$peek = '';
|
||||
$in = @fopen('php://input', 'r');
|
||||
if ($in !== false) {
|
||||
$peek = @stream_get_contents($in, $SNIFF_MAX_BYTES);
|
||||
@fclose($in);
|
||||
}
|
||||
|
||||
if ($peek !== false && $peek !== '') {
|
||||
$b = $this->isModuleEnabled('base64_detection') && $this->snifferService !== null ? $this->snifferService->detectJsonBase64Head($peek, 1024) : ['found' => false, 'decoded_head' => null, 'reason' => null];
|
||||
if (!empty($b['found'])) {
|
||||
if ($this->snifferService !== null && $this->snifferService->base64IsAllowlisted($this->context->getUri(), $ctype)) {
|
||||
$this->logger->logEvent('raw_body_base64_ignored', ['uri' => $this->context->getUri(), 'ctype' => $ctype]);
|
||||
} else {
|
||||
$fingerprints = [];
|
||||
if (!empty($b['decoded_head'])) {
|
||||
$decodedHead = $b['decoded_head'];
|
||||
$sample = substr($decodedHead, 0, $BASE64_FINGERPRINT_BYTES);
|
||||
$fingerprints['sha1'] = @sha1($sample);
|
||||
$fingerprints['md5'] = @md5($sample);
|
||||
if ($this->snifferService !== null && $this->snifferService->payloadContainsPhpMarkers($decodedHead, $ctype)) {
|
||||
$rawSuspicious = true;
|
||||
$this->logger->logEvent('raw_body_php_payload', [
|
||||
'len' => (int)$clen,
|
||||
'ctype' => $ctype,
|
||||
'reason' => $b['reason'] ?? 'base64_embedded',
|
||||
'fingerprints' => $fingerprints,
|
||||
]);
|
||||
} else {
|
||||
$this->logger->logEvent('raw_body_base64', [
|
||||
'len' => (int)$clen,
|
||||
'ctype' => $ctype,
|
||||
'reason' => $b['reason'] ?? 'base64_embedded',
|
||||
'fingerprints' => $fingerprints,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$this->logger->logEvent('raw_body_base64', [
|
||||
'len' => (int)$clen,
|
||||
'ctype' => $ctype,
|
||||
'reason' => $b['reason'] ?? 'base64_embedded',
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($this->snifferService !== null && $this->snifferService->payloadContainsPhpMarkers($peek, $ctype)) {
|
||||
$this->logger->logEvent('raw_body_php_payload', [
|
||||
'len' => (int)$clen,
|
||||
'ctype' => $ctype,
|
||||
'reason' => 'head_php_markers',
|
||||
]);
|
||||
$rawSuspicious = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rawSuspicious) {
|
||||
$this->logger->logEvent('raw_body', [
|
||||
'len' => (int)$clen,
|
||||
'ctype' => $ctype,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleFileEntry(string $name, string $type, int $size, string $tmp, int $err): void
|
||||
{
|
||||
global $BLOCK_SUSPICIOUS, $MAX_SIZE, $FLOOD_MAX_UPLOADS;
|
||||
global $QUARANTINE_ENABLED, $ARCHIVE_INSPECT, $ARCHIVE_BLOCK_ON_SUSPICIOUS;
|
||||
|
||||
if ($err !== UPLOAD_ERR_OK) {
|
||||
$this->logger->logEvent('upload_error', [
|
||||
'name' => $name,
|
||||
'err' => (int)$err,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$origName = (string)$name;
|
||||
$name = basename($origName);
|
||||
$type = (string)$type;
|
||||
$size = (int)$size;
|
||||
$tmp = (string)$tmp;
|
||||
|
||||
// Flood count per file (stronger)
|
||||
if ($this->isModuleEnabled('flood') && $this->floodService !== null) {
|
||||
$count = $this->floodService->check($this->context->getIp());
|
||||
if ($count > $FLOOD_MAX_UPLOADS) {
|
||||
$this->logger->logEvent('flood_alert', ['count' => (int)$count]);
|
||||
}
|
||||
}
|
||||
|
||||
$real = $this->snifferService !== null ? $this->snifferService->detectRealMime($tmp) : 'unknown';
|
||||
|
||||
// If client-provided MIME `type` is empty, fall back to detected real MIME
|
||||
if ($type === '' || $type === 'unknown') {
|
||||
if (!empty($real) && $real !== 'unknown') {
|
||||
$type = $real;
|
||||
}
|
||||
}
|
||||
|
||||
$suspicious = false;
|
||||
$reasons = [];
|
||||
|
||||
if ($origName !== $name || strpos($origName, '/') !== false || strpos($origName, '\\') !== false) {
|
||||
$suspicious = true;
|
||||
$reasons[] = 'bad_name';
|
||||
}
|
||||
|
||||
foreach ($this->detectors as $detector) {
|
||||
$detectorName = $detector->getName();
|
||||
if (!$this->isModuleEnabled($detectorName)) {
|
||||
continue;
|
||||
}
|
||||
$result = $detector->detect($this->context, [
|
||||
'name' => $name,
|
||||
'orig_name' => $origName,
|
||||
'real_mime' => $real,
|
||||
'type' => $type,
|
||||
'tmp' => $tmp,
|
||||
'size' => $size,
|
||||
]);
|
||||
if (!empty($result['suspicious'])) {
|
||||
$suspicious = true;
|
||||
if (!empty($result['reasons']) && is_array($result['reasons'])) {
|
||||
$reasons = array_merge($reasons, $result['reasons']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hash before any quarantine move
|
||||
$hashes = $this->isModuleEnabled('hashing') && $this->hashService !== null ? $this->hashService->computeHashes($tmp, $size) : [];
|
||||
|
||||
// Content sniffing for PHP payload (fast head scan, only for small files)
|
||||
if ($this->isModuleEnabled('mime_sniff') && $this->snifferService !== null && $this->snifferService->sniffFileForPhpPayload($tmp)) {
|
||||
$suspicious = true;
|
||||
$reasons[] = 'php_payload';
|
||||
}
|
||||
|
||||
// Very large file
|
||||
if ($size > $MAX_SIZE) {
|
||||
$this->logger->logEvent('big_upload', [
|
||||
'name' => $name,
|
||||
'size' => (int)$size,
|
||||
]);
|
||||
$reasons[] = 'big_file';
|
||||
}
|
||||
|
||||
// Archive uploads are higher risk (often used to smuggle payloads)
|
||||
if ($this->snifferService !== null && $this->snifferService->isArchive($name, $real)) {
|
||||
$reasons[] = 'archive';
|
||||
$this->logger->logEvent('archive_upload', [
|
||||
'name' => $name,
|
||||
'real_mime' => $real,
|
||||
]);
|
||||
|
||||
if ($QUARANTINE_ENABLED && $this->isModuleEnabled('quarantine')) {
|
||||
$qres = $this->quarantineService !== null ? $this->quarantineService->quarantineFile($tmp, $origName, $hashes) : ['ok' => false, 'path' => ''];
|
||||
if ($qres['ok']) {
|
||||
$qpath = $qres['path'];
|
||||
$this->logger->logEvent('archive_quarantined', ['path' => $qpath]);
|
||||
|
||||
if ($this->isModuleEnabled('archive_inspect') && $ARCHIVE_INSPECT) {
|
||||
$inspect = $this->quarantineService !== null ? $this->quarantineService->inspectArchiveQuarantine($qpath) : ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => true];
|
||||
$this->logger->logEvent('archive_inspect', ['path' => $qpath, 'summary' => $inspect]);
|
||||
if (!empty($inspect['suspicious_entries'])) {
|
||||
$suspicious = true;
|
||||
$reasons[] = 'archive_contains_suspicious';
|
||||
if ($ARCHIVE_BLOCK_ON_SUSPICIOUS && $BLOCK_SUSPICIOUS) {
|
||||
http_response_code(403);
|
||||
exit('Upload blocked - suspicious archive');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->logger->logEvent('archive_quarantine_failed', ['tmp' => $tmp, 'dest' => $qres['path']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->logEvent('upload', [
|
||||
'name' => $name,
|
||||
'orig_name' => $origName,
|
||||
'size' => (int)$size,
|
||||
'type' => $type,
|
||||
'real_mime' => $real,
|
||||
'tmp' => $tmp,
|
||||
'hashes' => $hashes,
|
||||
'flags' => $reasons,
|
||||
]);
|
||||
|
||||
if ($suspicious) {
|
||||
$this->logger->logEvent('suspicious_upload', [
|
||||
'name' => $name,
|
||||
'reasons' => $reasons,
|
||||
]);
|
||||
if ($BLOCK_SUSPICIOUS) {
|
||||
http_response_code(403);
|
||||
exit('Upload blocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user