429 lines
15 KiB
PHP
429 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* UploadShield runtime
|
|
* Purpose:
|
|
* - Log uploads and detect suspicious uploads before application code runs
|
|
* - Configured via `uploadshield.json` (env `UPLOADSHIELD_CONFIG` supported)
|
|
*
|
|
* Install as PHP-FPM pool `auto_prepend_file=/path/to/uploadshield.php`
|
|
*/
|
|
|
|
// 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 = 'UPLOADSHIELD_ALLOW_PEEK';
|
|
$PEEK_ALLOW_FILE = __DIR__ . '/.uploadshield_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-UploadShield-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('UPLOADSHIELD_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 `UPLOADSHIELD_CONFIG`.
|
|
$CONFIG_FILE_DEFAULT = __DIR__ . '/uploadshield.json';
|
|
$CONFIG_FILE = getenv('UPLOADSHIELD_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_UPLOADSHIELD_PEEK']) && $_SERVER['HTTP_X_UPLOADSHIELD_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 `uploadshield.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);
|