Harden quarantine provisioning; enforce strict permissions and update Ansible and docs
This commit is contained in:
64
core/Config.php
Normal file
64
core/Config.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace UploadLogger\Core;
|
||||
|
||||
/**
|
||||
* Simple immutable configuration holder for the upload logger.
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
private array $data;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a module is enabled.
|
||||
*/
|
||||
public function isModuleEnabled(string $name): bool
|
||||
{
|
||||
$modules = $this->data['modules'] ?? [];
|
||||
if (!is_array($modules)) return false;
|
||||
return !empty($modules[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value with optional default.
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
// Support simple dot-notation for nested keys, e.g. "limits.max_size"
|
||||
if (strpos($key, '.') === false) {
|
||||
return $this->data[$key] ?? $default;
|
||||
}
|
||||
|
||||
$parts = explode('.', $key);
|
||||
$cur = $this->data;
|
||||
foreach ($parts as $p) {
|
||||
if (!is_array($cur) || !array_key_exists($p, $cur)) {
|
||||
return $default;
|
||||
}
|
||||
$cur = $cur[$p];
|
||||
}
|
||||
return $cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the raw config array.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
}
|
||||
123
core/Context.php
Normal file
123
core/Context.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace UploadLogger\Core;
|
||||
|
||||
/**
|
||||
* Immutable request context used by detectors and loggers.
|
||||
*/
|
||||
final class Context
|
||||
{
|
||||
private string $requestId;
|
||||
private string $ip;
|
||||
private string $uri;
|
||||
private string $method;
|
||||
private string $contentType;
|
||||
private int $contentLength;
|
||||
private string $user;
|
||||
private string $userAgent;
|
||||
private string $transferEncoding;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $extra;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
public function __construct(
|
||||
string $requestId,
|
||||
string $ip,
|
||||
string $uri,
|
||||
string $method,
|
||||
string $contentType,
|
||||
int $contentLength,
|
||||
string $user,
|
||||
string $userAgent,
|
||||
string $transferEncoding,
|
||||
array $extra = []
|
||||
) {
|
||||
$this->requestId = $requestId;
|
||||
$this->ip = $ip;
|
||||
$this->uri = $uri;
|
||||
$this->method = $method;
|
||||
$this->contentType = $contentType;
|
||||
$this->contentLength = $contentLength;
|
||||
$this->user = $user;
|
||||
$this->userAgent = $userAgent;
|
||||
$this->transferEncoding = $transferEncoding;
|
||||
$this->extra = $extra;
|
||||
}
|
||||
|
||||
public function getRequestId(): string
|
||||
{
|
||||
return $this->requestId;
|
||||
}
|
||||
|
||||
public function getIp(): string
|
||||
{
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
public function getUri(): string
|
||||
{
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getContentType(): string
|
||||
{
|
||||
return $this->contentType;
|
||||
}
|
||||
|
||||
public function getContentLength(): int
|
||||
{
|
||||
return $this->contentLength;
|
||||
}
|
||||
|
||||
public function getUser(): string
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function getTransferEncoding(): string
|
||||
{
|
||||
return $this->transferEncoding;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getExtra(): array
|
||||
{
|
||||
return $this->extra;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'request_id' => $this->requestId,
|
||||
'ip' => $this->ip,
|
||||
'uri' => $this->uri,
|
||||
'method' => $this->method,
|
||||
'ctype' => $this->contentType,
|
||||
'clen' => $this->contentLength,
|
||||
'user' => $this->user,
|
||||
'ua' => $this->userAgent,
|
||||
'transfer_encoding' => $this->transferEncoding,
|
||||
'extra' => $this->extra,
|
||||
];
|
||||
}
|
||||
}
|
||||
19
core/DetectorInterface.php
Normal file
19
core/DetectorInterface.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace UploadLogger\Core;
|
||||
|
||||
/**
|
||||
* Detectors analyze requests and uploads and return findings.
|
||||
*/
|
||||
interface DetectorInterface
|
||||
{
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed> Structured detection output for logging.
|
||||
*/
|
||||
public function detect(Context $context, array $input = []): array;
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
core/Logger.php
Normal file
78
core/Logger.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace UploadLogger\Core;
|
||||
|
||||
/**
|
||||
* Structured JSON logger with request context.
|
||||
*/
|
||||
final class Logger
|
||||
{
|
||||
private string $logFile;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $context;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(string $logFile, array $context = [], ?Config $config = null)
|
||||
{
|
||||
$this->logFile = $logFile;
|
||||
$this->context = $context;
|
||||
// Keep optional config parameter for backward compatibility (unused here)
|
||||
unset($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function setContext(array $context): void
|
||||
{
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function logEvent(string $event, array $data = []): void
|
||||
{
|
||||
$payload = array_merge(
|
||||
['ts' => gmdate('c'), 'event' => $event],
|
||||
$this->context,
|
||||
$data
|
||||
);
|
||||
|
||||
$payload = $this->normalizeValue($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 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);
|
||||
}
|
||||
}
|
||||
58
core/Services/FloodService.php
Normal file
58
core/Services/FloodService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
core/Services/HashService.php
Normal file
37
core/Services/HashService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
core/Services/LogService.php
Normal file
60
core/Services/LogService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
136
core/Services/QuarantineService.php
Normal file
136
core/Services/QuarantineService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
core/Services/RequestService.php
Normal file
73
core/Services/RequestService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
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