commit 037b1768921a618647e60abfda724e621e12e716 Author: Gregor Klevze Date: Sat Feb 7 15:11:15 2026 +0100 fix(scanner): avoid SVG/XML false positives; add allowlist and .gitignore Relax payload scanner for XML/SVG by passing content-type into checks Skip JS-style eval() detection when content-type is XML/SVG Pass request Content-Type through sniff_file_for_php_payload() and raw-body checks Add common XML/SVG content-types to allowlist.json Add repository .gitignore (ignore logs, quarantine/, state/, env, vendor, IDE files) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f848ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Upload Logger repository .gitignore + +# Runtime logs and quarantine/state directories (do not commit) +/logs/ +/quarantine/ +/state/ +*.log +uploads.log + +# Peek allow marker (local only) +/.upload_logger_allow_peek + +# Local environment files +.env +.env.* + +# Composer / vendor +/vendor/ +/composer.lock + +# Node +/node_modules/ + +# IDEs and OS files +.vscode/ +.idea/ +*.iml +.DS_Store +Thumbs.db + +# Temp/cache +*.cache +*.tmp diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..9d9c9ed --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,472 @@ +# πŸ” Per-Site PHP Upload Guard Integration Guide + +This guide explains how to integrate a global PHP upload monitoring script +using `auto_prepend_file`, on a **per-site basis**, with isolated security +folders. + +--- + +## πŸ“ 1. Recommended Folder Structure + +Each website should contain its own hidden security directory: + +``` + +/var/www/sites/example-site/ +β”œβ”€β”€ public/ +β”œβ”€β”€ app/ +β”œβ”€β”€ uploads/ +β”œβ”€β”€ .security/ +β”‚ β”œβ”€β”€ upload_guard.php +β”‚ └── logs/ +β”‚ └── uploads.log + +```` + +Benefits: + +- Per-site isolation +- Easier debugging +- Independent log files +- Reduced attack surface + +--- + +## πŸ”§ 2. Create the Security Directory + +From the site root: + +```bash +cd /var/www/sites/example-site + +mkdir .security +mkdir .security/logs +```` + +Set secure permissions: + +```bash +chown -R root:www-data .security +chmod 750 .security +chmod 750 .security/logs +``` + +--- + +## πŸ“„ 3. Install the Upload Guard Script + +Create the script file: + +```bash +nano .security/upload_guard.php +``` + +Paste your hardened upload monitoring script. + +Inside the script, configure logging: + +```php +$logFile = __DIR__ . '/logs/uploads.log'; +``` + +Lock the script: + +```bash +chown root:root .security/upload_guard.php +chmod 644 .security/upload_guard.php +``` + +--- + +## βš™οΈ 4. Enable auto_prepend_file (Per Site) + +### Option A β€” PHP-FPM Pool (Recommended) + +Edit the site’s PHP-FPM pool configuration: + +```bash +nano /etc/php/8.x/fpm/pool.d/example-site.conf +``` + +Add: + +```ini +php_admin_value[auto_prepend_file] = /var/www/sites/example-site/.security/upload_guard.php +``` + +Reload PHP-FPM: + +```bash +systemctl reload php8.x-fpm +``` + +# πŸ” Per-Site PHP Upload Guard Integration Guide + +This guide explains how to integrate a global PHP upload monitoring script +using `auto_prepend_file`, on a **per-site basis**, with isolated security +folders. + +--- + +## πŸ“ 1. Recommended Folder Structure + +Each website should contain its own hidden security directory: + +```text +/var/www/sites/example-site/ +β”œβ”€β”€ public/ +β”œβ”€β”€ app/ +β”œβ”€β”€ uploads/ +β”œβ”€β”€ .security/ +β”‚ β”œβ”€β”€ upload-logger.php +β”‚ └── logs/ +β”‚ └── uploads.log + +``` + +Benefits: + +- Per-site isolation +- Easier debugging +- Independent log files +- Reduced attack surface + +--- + +## πŸ”§ 2. Create the Security Directory + +From the site root: + +```bash +cd /var/www/sites/example-site + +mkdir .security +mkdir .security/logs +``` + +Set secure permissions: + +```bash +chown -R root:www-data .security +chmod 750 .security +chmod 750 .security/logs +``` + +--- + +## πŸ“„ 3. Install the Upload Guard Script + +Create the script file: + +```bash +nano .security/upload-logger.php +``` + +Paste your hardened upload monitoring script. + +Inside the script, configure logging: + +```php +$logFile = __DIR__ . '/logs/uploads.log'; +``` + +Lock the script: + +```bash +chown root:root .security/upload-logger.php +chmod 644 .security/upload-logger.php +``` + +--- + +## βš™οΈ 4. Enable auto_prepend_file (Per Site) + +### Option A β€” PHP-FPM Pool (Recommended) + +Edit the site’s PHP-FPM pool configuration: + +```bash +nano /etc/php/8.x/fpm/pool.d/example-site.conf +``` + +Add: + +```ini +php_admin_value[auto_prepend_file] = /var/www/sites/example-site/.security/upload-logger.php +``` + +Reload PHP-FPM (adjust service name to match your PHP version): + +```bash +systemctl reload php8.x-fpm +``` + +--- + +### Option B β€” Apache Virtual Host + +If using a shared PHP-FPM pool, configure in the vHost: + +```apache + + php_admin_value auto_prepend_file /var/www/sites/example-site/.security/upload-logger.php + +``` + +Reload Apache: + +```bash +systemctl reload apache2 +``` + +--- + +## 🚫 5. Block Web Access to `.security` + +Prevent direct HTTP access to the security folder. + +In the vHost: + +```apache + + Require all denied + +``` + +Or in `.htaccess` (if allowed): + +```apache +Require all denied +``` + +--- + +## βœ… 6. Verify Installation + +Create a temporary file: + +```php + + + + +``` + +Upload any file and check logs: + +```bash +cat .security/logs/uploads.log +``` + +You should see a new entry. + +--- + +## πŸ”’ 8. Disable PHP Execution in Uploads + +Always block PHP execution in upload directories. + +Example (Apache): + +```apache + + php_admin_flag engine off + AllowOverride None + +``` + +Reload Apache after changes. + +--- + +## πŸ›‘οΈ 9. Enable Blocking Mode (Optional) + +After monitoring for some time, enable blocking. + +Edit: + +```php +$BLOCK_SUSPICIOUS = true; +``` + +Then reload PHP-FPM. + +--- + +## πŸ“Š 10. (Optional) Fail2Ban Integration (JSON logs) + +Create a JSON-aware filter that matches `event: "suspicious"` and extracts the IP address. + +```bash +nano /etc/fail2ban/filter.d/php-upload.conf +``` + +```ini +[Definition] +# Match JSON lines where event == "suspicious" and capture the IPv4 address as +failregex = ^.*"event"\s*:\s*"suspicious".*"ip"\s*:\s*"(?P\d{1,3}(?:\.\d{1,3}){3})".*$ +ignoreregex = +``` + +Create a jail that points to the per-site logs (or a central aggregated log): + +```ini +[php-upload] +enabled = true +filter = php-upload +logpath = /var/www/sites/*/.security/logs/uploads.log +maxretry = 3 +findtime = 600 +bantime = 86400 +action = iptables-multiport[name=php-upload, port="http,https", protocol=tcp] +``` + +Restart Fail2Ban: + +```bash +systemctl restart fail2ban +``` + +### Fail2Ban action: nftables example + +If your host uses nftables, prefer the `nftables` action so bans use the system firewall: + +```ini +[php-upload] +enabled = true +filter = php-upload +logpath = /var/www/sites/*/.security/logs/uploads.log +maxretry = 3 +findtime = 600 +bantime = 86400 +action = nftables[name=php-upload, port="http,https", protocol=tcp] +``` + +This uses Fail2Ban's `nftables` action (available on modern distributions). Adjust `port`/`protocol` to match your services. + +### Central log aggregation (Filebeat / rsyslog) + +Forwarding per-site JSON logs to a central collector simplifies alerts and Fail2Ban at scale. Two lightweight options: + +- Filebeat prospector (send to Logstash/Elasticsearch): + +```yaml +filebeat.inputs: +- type: log + paths: + - /var/www/sites/*/.security/logs/uploads.log + json.keys_under_root: true + json.add_error_key: true + fields: + source: php-upload-logger +output.logstash: + hosts: ["logserver:5044"] +``` + +- rsyslog `imfile` forwarding to remote syslog (central rsyslog/logstash): + +Add to `/etc/rsyslog.d/10-upload-logger.conf`: + +```text +module(load="imfile" PollingInterval="10") +input(type="imfile" File="/var/www/sites/*/.security/logs/uploads.log" Tag="uploadlogger" Severity="info" Facility="local7") +*.* @@logserver:514 +``` + +Both options keep JSON intact for downstream parsing and reduce per-host Fail2Ban complexity. + +### Testing your Fail2Ban filter + +Create a temporary file containing a representative JSON log line emitted by `upload-logger.php` and run `fail2ban-regex` against your filter to validate detection. + +```bash +# create test file with a suspicious event +cat > /tmp/test_upload.log <<'JSON' +{"ts":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","event":"suspicious","ip":"1.2.3.4","user":"guest","name":"evil.php.jpg","real_mime":"application/x-php","reasons":["bad_name","php_payload"]} +JSON + +# test the filter (adjust path to filter if different) +fail2ban-regex /tmp/test_upload.log /etc/fail2ban/filter.d/php-upload.conf +``` + +`fail2ban-regex` will report how many matches were found and display sample matched groups (including the captured ``). Use this to iterate on the `failregex` if it doesn't extract the IP as expected. + +--- + +## 🏁 Final Architecture + +```text +Client β†’ Web Server β†’ PHP (auto_prepend) β†’ Application β†’ Disk + ↓ + Log / Alert / Ban +``` + +This provides multi-layer upload monitoring and protection. + +--- + +## πŸ—‚οΈ Log rotation & SELinux/AppArmor notes + +- Example `logrotate` snippet to rotate per-site logs weekly and keep 8 rotations: + +```text +/var/www/sites/*/.security/logs/uploads.log { + weekly + rotate 8 + compress + missingok + notifempty + create 0640 root adm +} +``` + +- If your host enforces SELinux or AppArmor, ensure the `.security` directory and log files have the correct context so PHP-FPM can read the script and write logs. For SELinux (RHEL/CentOS) you may need: + +```bash +chcon -R -t httpd_sys_rw_content_t /var/www/sites/example-site/.security/logs +restorecon -R /var/www/sites/example-site/.security +``` + +Adjust commands to match your platform and policy. AppArmor profiles may require adding paths to the PHP-FPM profile. + +## ⚠️ Security Notes + +- Never use `777` permissions +- Keep `.security` owned by `root` +- Regularly review logs +- Update PHP and extensions +- Combine with OS-level auditing for best results + +--- + +## πŸ“Œ Recommended Maintenance + +Weekly: + +```bash +grep ALERT .security/logs/uploads.log +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f65b52 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Upload Logger (Hardened v3) + +This repository contains the v3 upload-logging helper: a hardened single-file monitor that logs uploads, detects common evasion techniques, and optionally blocks suspicious uploads. + +Primary file: [upload-logger.php](upload-logger.php) + +Summary +- Purpose: Log normal uploads and raw-body uploads, detect double-extension tricks, fake images, PHP payloads embedded in files, and provide flood detection. +- Runs only for HTTP requests; recommended to enable via `auto_prepend_file` in a per-site PHP-FPM pool for broad coverage. + +Key configuration (top of `upload-logger.php`) +- `$logFile` β€” path to the log file (default: `__DIR__ . '/logs/uploads.log'`). +- `$BLOCK_SUSPICIOUS` β€” if `true` the script returns `403` and exits when suspicious uploads are detected. +- `$MAX_SIZE` β€” threshold for `WARN big_upload` (default 50 MB). +- `$RAW_BODY_MIN` β€” minimum raw request body size considered suspicious when `$_FILES` is empty (default 500 KB). +- `$FLOOD_WINDOW_SEC`, `$FLOOD_MAX_UPLOADS` β€” lightweight per-IP flood detection window and max uploads before alerting. +- `$SNIFF_MAX_BYTES`, `$SNIFF_MAX_FILESIZE` β€” parameters controlling fast content sniffing for PHP/webshell markers. +- `$LOG_USER_AGENT` β€” include `User-Agent` in logs when true. + +What it detects +- Dangerous filenames (path-traversal, double extensions, hidden php-like dotfiles). +- Fake images: file extension indicates an image but `finfo` returns a non-image MIME. +- PHP/webshell markers inside file content (fast head-scan up to configured limits). +- Archive uploads (`.zip`, `.tar`, etc.) flagged for attention. +- Raw request bodies (e.g., `application/octet-stream` or streamed uploads) when `$_FILES` is empty and body size exceeds `$RAW_BODY_MIN`. +- Flooding by counting uploads per-IP in a rolling window. + +Logging and alerts +- Each accepted upload generates an `UPLOAD` line with fields: `ip`, `user`, `name`, `size`, `type`, `real` (detected MIME), `tmp`, `uri`, and optional `ua`. +- Suspicious uploads generate `ALERT suspicious` entries with `reasons=` listing detected flags (e.g., `bad_name,fake_image,php_payload`). +- Other notes: `WARN big_upload`, `NOTE archive_upload`, `MULTIPART_NO_FILES`, and `RAW_BODY` are emitted when appropriate. + +Integration notes +- Preferred deployment: set `php_admin_value[auto_prepend_file]` in the site-specific PHP-FPM pool to the absolute path of `upload-logger.php` so it runs before application code. +- If using sessions for user identification, the script safely reads `$_SESSION['user_id']` only when a session is active; do not rely on it being present unless your app starts sessions earlier. +- The script uses `is_uploaded_file()`/`finfo` where available; ensure the PHP `fileinfo` extension is enabled for best MIME detection. + +Operational recommendations +- Place the `logs/` directory outside the webroot or deny web access to it. +- Ensure correct owner/group and permissions (e.g., owner `root`, group `www-data`, `chmod 750` on `.security` and `chmod 640` for logs) and confirm PHP-FPM's user/group membership. +- Rotate logs with `logrotate` (see `INTEGRATION.md` for an example snippet). +- If your host uses SELinux/AppArmor, set correct contexts or adjust profiles so PHP-FPM can read the script and write logs. + +Limitations & safety +- This script improves visibility and blocks common upload tricks but cannot guarantee interception of every file-write vector (e.g., direct application writes, ZipArchive extraction, custom file APIs). Use it as part of a layered defense. +- Content sniffing is limited to a head-scan to reduce CPU and false positives; tune `$SNIFF_MAX_BYTES` and `$SNIFF_MAX_FILESIZE` to balance coverage and performance. + +Quick start +1. Place `upload-logger.php` in a per-site secure folder (see `INTEGRATION.md`). +2. Ensure the `logs/` directory exists and is writable by PHP-FPM. +3. Enable as an `auto_prepend_file` in the site pool and reload PHP-FPM. +4. Monitor `logs/uploads.log` and adjust configuration options at the top of the script. + +Support & changes +- For changes, edit configuration variables at the top of `upload-logger.php` or adapt detection helpers as needed. + +--- +Generated for upload-logger.php (v3). diff --git a/allowlist.json b/allowlist.json new file mode 100644 index 0000000..d948b78 --- /dev/null +++ b/allowlist.json @@ -0,0 +1,22 @@ +{ + "uris": [ + "/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" + ], + "ctypes": [ + "image/svg+xml", + "application/xml", + "text/xml" + ] +} diff --git a/upload-logger.php b/upload-logger.php new file mode 100644 index 0000000..7f8637c --- /dev/null +++ b/upload-logger.php @@ -0,0 +1,1000 @@ + 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']; + } + } +} + +function base64_is_allowlisted(string $uri, string $ctype): bool +{ + global $BASE64_ALLOWLIST_URI, $BASE64_ALLOWLIST_CTYPE; + + foreach ($BASE64_ALLOWLIST_URI as $p) { + if (strlen($p) > 1 && $p[0] === '#' && substr($p, -1) === '#') { + // regex + if (@preg_match($p, $uri)) return true; + } else { + if (strpos($uri, $p) !== false) return true; + } + } + + if (!empty($BASE64_ALLOWLIST_CTYPE) && $ctype !== '') { + $base = explode(';', $ctype, 2)[0]; + foreach ($BASE64_ALLOWLIST_CTYPE as $ct) { + if (strtolower(trim($ct)) === strtolower(trim($base))) return true; + } + } + + return false; +} + +$fileAllow = is_file($PEEK_ALLOW_FILE); +$headerAllow = false; +if (isset($_SERVER['HTTP_X_UPLOAD_LOGGER_PEEK']) && $_SERVER['HTTP_X_UPLOAD_LOGGER_PEEK'] === '1') { + $clientIp = get_client_ip(); + if (in_array($clientIp, $TRUSTED_PROXY_IPS, true)) { + $headerAllow = true; + } +} + +if ($envAllow || $fileAllow || $headerAllow) { + $PEEK_RAW_INPUT = true; + if (function_exists('log_event')) { + log_event('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 (function_exists('log_event')) { + log_event('config_warning', [ + 'msg' => 'quarantine_dir_perms_not_strict', + 'path' => $QUARANTINE_DIR, + 'perms_octal' => sprintf('%o', $mask), + ]); + } + } + } + } else { + if (function_exists('log_event')) { + log_event('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)) { + log_event('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 upload_clean($str): string +{ + return str_replace(["\n", "\r", "\t"], '_', (string)$str); +} + +function log_normalize_value($value) +{ + if (is_array($value)) { + $out = []; + foreach ($value as $k => $v) { + $out[$k] = log_normalize_value($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); +} + +function generate_request_id(): string +{ + try { + return bin2hex(random_bytes(8)); + } catch (Throwable $e) { + return uniqid('req', true); + } +} + +function log_event(string $event, array $data = []): void +{ + global $logFile, $REQUEST_CTX; + + $payload = array_merge( + ['ts' => gmdate('c'), 'event' => $event], + is_array($REQUEST_CTX) ? $REQUEST_CTX : [], + $data + ); + + $payload = log_normalize_value($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($logFile, $json . "\n", FILE_APPEND | LOCK_EX); +} + +function get_client_ip(): string +{ + // Prefer REMOTE_ADDR (trusted), but log proxy headers separately if needed. + return $_SERVER['REMOTE_ADDR'] ?? 'unknown'; +} + +function get_user_id(): string +{ + // Avoid assuming session is started. + // If you have app-specific auth headers, extend here. + 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'; +} + +function get_request_summary(): array +{ + global $LOG_USER_AGENT; + + $ip = get_client_ip(); + $uri = $_SERVER['REQUEST_URI'] ?? 'unknown'; + $method = $_SERVER['REQUEST_METHOD'] ?? 'unknown'; + + $ctype = $_SERVER['CONTENT_TYPE'] ?? ''; + $clen = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); + + $ua = $LOG_USER_AGENT ? ($_SERVER['HTTP_USER_AGENT'] ?? '') : ''; + + $te = $_SERVER['HTTP_TRANSFER_ENCODING'] ?? ''; + + return [$ip, $uri, $method, $ctype, $clen, $ua, $te]; +} + +/** + * Simple per-IP flood counter in /tmp with TTL window. + * This is lightweight and avoids dependencies. + */ +function flood_check(string $ip): int +{ + global $FLOOD_WINDOW_SEC, $STATE_DIR; + + $key = $STATE_DIR . '/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) > $FLOOD_WINDOW_SEC) { + $start = $now; + $count = 0; + } + + $count++; + rewind($fh); + ftruncate($fh, 0); + fwrite($fh, $start . ':' . $count); + fflush($fh); + flock($fh, LOCK_UN); + } + + fclose($fh); + + return $count; +} + +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 sniff_file_for_php_payload(string $tmpPath): bool +{ + global $SNIFF_MAX_BYTES, $SNIFF_MAX_FILESIZE; + + if (!is_uploaded_file($tmpPath)) return false; + + $sz = @filesize($tmpPath); + if ($sz === false) return false; + if ($sz <= 0) return false; + if ($sz > $SNIFF_MAX_FILESIZE) return false; + + $bytes = min($SNIFF_MAX_BYTES, $sz); + $head = @file_get_contents($tmpPath, false, null, 0, $bytes); + if ($head === false) return false; + + // Pass the detected real mime to the scanner so it can relax JS-specific + // rules for SVG/XML payloads (avoids false positives on benign SVGs). + $realMime = detect_real_mime($tmpPath); + if (payload_contains_php_markers($head, $realMime)) { + return true; + } + + return false; +} + + + function payload_contains_php_markers(string $text, string $contentType = ''): bool + { + // Determine if content-type suggests XML/SVG so we can be permissive + $isXmlLike = false; + if ($contentType !== '') { + $isXmlLike = (bool)preg_match('/xml|svg/i', $contentType); + } + + // Always detect explicit PHP tags or short-open tags (but avoid '= 200 chars) + if (preg_match('/"(?:file|data|payload|content)"\s*:\s*"(?:data:[^,]+,)?([A-Za-z0-9+\/=]{200,})"/i', $head, $m)) { + $b64 = $m[1]; + // Decode only the first N characters of base64 string safely (rounded up to multiple of 4) + $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]; + } + + // Also detect raw base64 body start (no JSON): long base64 string at start + 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]; + } + +function detect_real_mime(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; +} + +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; +} + +function is_archive(string $name, string $realMime): bool +{ + // Archives often used to smuggle payloads + 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; +} + +function compute_hashes(string $tmpPath, int $size): array +{ + global $HASH_MAX_FILESIZE; + + if (!is_uploaded_file($tmpPath)) return []; + if ($size <= 0 || $size > $HASH_MAX_FILESIZE) 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; +} + +function quarantine_file(string $tmpPath, string $origName, array $hashes): array +{ + global $QUARANTINE_ENABLED, $QUARANTINE_DIR; + + if (!$QUARANTINE_ENABLED) return ['ok' => false, 'path' => '']; + if (!is_uploaded_file($tmpPath)) return ['ok' => false, 'path' => '']; + if (!is_dir($QUARANTINE_DIR)) 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($QUARANTINE_DIR, '/\\') . '/' . $base . ($ext ? '.' . $ext : ''); + + $ok = @move_uploaded_file($tmpPath, $dest); + if ($ok) { + @chmod($dest, 0600); + return ['ok' => true, 'path' => $dest]; + } + + return ['ok' => false, 'path' => $dest]; +} + +/** + * Inspect archive file in quarantine without extracting. + * Supports ZIP via ZipArchive and TAR (.tar, .tar.gz) via PharData if available. + * Returns summary array: ['entries'=>N, 'suspicious_entries'=> [...], 'unsupported'=>bool] + */ +function inspect_archive_quarantine(string $path): array +{ + global $ARCHIVE_MAX_ENTRIES; + + global $ARCHIVE_MAX_INSPECT_SIZE; + + // Avoid inspecting extremely large archives + $fsz = @filesize($path); + if ($fsz !== false && $fsz > $ARCHIVE_MAX_INSPECT_SIZE) { + 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); + // ZIP + if (class_exists('ZipArchive') && preg_match('/\.zip$/i', $lower)) { + $za = new ZipArchive(); + if ($za->open($path) === true) { + $cnt = $za->numFiles; + $out['entries'] = min($cnt, $ARCHIVE_MAX_ENTRIES); + $limit = $out['entries']; + for ($i = 0; $i < $limit; $i++) { + $stat = $za->statIndex($i); + if ($stat && isset($stat['name'])) { + $name = $stat['name']; + $entry = ['name' => $name, 'suspicious' => false, 'reason' => null]; + // traversal or absolute path + if (strpos($name, '../') !== false || strpos($name, '..\\') !== false || strpos($name, '/') === 0 || strpos($name, '\\') === 0) { + $entry['suspicious'] = true; + $entry['reason'] = 'path_traversal'; + } + // suspicious extension + 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; + } + + // TAR (including .tar.gz) via PharData if available + 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++ >= $ARCHIVE_MAX_ENTRIES) 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; + } + + // unsupported archive type + $out['unsupported'] = true; + return $out; +} + +/* ---------- Context ---------- */ + +[$ip, $uri, $method, $ctype, $clen, $ua, $te] = get_request_summary(); +$userId = get_user_id(); +$requestId = generate_request_id(); + +$REQUEST_CTX = [ + 'request_id' => $requestId, + 'ip' => $ip, + 'uri' => $uri, + 'method' => $method, + 'ctype' => $ctype, + 'clen' => (int)$clen, + 'user' => $userId, + 'ua' => $ua, + 'transfer_encoding' => $te, +]; + +// Only upload-capable methods +if (!in_array($method, ['POST', 'PUT', 'PATCH'], true)) { + return; +} + +// Log suspicious raw-body uploads that bypass $_FILES +// (Do this early so we capture endpoints that stream content into file_put_contents) +if (empty($_FILES)) { + $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. + // Only perform when explicitly enabled and when CONTENT_LENGTH is small enough + // to avoid consuming large bodies or affecting application behavior. + global $PEEK_RAW_INPUT, $SNIFF_MAX_FILESIZE, $SNIFF_MAX_BYTES; + if ($PEEK_RAW_INPUT && $clen > 0 && $clen <= $SNIFF_MAX_FILESIZE) { + $peek = ''; + $in = @fopen('php://input', 'r'); + if ($in !== false) { + // read a small head only + $peek = @stream_get_contents($in, $SNIFF_MAX_BYTES); + @fclose($in); + } + + if ($peek !== false && $peek !== '') { + // Detect JSON-embedded base64 and inspect decoded head + $b = detect_json_base64_head($peek, 1024); + if (!empty($b['found'])) { + // skip fingerprinting/inspection for allowlisted URIs/CTypes + if (base64_is_allowlisted($uri, $ctype)) { + log_event('raw_body_base64_ignored', ['uri' => $uri, 'ctype' => $ctype]); + // mark suspicious only if other raw indicators exist + // continue without further decoding/fingerprinting + $rawSuspicious = $rawSuspicious || false; + } else { + // log base64 blob detected; include fingerprint of decoded head when available + $fingerprints = []; + if (!empty($b['decoded_head'])) { + $decoded_head = $b['decoded_head']; + $sample = substr($decoded_head, 0, $BASE64_FINGERPRINT_BYTES); + $fingerprints['sha1'] = @sha1($sample); + $fingerprints['md5'] = @md5($sample); + if (payload_contains_php_markers($decoded_head, $ctype)) { + $rawSuspicious = true; + log_event('raw_body_php_payload', [ + 'len' => (int)$clen, + 'ctype' => $ctype, + 'reason' => $b['reason'] ?? 'base64_embedded', + 'fingerprints' => $fingerprints, + ]); + } else { + log_event('raw_body_base64', [ + 'len' => (int)$clen, + 'ctype' => $ctype, + 'reason' => $b['reason'] ?? 'base64_embedded', + 'fingerprints' => $fingerprints, + ]); + } + } else { + log_event('raw_body_base64', [ + 'len' => (int)$clen, + 'ctype' => $ctype, + 'reason' => $b['reason'] ?? 'base64_embedded', + ]); + } + } + } else { + // Also scan the raw head itself for PHP markers (text/plain, octet-stream, etc.) + if (payload_contains_php_markers($peek, $ctype)) { + log_event('raw_body_php_payload', [ + 'len' => (int)$clen, + 'ctype' => $ctype, + 'reason' => 'head_php_markers', + ]); + $rawSuspicious = true; + } + } + } + } + + if ($rawSuspicious) { + log_event('raw_body', [ + 'len' => (int)$clen, + 'ctype' => $ctype, + ]); + } +} + +// multipart/form-data but no $_FILES +if ( + empty($_FILES) && + $ctype && + stripos($ctype, 'multipart/form-data') !== false +) { + log_event('multipart_no_files', []); +} + +/* ---------- Upload Handling ---------- */ + +if (!empty($_FILES)) { + + // Per request flood check: count each file below too + // (Optional: log the current counter at request-level) + $reqCount = flood_check($ip); + + if ($reqCount > $GLOBALS['FLOOD_MAX_UPLOADS']) { + log_event('flood_alert', ['count' => (int)$reqCount]); + // Don't block purely on this here unless you want to + // if ($BLOCK_SUSPICIOUS) { http_response_code(429); exit('Too many uploads'); } + } + + 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++) { + handle_file_v3( + $ip, $uri, $userId, $ua, + $file['name'][$i] ?? '', + $file['type'][$i] ?? '', + $file['size'][$i] ?? 0, + $file['tmp_name'][$i] ?? '', + $file['error'][$i] ?? UPLOAD_ERR_NO_FILE + ); + } + + } else { + + handle_file_v3( + $ip, $uri, $userId, $ua, + $file['name'] ?? '', + $file['type'] ?? '', + $file['size'] ?? 0, + $file['tmp_name'] ?? '', + $file['error'] ?? UPLOAD_ERR_NO_FILE + ); + } + } +} + +/* ---------- Core ---------- */ + +function handle_file_v3($ip, $uri, $user, $ua, $name, $type, $size, $tmp, $err): void +{ + global $BLOCK_SUSPICIOUS, $MAX_SIZE, $FLOOD_MAX_UPLOADS; + + if ($err !== UPLOAD_ERR_OK) { + // Log non-OK upload errors for forensics + log_event('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) + $count = flood_check($ip); + if ($count > $FLOOD_MAX_UPLOADS) { + log_event('flood_alert', ['count' => (int)$count]); + // Optional blocking: + // if ($BLOCK_SUSPICIOUS) { http_response_code(429); exit('Too many uploads'); } + } + + // Real MIME + $real = detect_real_mime($tmp); + + /* Detection */ + $suspicious = false; + $reasons = []; + + // Path components or modified basename + if ($origName !== $name || strpos($origName, '/') !== false || strpos($origName, '\\') !== false) { + $suspicious = true; + $reasons[] = 'bad_name'; + } + + // Dangerous / tricky filename + if (is_suspicious_filename($name)) { + $suspicious = true; + $reasons[] = 'bad_name'; + } + + // Fake images (name says image, MIME isn't) + if (is_fake_image($name, $real)) { + $suspicious = true; + $reasons[] = 'fake_image'; + } + + // Very large file + if ($size > $MAX_SIZE) { + log_event('big_upload', [ + 'name' => $name, + 'size' => (int)$size, + ]); + $reasons[] = 'big_file'; + // (Not automatically suspicious; depends on your app) + } + + // Archive uploads are higher risk (often used to smuggle payloads) + if (is_archive($name, $real)) { + $reasons[] = 'archive'; + // Move to quarantine and inspect archive contents safely (no extraction) + log_event('archive_upload', [ + 'name' => $name, + 'real_mime' => $real, + ]); + + if ($QUARANTINE_ENABLED) { + $qres = quarantine_file($tmp, $origName, $hashes ?? []); + if ($qres['ok']) { + $qpath = $qres['path']; + log_event('archive_quarantined', ['path' => $qpath]); + + if ($ARCHIVE_INSPECT) { + $inspect = inspect_archive_quarantine($qpath); + log_event('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 { + log_event('archive_quarantine_failed', ['tmp' => $tmp, 'dest' => $qres['path']]); + } + } + } + + // Content sniffing for PHP payload (fast head scan, only for small files) + if (sniff_file_for_php_payload($tmp)) { + $suspicious = true; + $reasons[] = 'php_payload'; + } + + /* Logging */ + $hashes = compute_hashes($tmp, $size); + + log_event('upload', [ + 'name' => $name, + 'orig_name' => $origName, + 'size' => (int)$size, + 'type' => $type, + 'real_mime' => $real, + 'tmp' => $tmp, + 'hashes' => $hashes, + 'flags' => $reasons, + ]); + + /* Alert / Block */ + if ($suspicious) { + + $q = quarantine_file($tmp, $origName, $hashes); + + log_event('suspicious', [ + 'name' => $name, + 'orig_name' => $origName, + 'real_mime' => $real, + 'reasons' => $reasons, + 'quarantine_ok' => $q['ok'], + 'quarantine_path' => $q['path'], + ]); + + if ($BLOCK_SUSPICIOUS) { + http_response_code(403); + exit('Upload blocked'); + } + } +}