Files
UploadShied/upload-logger.php

438 lines
16 KiB
PHP

<?php
/**
* Global Upload Logger (Hardened v3) — part of UploadShield
* Project: TheWallpapers
*
* Purpose:
* - Log ALL normal uploads via $_FILES (single + multi)
* - Detect common evasion (double extensions, fake images, path tricks, PHP payload in non-php files)
* - Log suspicious "raw body" uploads (php://input / octet-stream) that bypass $_FILES
* - Optional blocking mode
*
* Install:
* - Use as PHP-FPM pool `auto_prepend_file=.../upload_logger.php`
*
* Notes:
* - This cannot *guarantee* interception of every file-write exploit (file_put_contents, ZipArchive extract, etc.)
* but it catches most real-world upload vectors and provides strong forensic logging.
*/
// Ignore CLI
if (PHP_SAPI === 'cli') {
return;
}
// Core classes (modular detectors)
require_once __DIR__ . '/core/Context.php';
require_once __DIR__ . '/core/DetectorInterface.php';
require_once __DIR__ . '/core/Logger.php';
require_once __DIR__ . '/core/Dispatcher.php';
require_once __DIR__ . '/core/Config.php';
require_once __DIR__ . '/detectors/FilenameDetector.php';
require_once __DIR__ . '/detectors/MimeDetector.php';
require_once __DIR__ . '/detectors/ContentDetector.php';
require_once __DIR__ . '/core/Services/FloodService.php';
require_once __DIR__ . '/core/Services/SnifferService.php';
require_once __DIR__ . '/core/Services/HashService.php';
require_once __DIR__ . '/core/Services/QuarantineService.php';
require_once __DIR__ . '/core/Services/RequestService.php';
require_once __DIR__ . '/core/Services/LogService.php';
$REQ = new \UploadLogger\Core\Services\RequestService();
/* ================= CONFIG ================= */
// Log file path (prefer per-vhost path outside webroot if possible)
$logFile = __DIR__ . '/logs/uploads.log';
// Block suspicious uploads (true = block request, false = log only)
$BLOCK_SUSPICIOUS = false;
// Warn if file > 50MB
$MAX_SIZE = 50 * 1024 * 1024;
// Treat payload > 500KB with no $_FILES as suspicious "raw upload"
$RAW_BODY_MIN = 500 * 1024;
// Flood detection (per-IP uploads per window)
$FLOOD_WINDOW_SEC = 60;
$FLOOD_MAX_UPLOADS = 40;
// Content sniffing: scan first N bytes for PHP/shell patterns (keep small for performance)
$SNIFF_MAX_BYTES = 8192; // 8KB
$SNIFF_MAX_FILESIZE = 2 * 1024 * 1024; // only sniff files up to 2MB
// If true, also log request headers that are useful in forensics (careful with privacy)
$LOG_USER_AGENT = true;
// Whether the logger may peek into php://input for a small head scan.
// WARNING: reading php://input can consume the request body for the application.
// Keep this false unless you accept the risk or run behind a proxy that buffers request bodies.
$PEEK_RAW_INPUT = false;
// Trusted proxy IPs that may set a header to indicate the request body was buffered
$TRUSTED_PROXY_IPS = ['127.0.0.1', '::1'];
// Environment variable name or marker file to explicitly allow peeking
$ALLOW_PEEK_ENV = 'UPLOAD_LOGGER_ALLOW_PEEK';
$PEEK_ALLOW_FILE = __DIR__ . '/.upload_logger_allow_peek';
// Auto-enable peek only when explicitly allowed by environment/file or when a
// trusted frontend indicates the body was buffered via header `X-Upload-Logger-Peek: 1`.
// This avoids consuming request bodies unexpectedly.
try {
$envAllow = getenv($ALLOW_PEEK_ENV) === '1';
} catch (Throwable $e) {
$envAllow = false;
}
// Base64/JSON detection thresholds
$BASE64_MIN_CHARS = 200; // minimum base64 chars to consider a blob
$BASE64_DECODE_CHUNK = 1024; // how many base64 chars to decode for fingerprinting
$BASE64_FINGERPRINT_BYTES = 128; // bytes of decoded head to hash for fingerprint
// Allowlist for known benign base64 sources. Patterns can be simple substrings
// (checked with `strpos`) or PCRE regex when wrapped with '#', e.g. '#^/internal/webhook#'.
// Default in-code allowlist (used if no allowlist file is present)
$BASE64_ALLOWLIST_URI = [
'/api/uploads/avatars',
'/api/v1/avatars',
'/user/avatar',
'/media/upload',
'/api/media',
'/api/uploads',
'/api/v1/uploads',
'/attachments/upload',
'/upload',
'#^/internal/webhook#',
'#/hooks/(github|gitlab|stripe|slack)#',
'/services/avatars',
'/api/profile/photo'
];
// Optional allowlist of content-types (exact match, without params)
$BASE64_ALLOWLIST_CTYPE = [];
// Allowlist file location and environment override
$ALLOWLIST_FILE_DEFAULT = __DIR__ . '/allowlist.json';
$ALLOWLIST_FILE = getenv('UPLOAD_LOGGER_ALLOWLIST') ?: $ALLOWLIST_FILE_DEFAULT;
if (is_file($ALLOWLIST_FILE)) {
$raw = @file_get_contents($ALLOWLIST_FILE);
$json = @json_decode($raw, true);
if (is_array($json)) {
if (!empty($json['uris']) && is_array($json['uris'])) {
$BASE64_ALLOWLIST_URI = $json['uris'];
}
if (!empty($json['ctypes']) && is_array($json['ctypes'])) {
$BASE64_ALLOWLIST_CTYPE = $json['ctypes'];
}
}
}
// Load config (JSON) or fall back to inline defaults.
// Config file path may be overridden with env `UPLOAD_LOGGER_CONFIG`.
$CONFIG_FILE_DEFAULT = __DIR__ . '/upload-logger.json';
$CONFIG_FILE = getenv('UPLOAD_LOGGER_CONFIG') ?: $CONFIG_FILE_DEFAULT;
// Default modules and settings
$DEFAULT_CONFIG = [
'modules' => [
'flood' => true,
'filename' => true,
'mime_sniff' => true,
'hashing' => true,
'base64_detection' => true,
'raw_peek' => false,
'archive_inspect' => true,
'quarantine' => true,
],
];
$CONFIG_DATA = $DEFAULT_CONFIG;
if (is_file($CONFIG_FILE)) {
$rawCfg = @file_get_contents($CONFIG_FILE);
$jsonCfg = @json_decode($rawCfg, true);
if (is_array($jsonCfg)) {
// Merge modules if present
if (isset($jsonCfg['modules']) && is_array($jsonCfg['modules'])) {
$CONFIG_DATA['modules'] = array_merge($CONFIG_DATA['modules'], $jsonCfg['modules']);
}
// Merge other top-level keys
foreach ($jsonCfg as $k => $v) {
if ($k === 'modules') continue;
$CONFIG_DATA[$k] = $v;
}
}
}
$cfgLogFile = $CONFIG_DATA['paths']['log_file'] ?? null;
if (is_string($cfgLogFile) && $cfgLogFile !== '') {
$isAbs = preg_match('#^[A-Za-z]:[\\/]#', $cfgLogFile) === 1
|| (strlen($cfgLogFile) > 0 && ($cfgLogFile[0] === '/' || $cfgLogFile[0] === '\\'));
if ($isAbs) {
$logFile = $cfgLogFile;
} else {
$logFile = __DIR__ . '/' . ltrim($cfgLogFile, '/\\');
}
}
$BOOT_LOGGER = new \UploadLogger\Core\Services\LogService($logFile, []);
$fileAllow = is_file($PEEK_ALLOW_FILE);
$headerAllow = false;
if (isset($_SERVER['HTTP_X_UPLOAD_LOGGER_PEEK']) && $_SERVER['HTTP_X_UPLOAD_LOGGER_PEEK'] === '1') {
$clientIp = $REQ->getClientIp();
if (in_array($clientIp, $TRUSTED_PROXY_IPS, true)) {
$headerAllow = true;
}
}
if ($envAllow || $fileAllow || $headerAllow) {
$PEEK_RAW_INPUT = true;
$BOOT_LOGGER->logEvent('config_info', ['msg' => 'peek_enabled', 'env' => $envAllow, 'file' => $fileAllow, 'header' => $headerAllow]);
}
// Store flood counters in a protected directory (avoid /tmp tampering)
$STATE_DIR = __DIR__ . '/state';
// Hash files up to this size for forensics
$HASH_MAX_FILESIZE = 10 * 1024 * 1024; // 10MB
// Quarantine suspicious uploads (move outside webroot, restrictive perms)
$QUARANTINE_ENABLED = true; // enabled by default for hardened deployments
$QUARANTINE_DIR = __DIR__ . '/quarantine';
// Archive inspection
$ARCHIVE_INSPECT = true; // inspect archives moved to quarantine
$ARCHIVE_BLOCK_ON_SUSPICIOUS = false; // optionally block request when archive contains suspicious entries
$ARCHIVE_MAX_ENTRIES = 200; // max entries to inspect in an archive
// Max archive file size to inspect (bytes). Larger archives will be skipped to avoid CPU/IO costs.
$ARCHIVE_MAX_INSPECT_SIZE = 50 * 1024 * 1024; // 50 MB
/* ========================================== */
// Ensure log dir
$logDir = dirname($logFile);
if (!is_dir($logDir)) {
@mkdir($logDir, 0750, true);
}
// Ensure state dir
if (!is_dir($STATE_DIR)) {
@mkdir($STATE_DIR, 0750, true);
}
// Ensure quarantine dir if enabled and enforce strict permissions
if ($QUARANTINE_ENABLED) {
if (!is_dir($QUARANTINE_DIR)) {
@mkdir($QUARANTINE_DIR, 0700, true);
}
if (is_dir($QUARANTINE_DIR)) {
// attempt to enforce strict permissions (owner only)
@chmod($QUARANTINE_DIR, 0700);
// verify perms: group/other bits must be zero
$perms = @fileperms($QUARANTINE_DIR);
if ($perms !== false) {
// mask to rwxrwxrwx (lower 9 bits)
$mask = $perms & 0x1FF;
// if any group/other bits set, warn
if (($mask & 0o077) !== 0) {
if (isset($BOOT_LOGGER)) {
$BOOT_LOGGER->logEvent('config_warning', [
'msg' => 'quarantine_dir_perms_not_strict',
'path' => $QUARANTINE_DIR,
'perms_octal' => sprintf('%o', $mask),
]);
}
}
}
} else {
if (isset($BOOT_LOGGER)) {
$BOOT_LOGGER->logEvent('config_error', ['msg' => 'quarantine_dir_missing', 'path' => $QUARANTINE_DIR]);
}
}
}
// Attempt to enforce owner:group for quarantine directory when possible
$DESIRED_QUARANTINE_OWNER = 'root';
$DESIRED_QUARANTINE_GROUP = 'www-data';
if ($QUARANTINE_ENABLED && is_dir($QUARANTINE_DIR)) {
// If running as root, attempt to chown/chgrp to desired values
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
@chown($QUARANTINE_DIR, $DESIRED_QUARANTINE_OWNER);
@chgrp($QUARANTINE_DIR, $DESIRED_QUARANTINE_GROUP);
} elseif (function_exists('posix_getegid') && function_exists('posix_getgrgid')) {
// Not root: try at least to set group to the process group
$egid = posix_getegid();
$gr = posix_getgrgid($egid);
if ($gr && isset($gr['name'])) {
@chgrp($QUARANTINE_DIR, $gr['name']);
}
}
// Verify owner/group and log if not matching desired values
$ownerOk = false;
$groupOk = false;
$statUid = @fileowner($QUARANTINE_DIR);
$statGid = @filegroup($QUARANTINE_DIR);
if ($statUid !== false && function_exists('posix_getpwuid')) {
$pw = posix_getpwuid($statUid);
if ($pw && isset($pw['name']) && $pw['name'] === $DESIRED_QUARANTINE_OWNER) {
$ownerOk = true;
}
}
if ($statGid !== false && function_exists('posix_getgrgid')) {
$gg = posix_getgrgid($statGid);
if ($gg && isset($gg['name']) && $gg['name'] === $DESIRED_QUARANTINE_GROUP) {
$groupOk = true;
}
}
if (!($ownerOk && $groupOk)) {
if (isset($BOOT_LOGGER)) {
$BOOT_LOGGER->logEvent('config_warning', [
'msg' => 'quarantine_owner_group_mismatch',
'path' => $QUARANTINE_DIR,
'desired_owner' => $DESIRED_QUARANTINE_OWNER,
'desired_group' => $DESIRED_QUARANTINE_GROUP,
'current_uid' => $statUid,
'current_gid' => $statGid,
]);
}
}
}
/* ---------- Utils ---------- */
function is_suspicious_filename(string $name): bool
{
$n = strtolower($name);
// Path traversal / weird separators in filename
if (strpos($n, '../') !== false || strpos($n, '..\\') !== false || strpos($n, "\0") !== false) {
return true;
}
// Dangerous extensions (final)
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $n)) {
return true;
}
// Double-extension tricks anywhere (e.g., image.php.jpg or image.jpg.php)
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)\./i', $n)) {
return true;
}
// Hidden dotfile php-like names
if (preg_match('/^\.(php|phtml|phar|php\d)/i', $n)) {
return true;
}
return false;
}
function is_fake_image(string $name, string $realMime): bool
{
// If filename looks like image but real mime is not image/*
if (preg_match('/\.(png|jpe?g|gif|webp|bmp|ico|svg)$/i', $name)) {
// SVG often returns image/svg+xml; still image/*
if (!preg_match('/^image\//', $realMime)) {
return true;
}
}
return false;
}
/* ---------- Context ---------- */
[$ip, $uri, $method, $ctype, $clen, $ua, $te] = $REQ->getRequestSummary($LOG_USER_AGENT);
$userId = $REQ->getUserId();
$requestId = $REQ->generateRequestId();
$REQUEST_CTX = [
'request_id' => $requestId,
'ip' => $ip,
'uri' => $uri,
'method' => $method,
'ctype' => $ctype,
'clen' => (int)$clen,
'user' => $userId,
'ua' => $ua,
'transfer_encoding' => $te,
];
// Logger instance for structured JSON output
$CONFIG = new \UploadLogger\Core\Config($CONFIG_DATA);
$LOGGER = new \UploadLogger\Core\Logger($logFile, $REQUEST_CTX, $CONFIG);
/*
* Map frequently-used legacy globals to values from `Config` so the rest of
* the procedural helpers can continue to reference globals but operators
* may control behavior via `upload-logger.json`.
*/
$BLOCK_SUSPICIOUS = $CONFIG->get('ops.block_suspicious', $BLOCK_SUSPICIOUS ?? false);
$MAX_SIZE = (int)$CONFIG->get('limits.max_size', $MAX_SIZE ?? (50 * 1024 * 1024));
$RAW_BODY_MIN = (int)$CONFIG->get('limits.raw_body_min', $RAW_BODY_MIN ?? (500 * 1024));
$FLOOD_WINDOW_SEC = (int)$CONFIG->get('limits.flood_window_sec', $FLOOD_WINDOW_SEC ?? 60);
$FLOOD_MAX_UPLOADS = (int)$CONFIG->get('limits.flood_max_uploads', $FLOOD_MAX_UPLOADS ?? 40);
$SNIFF_MAX_BYTES = (int)$CONFIG->get('limits.sniff_max_bytes', $SNIFF_MAX_BYTES ?? 8192);
$SNIFF_MAX_FILESIZE = (int)$CONFIG->get('limits.sniff_max_filesize', $SNIFF_MAX_FILESIZE ?? (2 * 1024 * 1024));
$LOG_USER_AGENT = (bool)$CONFIG->get('ops.log_user_agent', $LOG_USER_AGENT ?? true);
// Determine whether peeking into php://input may be used (module + runtime allow)
$PEEK_RAW_INPUT = ($CONFIG->isModuleEnabled('raw_peek') || ($PEEK_RAW_INPUT ?? false)) ? ($PEEK_RAW_INPUT ?? false) : ($PEEK_RAW_INPUT ?? false);
$TRUSTED_PROXY_IPS = $CONFIG->get('ops.trusted_proxy_ips', $TRUSTED_PROXY_IPS ?? ['127.0.0.1', '::1']);
$ALLOWLIST_FILE = $CONFIG->get('paths.allowlist_file', $ALLOWLIST_FILE ?? (__DIR__ . '/allowlist.json'));
$STATE_DIR = $CONFIG->get('paths.state_dir', $STATE_DIR ?? (__DIR__ . '/state'));
$HASH_MAX_FILESIZE = (int)$CONFIG->get('limits.hash_max_filesize', $HASH_MAX_FILESIZE ?? (10 * 1024 * 1024));
$QUARANTINE_ENABLED = $CONFIG->isModuleEnabled('quarantine') && ($CONFIG->get('ops.quarantine_enabled', $QUARANTINE_ENABLED ?? true));
$QUARANTINE_DIR = $CONFIG->get('paths.quarantine_dir', $QUARANTINE_DIR ?? (__DIR__ . '/quarantine'));
$ARCHIVE_INSPECT = $CONFIG->isModuleEnabled('archive_inspect') || ($ARCHIVE_INSPECT ?? false);
$ARCHIVE_BLOCK_ON_SUSPICIOUS = (bool)$CONFIG->get('ops.archive_block_on_suspicious', $ARCHIVE_BLOCK_ON_SUSPICIOUS ?? false);
$ARCHIVE_MAX_ENTRIES = (int)$CONFIG->get('limits.archive_max_entries', $ARCHIVE_MAX_ENTRIES ?? 200);
$ARCHIVE_MAX_INSPECT_SIZE = (int)$CONFIG->get('limits.archive_max_inspect_size', $ARCHIVE_MAX_INSPECT_SIZE ?? (50 * 1024 * 1024));
// Detector context and registry
$CONTEXT = new \UploadLogger\Core\Context(
$requestId,
$ip,
$uri,
$method,
$ctype,
(int)$clen,
$userId,
$ua,
$te
);
$DETECTORS = [
new \UploadLogger\Detectors\FilenameDetector(),
new \UploadLogger\Detectors\MimeDetector(),
new \UploadLogger\Detectors\ContentDetector($CONFIG),
];
// Dispatch request processing
$FLOOD_SERVICE = new \UploadLogger\Core\Services\FloodService($CONFIG);
$SNIFFER_SERVICE = new \UploadLogger\Core\Services\SnifferService($CONFIG);
$HASH_SERVICE = new \UploadLogger\Core\Services\HashService($CONFIG);
$QUARANTINE_SERVICE = new \UploadLogger\Core\Services\QuarantineService($CONFIG);
$DISPATCHER = new \UploadLogger\Core\Dispatcher($LOGGER, $CONTEXT, $DETECTORS, $CONFIG, $FLOOD_SERVICE, $SNIFFER_SERVICE, $HASH_SERVICE, $QUARANTINE_SERVICE);
$DISPATCHER->dispatch($_FILES, $_SERVER);