diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..69b46a1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + # Auto-merge security updates is handled separately; label security PRs with `security`. + open-pull-requests-limit: 8 + rebase-strategy: "auto" + labels: + - "dependencies" + - "dependabot" + # Dependabot will create security and regular dependency update PRs. diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml new file mode 100644 index 0000000..a1a20e1 --- /dev/null +++ b/.github/workflows/auto-merge-dependabot.yml @@ -0,0 +1,36 @@ +name: Auto-merge Dependabot security updates + +on: + pull_request_target: + types: [opened, labeled, reopened, ready_for_review] + +jobs: + enable-automerge: + name: Enable auto-merge for Dependabot security PRs + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'dependabot-preview[bot]' + steps: + - name: Check PR labels for security + id: label-check + uses: actions/github-script@v6 + with: + script: | + const pr = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number }); + const labels = pr.data.labels.map(l => l.name.toLowerCase()); + const isSecurity = labels.includes('security') || labels.includes('dependabot-security') || pr.data.body && /security/i.test(pr.data.body); + return { isSecurity }; + + - name: Enable GitHub auto-merge on PR + if: steps.label-check.outputs.isSecurity == 'true' + uses: peter-evans/enable-pull-request-automerge@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + pull-request-number: ${{ github.event.pull_request.number }} + merge-method: squash + + - name: Comment when auto-merge enabled + if: steps.label-check.outputs.isSecurity == 'true' + uses: actions/github-script@v6 + with: + script: | + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, body: 'Auto-merge enabled for this Dependabot security update. Merge will occur automatically once required checks pass.' }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e340683 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1', '8.2', '8.3', '8.4'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Cache Composer + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + env: + COMPOSER_MEMORY_LIMIT: -1 + run: composer install --no-progress --prefer-dist --no-interaction + + - name: Dependency audit (Composer) + run: composer audit --no-interaction + + - name: Run tests (PHPUnit) + run: vendor/bin/phpunit --configuration phpunit.xml --testdox + + - name: Run static analysis (PHPStan) + run: vendor/bin/phpstan analyse -c phpstan.neon + lint: + name: PHP Lint & Basic Checks (matrix) + runs-on: ubuntu-latest + strategy: + matrix: + php: [ '8.0', '8.1', '8.2' ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Show PHP version + run: php -v + + - name: Install composer dependencies + run: | + composer --version || (curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer) + composer install --no-progress --no-suggest --prefer-dist --no-interaction + + - name: PHP -l lint (all .php files) + run: | + set -euo pipefail + echo "Finding PHP files..." + find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 -P4 php -l + + - name: Run PHPStan static analysis + run: | + set -euo pipefail + vendor/bin/phpstan analyse --no-progress -c phpstan.neon + + - name: Run PHPUnit + run: | + set -euo pipefail + if [ -x vendor/bin/phpunit ]; then + vendor/bin/phpunit --configuration phpunit.xml --colors=always + else + echo 'phpunit not installed; skipping tests (composer install should have installed dev deps).' + exit 0 + fi diff --git a/AUTO_MERGE.md b/AUTO_MERGE.md new file mode 100644 index 0000000..2e7c550 --- /dev/null +++ b/AUTO_MERGE.md @@ -0,0 +1,17 @@ +# Auto-merge & Dependabot: repository settings + +This project enables automated dependency updates (Dependabot) and a workflow that will enable GitHub auto-merge for Dependabot security PRs. To ensure auto-merge works correctly, the repository must be configured as follows: + +- **Allow auto-merge**: enable Allow auto-merge in the repository settings (Settings → General → Merge button → Allow auto-merge). +- **Branch protection**: configure branch protection for your main branch so that required status checks (CI) are set and required before merging. Dependabot PRs will only be merged automatically after required checks pass. +- **Required checks**: ensure the CI workflow `.github/workflows/ci.yml` is listed as a required check in your branch-protection rules (it runs PHPUnit, PHPStan and Composer Audit). +- **Bot permissions**: the default `GITHUB_TOKEN` used by workflows has permission to enable auto-merge; ensure Actions are allowed to create pull requests and manage them in repository settings if you have tightened permissions. + +## How to revoke or disable auto-merge + +- **Disable for a single PR**: open the PR and click the `Auto-merge` button to turn it off (or remove the `security` label). The workflow also posts a comment when it enables auto-merge. +- **Disable global auto-merge**: Repository Settings → Merge button → uncheck **Allow auto-merge**. +- **Disable the automation workflow**: remove or rename `.github/workflows/auto-merge-dependabot.yml` to stop the automatic enabling step. +- **Disable Dependabot updates**: remove or rename `.github/dependabot.yml` or change its `schedule` to `interval: "never"`. + +If you want stricter control, enable protected branch rules that require review approvals before merge; auto-merge will still wait for those approvals unless allowed by your protection policy. diff --git a/INTEGRATION.md b/INTEGRATION.md index 9d9c9ed..05cc6ab 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -1,3 +1,99 @@ +## Integration + +Example `upload-logger.json` (commented for easy copy/paste into your environment): + +```json +// { +// "modules": { +// "flood": true, +// "filename": true, +// "mime_sniff": true, +// "hashing": true, +// "base64_detection": true, +// "raw_peek": false, +// "archive_inspect": true, +// "quarantine": true +// }, +// "paths": { +// "log_file": "logs/uploads.log", +// "quarantine_dir": "quarantine", +// "state_dir": "state", +// "allowlist_file": "allowlist.json" +// }, +// "limits": { +// "max_size": 52428800, +// "raw_body_min": 512000, +// "sniff_max_bytes": 8192, +// "sniff_max_filesize": 2097152, +// "hash_max_filesize": 10485760, +// "archive_max_inspect_size": 52428800, +// "archive_max_entries": 200 +// }, +// "ops": { +// "quarantine_owner": "root", +// "quarantine_group": "www-data", +// "quarantine_dir_perms": "0700", +// "log_rotate": { +// "enabled": true, +// "size": 10485760, +// "keep": 7 +// } +// }, +// "allowlists": { +// "base64_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"] +// } +// } +``` + +Notes: + +- Remove the leading `// ` when copying this into a real `upload-logger.json` file. +- Adjust paths, owners, and limits to match your environment and PHP-FPM worker permissions. + +ContentDetector tuning and false-positive guidance + +- The repository includes a `ContentDetector` that performs a fast head-scan of uploaded files to detect PHP open-tags and common webshell indicators (for example `passthru()`, `system()`, `exec()`, `shell_exec()`, `proc_open()`, `popen()`, `base64_decode()`, `eval()`, `assert()`). It intentionally limits the scan to a small number of bytes to reduce CPU/IO overhead. + +- Tuning options (place these in `upload-logger.json`): + - `limits.sniff_max_bytes` (integer): number of bytes to read from the file head for scanning. Default: `8192`. + - `limits.sniff_max_filesize` (integer): only perform head-scan on files with size <= this value. Default: `2097152` (2 MB). + - `allowlists.ctypes` (array): content-types that should be considered trusted for base64/raw payloads (for example `image/svg+xml`, `application/xml`, `text/xml`) and may relax some detections. + - `allowlists.base64_uris` (array): URI patterns that should be ignored for large base64 payloads (webhooks, avatar uploads, etc.). + +- False positives: `eval(` and other tokens commonly appear in client-side JS inside SVG files or in benign templating contexts. If you observe false positives: + - Add trusted URIs to `allowlists.base64_uris` for endpoints that legitimately accept encoded content. + - Add trusted content-types to `allowlists.ctypes` to relax detection for XML/SVG uploads. + - Tune `limits.sniff_max_bytes` and `limits.sniff_max_filesize` to increase or decrease sensitivity. + +- Suggested (example) detector tuning block (commented): + +```json +// "detectors": { +// "content": { +// "enabled": true, +// "sniff_max_bytes": 8192, +// "sniff_max_filesize": 2097152, +// "allow_xml_eval": false +// } +// } +``` + +Remove the leading `// ` when copying these example snippets into a real `upload-logger.json` file. # 🔐 Per-Site PHP Upload Guard Integration Guide This guide explains how to integrate a global PHP upload monitoring script @@ -44,6 +140,7 @@ mkdir .security/logs ```` Set secure permissions: + - Set secure permissions: ```bash chown -R root:www-data .security @@ -51,6 +148,14 @@ chmod 750 .security chmod 750 .security/logs ``` +Quarantine hardening (important): + + - Ensure the quarantine directory is owner `root`, group `www-data`, and mode `0700` so quarantined files are not accessible to other system users. Example provisioning script `scripts/provision_dirs.sh` now enforces these permissions and tightens existing files to `0600`. + + - If using Ansible, the playbook `scripts/ansible/upload-logger-provision.yml` includes a task that sets any existing files in the quarantine directory to `0600` and enforces owner/group. + + - Verify SELinux/AppArmor contexts after provisioning; the script attempts to register fcontext entries and calls `restorecon` when available. + --- ## 📄 3. Install the Upload Guard Script diff --git a/README.md b/README.md index 4f65b52..ae22f3f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,20 @@ 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. +- The script uses `is_uploaded_file()`/`finfo` where available; ensure the PHP `fileinfo` extension is enabled for best MIME detection. + +Content detector & tuning + +- `ContentDetector` is now included and performs a fast head-scan of uploaded files to detect PHP open-tags and common webshell indicators (e.g., `passthru()`, `system()`, `exec()`, `shell_exec()`, `proc_open()`, `popen()`, `base64_decode()`, `eval()`, `assert()`). +- The detector only scans the first N bytes of a file to limit CPU/io work; tune these limits in `upload-logger.json`: + - `limits.sniff_max_bytes` — number of bytes to scan from file head (default `8192`). + - `limits.sniff_max_filesize` — only scan files up to this size in bytes (default `2097152` / 2MB). +- Behavior note: `eval()` and similar tokens commonly appear inside SVG/JS contexts. The detector uses the detected MIME to be more permissive for XML/SVG-like content, but you should test and tune for your application's upload patterns to avoid false positives (see `INTEGRATION.md`). +- If your application legitimately accepts encoded or templated payloads, add application-specific allowlist rules (URI or content-type) in `allowlist.json` or extend `upload-logger.json` with detector-specific tuning before enabling blocking mode. +Further integration +- Read the `INTEGRATION.md` for a commented example `upload-logger.json`, logrotate hints, and deployment caveats. +- Provision the required directories (`quarantine`, `state`) and set ownership/SELinux via the included provisioning script: `scripts/provision_dirs.sh`. +- Example automation: `scripts/ansible/upload-logger-provision.yml` and `scripts/systemd/upload-logger-provision.service` are included as examples to run provisioning at deploy-time or boot. Operational recommendations - Place the `logs/` directory outside the webroot or deny web access to it. @@ -56,3 +70,7 @@ Support & changes --- Generated for upload-logger.php (v3). + +## Additional documentation + +- Auto-merge & Dependabot: [docs/auto-merge.md](docs/auto-merge.md) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ad2a615 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "upload-logger/upload-logger", + "description": "Hardened PHP upload logger with quarantine and detectors", + "type": "library", + "require": { + "php": "^8.0" + }, + "autoload": { + "psr-4": { + "UploadLogger\\Core\\": "core/", + "UploadLogger\\Detectors\\": "detectors/" + } + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6" + }, + "scripts": { + "test": "phpunit --configuration phpunit.xml", + "analyze": "vendor/bin/phpstan analyse -c phpstan.neon" + } +} diff --git a/config/upload-logger.blocking.json b/config/upload-logger.blocking.json new file mode 100644 index 0000000..6c84e01 --- /dev/null +++ b/config/upload-logger.blocking.json @@ -0,0 +1,43 @@ +{ + "modules": { + "flood": true, + "mime_sniff": true, + "base64_detection": true, + "quarantine": true, + "archive_inspect": true + }, + "paths": { + "quarantine_dir": "/var/lib/upload-logger/quarantine", + "state_dir": "/var/lib/upload-logger/state", + "allowlist_file": "/etc/upload-logger/allowlist.json" + }, + "limits": { + "max_size": 52428800, + "raw_body_min": 512000, + "sniff_max_bytes": 8192, + "sniff_max_filesize": 2097152, + "hash_max_filesize": 10485760, + "flood_max_uploads": 40, + "archive_max_entries": 200, + "archive_max_inspect_size": 52428800 + }, + "ops": { + "block_suspicious": true, + "quarantine_enabled": true, + "archive_block_on_suspicious": true, + "log_user_agent": true, + "trusted_proxy_ips": ["127.0.0.1", "::1"] + }, + "allowlists": { + "base64_uris": [], + "ctypes": [] + }, + "detectors": { + "content": { + "sniff_max_bytes": 8192, + "sniff_max_filesize": 2097152, + "allow_xml_eval": false, + "custom_patterns": [] + } + } +} diff --git a/config/upload-logger.prod.json b/config/upload-logger.prod.json new file mode 100644 index 0000000..884b4d0 --- /dev/null +++ b/config/upload-logger.prod.json @@ -0,0 +1,43 @@ +{ + "modules": { + "flood": true, + "mime_sniff": true, + "base64_detection": true, + "quarantine": true, + "archive_inspect": true + }, + "paths": { + "quarantine_dir": "/var/lib/upload-logger/quarantine", + "state_dir": "/var/lib/upload-logger/state", + "allowlist_file": "/etc/upload-logger/allowlist.json" + }, + "limits": { + "max_size": 52428800, + "raw_body_min": 512000, + "sniff_max_bytes": 8192, + "sniff_max_filesize": 2097152, + "hash_max_filesize": 10485760, + "flood_max_uploads": 40, + "archive_max_entries": 200, + "archive_max_inspect_size": 52428800 + }, + "ops": { + "block_suspicious": false, + "quarantine_enabled": true, + "archive_block_on_suspicious": false, + "log_user_agent": true, + "trusted_proxy_ips": ["127.0.0.1", "::1"] + }, + "allowlists": { + "base64_uris": [], + "ctypes": [] + }, + "detectors": { + "content": { + "sniff_max_bytes": 8192, + "sniff_max_filesize": 2097152, + "allow_xml_eval": false, + "custom_patterns": [] + } + } +} diff --git a/core/Config.php b/core/Config.php new file mode 100644 index 0000000..41b75c8 --- /dev/null +++ b/core/Config.php @@ -0,0 +1,64 @@ + */ + private array $data; + + /** + * @param array $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 + */ + public function toArray(): array + { + return $this->data; + } +} diff --git a/core/Context.php b/core/Context.php new file mode 100644 index 0000000..4df0422 --- /dev/null +++ b/core/Context.php @@ -0,0 +1,123 @@ + */ + private array $extra; + + /** + * @param array $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 + */ + public function getExtra(): array + { + return $this->extra; + } + + /** + * @return array + */ + 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, + ]; + } +} diff --git a/core/DetectorInterface.php b/core/DetectorInterface.php new file mode 100644 index 0000000..2adcd98 --- /dev/null +++ b/core/DetectorInterface.php @@ -0,0 +1,19 @@ + $input + * @return array Structured detection output for logging. + */ + public function detect(Context $context, array $input = []): array; +} diff --git a/core/Dispatcher.php b/core/Dispatcher.php new file mode 100644 index 0000000..80661b4 --- /dev/null +++ b/core/Dispatcher.php @@ -0,0 +1,340 @@ +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> $files + * @param array $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'); + } + } + } +} diff --git a/core/Logger.php b/core/Logger.php new file mode 100644 index 0000000..59df7cf --- /dev/null +++ b/core/Logger.php @@ -0,0 +1,78 @@ + */ + private array $context; + + /** + * @param array $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 $context + */ + public function setContext(array $context): void + { + $this->context = $context; + } + + /** + * @param array $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); + } +} diff --git a/core/Services/FloodService.php b/core/Services/FloodService.php new file mode 100644 index 0000000..2dd7b93 --- /dev/null +++ b/core/Services/FloodService.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/core/Services/HashService.php b/core/Services/HashService.php new file mode 100644 index 0000000..bb04711 --- /dev/null +++ b/core/Services/HashService.php @@ -0,0 +1,37 @@ +config = $config ?? new Config(['modules' => []]); + } + + /** + * @param string $tmpPath + * @param int $size + * @return array + */ + 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; + } +} diff --git a/core/Services/LogService.php b/core/Services/LogService.php new file mode 100644 index 0000000..5df574f --- /dev/null +++ b/core/Services/LogService.php @@ -0,0 +1,60 @@ + */ + private array $ctx; + + /** + * @param string $logFile + * @param array $ctx + */ + public function __construct(string $logFile, array $ctx = []) + { + $this->logFile = $logFile; + $this->ctx = $ctx; + } + + /** + * @param string $event + * @param array $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); + } +} diff --git a/core/Services/QuarantineService.php b/core/Services/QuarantineService.php new file mode 100644 index 0000000..9de791c --- /dev/null +++ b/core/Services/QuarantineService.php @@ -0,0 +1,136 @@ +config = $config ?? new Config(['modules' => []]); + } + + /** + * @param string $tmpPath + * @param string $origName + * @param array $hashes + * @return array + */ + 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 + */ + 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; + } +} diff --git a/core/Services/RequestService.php b/core/Services/RequestService.php new file mode 100644 index 0000000..099570c --- /dev/null +++ b/core/Services/RequestService.php @@ -0,0 +1,73 @@ + $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]; + } +} diff --git a/core/Services/SnifferService.php b/core/Services/SnifferService.php new file mode 100644 index 0000000..1ca5c9f --- /dev/null +++ b/core/Services/SnifferService.php @@ -0,0 +1,153 @@ +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; + } +} diff --git a/detectors/ContentDetector.php b/detectors/ContentDetector.php new file mode 100644 index 0000000..1e8b16f --- /dev/null +++ b/detectors/ContentDetector.php @@ -0,0 +1,114 @@ +config = $config; + } + + public function getName(): string + { + return 'content'; + } + + /** + * @param array $input + * @return array + */ + public function detect(Context $context, array $input = []): array + { + $tmp = (string)($input['tmp'] ?? ''); + $size = (int)($input['size'] ?? 0); + $realMime = (string)($input['real_mime'] ?? ''); + + $suspicious = false; + $reasons = []; + + if ($tmp === '' || !is_file($tmp)) { + return ['suspicious' => false, 'reasons' => []]; + } + + // Determine limits from Config if provided, otherwise use defaults + $maxBytes = 8192; + $maxFilesize = 2 * 1024 * 1024; + $allowXmlEval = false; + $customPatterns = []; + + if ($this->config instanceof Config) { + $maxBytes = (int)$this->config->get('detectors.content.sniff_max_bytes', $this->config->get('limits.sniff_max_bytes', $maxBytes)); + $maxFilesize = (int)$this->config->get('detectors.content.sniff_max_filesize', $this->config->get('limits.sniff_max_filesize', $maxFilesize)); + $allowXmlEval = (bool)$this->config->get('detectors.content.allow_xml_eval', false); + $customPatterns = (array)$this->config->get('detectors.content.custom_patterns', []); + } + + if ($size <= 0) { + $size = @filesize($tmp) ?: 0; + } + if ($size <= 0 || $size > $maxFilesize) { + return ['suspicious' => false, 'reasons' => []]; + } + + $bytes = min($maxBytes, $size); + $maxlen = $bytes > 0 ? $bytes : null; + $head = @file_get_contents($tmp, false, null, 0, $maxlen); + if ($head === false || $head === '') { + return ['suspicious' => false, 'reasons' => []]; + } + + $scan = $head; + + // Detect PHP open tags (avoid matching $suspicious, 'reasons' => array_values(array_unique($reasons))]; + } +} diff --git a/detectors/FilenameDetector.php b/detectors/FilenameDetector.php new file mode 100644 index 0000000..634f7bc --- /dev/null +++ b/detectors/FilenameDetector.php @@ -0,0 +1,66 @@ + $input + * @return array + */ + public function detect(Context $context, array $input = []): array + { + $name = (string)($input['name'] ?? ''); + $origName = (string)($input['orig_name'] ?? $name); + $suspicious = false; + $reasons = []; + + if ($origName !== $name || strpos($origName, '/') !== false || strpos($origName, '\\') !== false) { + $suspicious = true; + $reasons[] = 'bad_name'; + } + + if ($this->isSuspiciousFilename($name)) { + $suspicious = true; + $reasons[] = 'bad_name'; + } + + return [ + 'suspicious' => $suspicious, + 'reasons' => $reasons, + ]; + } + + private function isSuspiciousFilename(string $name): bool + { + $n = strtolower($name); + + if (strpos($n, '../') !== false || strpos($n, '..\\') !== false || strpos($n, "\0") !== false) { + return true; + } + + if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $n)) { + return true; + } + + if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)\./i', $n)) { + return true; + } + + if (preg_match('/^\.(php|phtml|phar|php\d)/i', $n)) { + return true; + } + + return false; + } +} diff --git a/detectors/MimeDetector.php b/detectors/MimeDetector.php new file mode 100644 index 0000000..8a49377 --- /dev/null +++ b/detectors/MimeDetector.php @@ -0,0 +1,49 @@ + $input + * @return array + */ + public function detect(Context $context, array $input = []): array + { + $name = (string)($input['name'] ?? ''); + $realMime = (string)($input['real_mime'] ?? 'unknown'); + + $suspicious = false; + $reasons = []; + + if ($this->isFakeImage($name, $realMime)) { + $suspicious = true; + $reasons[] = 'fake_image'; + } + + return [ + 'suspicious' => $suspicious, + 'reasons' => $reasons, + ]; + } + + private 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; + } +} diff --git a/docs/auto-merge.md b/docs/auto-merge.md new file mode 100644 index 0000000..2e7c550 --- /dev/null +++ b/docs/auto-merge.md @@ -0,0 +1,17 @@ +# Auto-merge & Dependabot: repository settings + +This project enables automated dependency updates (Dependabot) and a workflow that will enable GitHub auto-merge for Dependabot security PRs. To ensure auto-merge works correctly, the repository must be configured as follows: + +- **Allow auto-merge**: enable Allow auto-merge in the repository settings (Settings → General → Merge button → Allow auto-merge). +- **Branch protection**: configure branch protection for your main branch so that required status checks (CI) are set and required before merging. Dependabot PRs will only be merged automatically after required checks pass. +- **Required checks**: ensure the CI workflow `.github/workflows/ci.yml` is listed as a required check in your branch-protection rules (it runs PHPUnit, PHPStan and Composer Audit). +- **Bot permissions**: the default `GITHUB_TOKEN` used by workflows has permission to enable auto-merge; ensure Actions are allowed to create pull requests and manage them in repository settings if you have tightened permissions. + +## How to revoke or disable auto-merge + +- **Disable for a single PR**: open the PR and click the `Auto-merge` button to turn it off (or remove the `security` label). The workflow also posts a comment when it enables auto-merge. +- **Disable global auto-merge**: Repository Settings → Merge button → uncheck **Allow auto-merge**. +- **Disable the automation workflow**: remove or rename `.github/workflows/auto-merge-dependabot.yml` to stop the automatic enabling step. +- **Disable Dependabot updates**: remove or rename `.github/dependabot.yml` or change its `schedule` to `interval: "never"`. + +If you want stricter control, enable protected branch rules that require review approvals before merge; auto-merge will still wait for those approvals unless allowed by your protection policy. diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..95f7bfb --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,57 @@ +# Release & Deploy Checklist + +This checklist helps you deploy `upload-logger.php` to production safely. + +## Pre-release + +- [ ] Review and pin configuration in `upload-logger.json` (see `examples/upload-logger.json`). +- [ ] Ensure unit tests pass and CI workflows are green for the release branch. +- [ ] Run static analysis (`vendor/bin/phpstan analyse`) and fix any new issues. +- [ ] Run `composer audit` to confirm no advisories remain. +- [ ] Confirm branch protection and required checks are enabled for `main`/`master`. + +## Infrastructure & permissions + +- [ ] Create directories with correct ownership and permissions: + - `logs/` — writeable by PHP-FPM user; ensure outside the webroot or blocked by web server. + - `quarantine/` — writeable by PHP-FPM user; should be secured and not executable. + - `state/` — writeable by PHP-FPM user; used for flood counters and transient state. + +- Recommended permissions (adjust to your environment): + - Owner: root (or deploy user) + - Group: web server group (e.g., `www-data`) + - `logs/` directory: `chmod 750` (owner rwx, group r-x) + - Log files: `chmod 640` (owner rw, group r-) + - `quarantine/` and `state/`: `chmod 750` + +- SELinux/AppArmor: apply proper contexts/profiles so PHP-FPM can write to `logs/`, `quarantine/`, and `state/`. + +## Configuration + +- [ ] Create `upload-logger.json` from `examples/upload-logger.json` and adjust values: + - `paths.quarantine_dir` — absolute path to `quarantine/`. + - `paths.state_dir` — absolute path to `state/`. + - `paths.allowlist_file` — path to `allowlist.json`. + - `limits.*` — tune `max_size`, `sniff_max_bytes`, etc., for your environment. + - `ops.block_suspicious` — set to `false` initially to observe alerts, then `true` once tuned. + +## Deployment + +- [ ] Ensure `php_admin_value[auto_prepend_file]` is configured in the site pool for PHP-FPM to include `upload-logger.php`. +- [ ] Reload or restart PHP-FPM gracefully after changing pool settings. +- [ ] Verify the web server denies direct access to `logs/` and `quarantine/`. + +## Validation + +- [ ] Run integration tests / smoke tests (upload small benign files, large files, multipart without files, raw-body requests). +- [ ] Confirm logs are written with expected fields and no sensitive information is recorded. +- [ ] Inspect quarantine behavior by uploading archive files and verifying entries are quarantined and inspected. +- [ ] Monitor CPU and IO while running detectors on sample traffic to ensure acceptable overhead. + +## Post-release + +- [ ] Configure log rotation (see `examples/logrotate.d/upload-logger`). +- [ ] Set up monitoring/alerting on log file growth, error events, and flood alerts. +- [ ] Schedule periodic dependency checks (Dependabot and weekly `composer audit`). +- [ ] Periodically review `allowlist.json` and detector tuning to reduce false positives. + diff --git a/examples/logrotate.d/upload-logger b/examples/logrotate.d/upload-logger new file mode 100644 index 0000000..26d98be --- /dev/null +++ b/examples/logrotate.d/upload-logger @@ -0,0 +1,9 @@ +/path/to/your/project/logs/uploads.log { + daily + rotate 14 + compress + missingok + notifempty + copytruncate + create 0640 www-data www-data +} diff --git a/examples/upload-logger.json b/examples/upload-logger.json new file mode 100644 index 0000000..7428715 --- /dev/null +++ b/examples/upload-logger.json @@ -0,0 +1,43 @@ +{ + "modules": { + "flood": true, + "mime_sniff": true, + "base64_detection": true, + "quarantine": true, + "archive_inspect": true + }, + "paths": { + "quarantine_dir": "./quarantine", + "state_dir": "./state", + "allowlist_file": "./allowlist.json" + }, + "limits": { + "max_size": 52428800, + "raw_body_min": 512000, + "sniff_max_bytes": 8192, + "sniff_max_filesize": 2097152, + "hash_max_filesize": 10485760, + "flood_max_uploads": 40, + "archive_max_entries": 200, + "archive_max_inspect_size": 52428800 + }, + "ops": { + "block_suspicious": false, + "quarantine_enabled": true, + "archive_block_on_suspicious": false, + "log_user_agent": true, + "trusted_proxy_ips": ["127.0.0.1", "::1"] + }, + "allowlists": { + "base64_uris": [], + "ctypes": [] + }, + "detectors": { + "content": { + "sniff_max_bytes": 8192, + "sniff_max_filesize": 2097152, + "allow_xml_eval": false, + "custom_patterns": [] + } + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5f8f2a1 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 5 + paths: + - core + - detectors + bootstrapFiles: + - %currentWorkingDirectory%/vendor/autoload.php diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..eaee26a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/scripts/ansible/provision-full.yml b/scripts/ansible/provision-full.yml new file mode 100644 index 0000000..2913d6d --- /dev/null +++ b/scripts/ansible/provision-full.yml @@ -0,0 +1,104 @@ +--- +# Full Ansible playbook to provision upload-logger directories, permissions, tmpfiles and logrotate. +# Usage: ansible-playbook -i inventory scripts/ansible/provision-full.yml + +- hosts: web + become: true + vars: + upload_logger_root: "{{ playbook_dir | default('.') | dirname | realpath }}" + logs_dir: "{{ upload_logger_root }}/logs" + quarantine_dir: "{{ upload_logger_root }}/quarantine" + state_dir: "{{ upload_logger_root }}/state" + examples_dir: "{{ upload_logger_root }}/examples" + quarantine_owner: "root" + quarantine_group: "www-data" + quarantine_perms: "0700" + state_perms: "0750" + logs_perms: "0750" + log_file_mode: "0640" + selinux_fcontext: "httpd_sys_rw_content_t" + tmpfiles_conf: "/etc/tmpfiles.d/upload-logger.conf" + logrotate_dest: "/etc/logrotate.d/upload-logger" + + tasks: + - name: Ensure logs directory exists + file: + path: "{{ logs_dir }}" + state: directory + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + mode: "{{ logs_perms }}" + + - name: Ensure quarantine directory exists + file: + path: "{{ quarantine_dir }}" + state: directory + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + mode: "{{ quarantine_perms }}" + + - name: Ensure state directory exists + file: + path: "{{ state_dir }}" + state: directory + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + mode: "{{ state_perms }}" + + - name: Ensure example upload-logger.json is copied (only when missing) + copy: + src: "{{ examples_dir }}/upload-logger.json" + dest: "{{ upload_logger_root }}/upload-logger.json" + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + mode: "0644" + when: not (upload_logger_root + '/upload-logger.json') | path_exists + + - name: Install tmpfiles.d entry to recreate dirs at boot + copy: + dest: "{{ tmpfiles_conf }}" + content: | + d {{ quarantine_dir }} {{ quarantine_perms }} {{ quarantine_owner }} {{ quarantine_group }} - + d {{ state_dir }} {{ state_perms }} {{ quarantine_owner }} {{ quarantine_group }} - + owner: root + group: root + mode: '0644' + + - name: Install logrotate snippet if example exists + copy: + src: "{{ examples_dir }}/logrotate.d/upload-logger" + dest: "{{ logrotate_dest }}" + owner: root + group: root + mode: '0644' + when: (examples_dir + '/logrotate.d/upload-logger') | path_exists + + - name: Set SELinux fcontext for directories when selinux enabled + when: ansible_selinux.status == 'enabled' + sefcontext: + target: "{{ item }}(/.*)?" + setype: "{{ selinux_fcontext }}" + loop: + - "{{ quarantine_dir }}" + - "{{ state_dir }}" + - "{{ logs_dir }}" + + - name: Apply SELinux contexts + when: ansible_selinux.status == 'enabled' + command: restorecon -Rv {{ quarantine_dir }} {{ state_dir }} {{ logs_dir }} + + - name: Ensure log file exists with correct mode (touch) + file: + path: "{{ logs_dir }}/uploads.log" + state: touch + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + mode: "{{ log_file_mode }}" + + - name: Summary - show directories + debug: + msg: | + Provisioned: + - logs: {{ logs_dir }} (owner={{ quarantine_owner }} group={{ quarantine_group }} mode={{ logs_perms }}) + - quarantine: {{ quarantine_dir }} (owner={{ quarantine_owner }} group={{ quarantine_group }} mode={{ quarantine_perms }}) + - state: {{ state_dir }} (owner={{ quarantine_owner }} group={{ quarantine_group }} mode={{ state_perms }}) diff --git a/scripts/ansible/upload-logger-provision.yml b/scripts/ansible/upload-logger-provision.yml new file mode 100644 index 0000000..527426a --- /dev/null +++ b/scripts/ansible/upload-logger-provision.yml @@ -0,0 +1,63 @@ +--- +# Ansible playbook snippet to provision upload-logger directories and permissions. +# Usage: ansible-playbook -i inventory scripts/ansible/upload-logger-provision.yml + +- hosts: web + become: true + vars: + upload_logger_root: "{{ playbook_dir | default('.') | dirname | realpath }}" + quarantine_dir: "{{ upload_logger_root }}/quarantine" + state_dir: "{{ upload_logger_root }}/state" + quarantine_owner: "root" + quarantine_group: "www-data" + quarantine_perms: "0700" + state_perms: "0750" + selinux_fcontext: "httpd_sys_rw_content_t" + + tasks: + - name: Ensure quarantine directory exists + file: + path: "{{ quarantine_dir }}" + state: directory + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + mode: "{{ quarantine_perms }}" + + - name: Ensure state directory exists + file: + path: "{{ state_dir }}" + state: directory + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + mode: "{{ state_perms }}" + + - name: Ensure quarantined files have strict permissions (files -> 0600) + find: + paths: "{{ quarantine_dir }}" + file_type: file + register: quarantine_files + + - name: Set strict mode on existing quarantined files + file: + path: "{{ item.path }}" + mode: '0600' + owner: "{{ quarantine_owner }}" + group: "{{ quarantine_group }}" + loop: "{{ quarantine_files.files }}" + when: quarantine_files.matched > 0 + + - name: Set SELinux fcontext for quarantine dir (when selinux enabled) + when: ansible_selinux.status == 'enabled' + sefcontext: + target: "{{ quarantine_dir }}(/.*)?" + setype: "{{ selinux_fcontext }}" + + - name: Set SELinux fcontext for state dir (when selinux enabled) + when: ansible_selinux.status == 'enabled' + sefcontext: + target: "{{ state_dir }}(/.*)?" + setype: "{{ selinux_fcontext }}" + + - name: Apply SELinux contexts + when: ansible_selinux.status == 'enabled' + command: restorecon -Rv {{ quarantine_dir }} {{ state_dir }} diff --git a/scripts/deploy_provision.sh b/scripts/deploy_provision.sh new file mode 100644 index 0000000..6fc8ca7 --- /dev/null +++ b/scripts/deploy_provision.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Wrapper to provision upload-logger directories using Ansible if available, +# otherwise falling back to the included provision_dirs.sh script. +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ANSIBLE_PLAYBOOK="$(command -v ansible-playbook || true)" +PLAYBOOK_PATH="$ROOT_DIR/scripts/ansible/provision-full.yml" +FALLBACK_SCRIPT="$ROOT_DIR/scripts/provision_dirs.sh" + +if [[ -n "$ANSIBLE_PLAYBOOK" && -f "$PLAYBOOK_PATH" ]]; then + echo "Running Ansible playbook: $PLAYBOOK_PATH" + # Use local connection if running on the target host + if [[ "$1" == "local" ]]; then + sudo ansible-playbook -i localhost, -c local "$PLAYBOOK_PATH" + else + sudo ansible-playbook "$PLAYBOOK_PATH" + fi +else + echo "Ansible not available or playbook missing; using fallback script" + sudo "$FALLBACK_SCRIPT" "$@" +fi diff --git a/scripts/provision_dirs.sh b/scripts/provision_dirs.sh new file mode 100644 index 0000000..5bd7352 --- /dev/null +++ b/scripts/provision_dirs.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail +# Provision quarantine and state directories for upload-logger +# Usage: sudo ./provision_dirs.sh [--config path/to/upload-logger.json] + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CFG="${1:-$ROOT_DIR/upload-logger.json}" + +QUIET=0 +if [[ "${2:-}" == "--quiet" ]]; then QUIET=1; fi + +info(){ if [[ $QUIET -ne 1 ]]; then echo "[INFO] $*"; fi } +err(){ echo "[ERROR] $*" >&2; } + +if [[ $EUID -ne 0 ]]; then + err "This script must be run as root to set ownership and SELinux contexts." + err "Rerun as: sudo $0" + exit 2 +fi + +# Defaults +QUARANTINE_DIR_DEFAULT="$ROOT_DIR/quarantine" +STATE_DIR_DEFAULT="$ROOT_DIR/state" +QUARANTINE_OWNER_DEFAULT="root" +QUARANTINE_GROUP_DEFAULT="www-data" +QUARANTINE_PERMS_DEFAULT="0700" +STATE_PERMS_DEFAULT="0750" +SELINUX_FCONTEXT_DEFAULT="httpd_sys_rw_content_t" + +QUARANTINE_DIR="$QUARANTINE_DIR_DEFAULT" +STATE_DIR="$STATE_DIR_DEFAULT" +QUARANTINE_OWNER="$QUARANTINE_OWNER_DEFAULT" +QUARANTINE_GROUP="$QUARANTINE_GROUP_DEFAULT" +QUARANTINE_PERMS="$QUARANTINE_PERMS_DEFAULT" +STATE_PERMS="$STATE_PERMS_DEFAULT" +SELINUX_FCONTEXT="$SELINUX_FCONTEXT_DEFAULT" + +if [[ -f "$CFG" ]]; then + info "Loading config from $CFG" + # Use jq-like parsing with grep/sed to avoid requiring jq on systems + QUARANTINE_DIR=$(grep -oP '"quarantine_dir"\s*:\s*"\K[^"]+' "$CFG" || echo "$QUARANTINE_DIR") + STATE_DIR=$(grep -oP '"state_dir"\s*:\s*"\K[^"]+' "$CFG" || echo "$STATE_DIR") + QUARANTINE_PERMS=$(grep -oP '"quarantine_dir_perms"\s*:\s*"\K[^"]+' "$CFG" || echo "$QUARANTINE_PERMS") + QUARANTINE_OWNER=$(grep -oP '"quarantine_owner"\s*:\s*"\K[^"]+' "$CFG" || echo "$QUARANTINE_OWNER") + QUARANTINE_GROUP=$(grep -oP '"quarantine_group"\s*:\s*"\K[^"]+' "$CFG" || echo "$QUARANTINE_GROUP") +fi + +info "Ensuring directories exist" +mkdir -p -- "$QUARANTINE_DIR" +mkdir -p -- "$STATE_DIR" + +info "Setting permissions and ownership" +chmod ${QUARANTINE_PERMS} "$QUARANTINE_DIR" || true +chown ${QUARANTINE_OWNER}:${QUARANTINE_GROUP} "$QUARANTINE_DIR" || true +chmod ${STATE_PERMS} "$STATE_DIR" || true +chown ${QUARANTINE_OWNER}:${QUARANTINE_GROUP} "$STATE_DIR" || true + +# Ensure existing files in quarantine are not world-readable/executable. +if [[ -d "$QUARANTINE_DIR" ]]; then + info "Hardening existing files in $QUARANTINE_DIR" + # Set files to 0600 and directories to 0700 + find "$QUARANTINE_DIR" -type d -print0 | xargs -0 -r chmod 0700 || true + find "$QUARANTINE_DIR" -type f -print0 | xargs -0 -r chmod 0600 || true + chown -R ${QUARANTINE_OWNER}:${QUARANTINE_GROUP} "$QUARANTINE_DIR" || true +fi + +info "Verifying permissions" +ls -ld "$QUARANTINE_DIR" "$STATE_DIR" + +# SELinux handling (best-effort) +if command -v getenforce >/dev/null 2>&1 && [[ "$(getenforce)" != "Disabled" ]]; then + info "SELinux enabled on system; attempting to configure file context" + if command -v semanage >/dev/null 2>&1; then + info "Registering fcontext for $QUARANTINE_DIR -> $SELINUX_FCONTEXT" + semanage fcontext -a -t ${SELINUX_FCONTEXT} "${QUARANTINE_DIR}(/.*)?" || true + info "Registering fcontext for $STATE_DIR -> ${SELINUX_FCONTEXT}" + semanage fcontext -a -t ${SELINUX_FCONTEXT} "${STATE_DIR}(/.*)?" || true + info "Applying contexts with restorecon" + restorecon -Rv "$QUARANTINE_DIR" "$STATE_DIR" || true + else + info "semanage not available; skipping fcontext registration. Install policycoreutils-python-utils or provide manual guidance." + fi +else + info "SELinux not enabled or getenforce unavailable; skipping SELinux steps" +fi + +# Optional tmpfiles.d entry to recreate directories at boot (idempotent) +TMPFILE="/etc/tmpfiles.d/upload-logger.conf" +if [[ -w /etc/tmpfiles.d || $QUIET -eq 1 ]]; then + info "Writing tmpfiles.d entry to ${TMPFILE}" + cat > "$TMPFILE" <&2 + exit 2 +fi + +if [[ ! -f "$PROD_CFG" ]]; then + echo "Prod config not found: $PROD_CFG" >&2 + exit 2 +fi + +if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY RUN: Would replace $ACTIVE_CFG with $BLOCK_CFG" + echo "DRY RUN: Would reload PHP-FPM (if present)" + exit 0 +fi + +if [[ $CONFIRM -ne 1 ]]; then + echo "This will replace $ACTIVE_CFG with the blocking config and reload PHP-FPM." + echo "Run with --confirm to proceed, or --dry-run to preview." + exit 1 +fi + +mkdir -p "$BACKUP_DIR" +TS=$(date +%Y%m%dT%H%M%S) +if [[ -f "$ACTIVE_CFG" ]]; then + cp -a "$ACTIVE_CFG" "$BACKUP_DIR/upload-logger.json.bak.$TS" + echo "Backed up current config to $BACKUP_DIR/upload-logger.json.bak.$TS" +fi + +cp -a "$BLOCK_CFG" "$ACTIVE_CFG" +echo "Copied blocking config to $ACTIVE_CFG" + +# Try to reload PHP-FPM gracefully using common service names +RELOADED=0 +if command -v systemctl >/dev/null 2>&1; then + for svc in php-fpm php7.4-fpm php8.0-fpm php8.1-fpm php8.2-fpm; do + if systemctl list-units --full -all | grep -q "^${svc}\.service"; then + echo "Reloading $svc" + systemctl reload "$svc" || systemctl restart "$svc" + RELOADED=1 + break + fi + done +fi + +if [[ $RELOADED -eq 0 ]]; then + if command -v service >/dev/null 2>&1; then + for svc in php7.4-fpm php8.0-fpm php8.1-fpm php8.2-fpm php-fpm; do + if service --status-all 2>&1 | grep -q "$svc"; then + echo "Reloading $svc via service" + service "$svc" reload || service "$svc" restart + RELOADED=1 + break + fi + done + fi +fi + +if [[ $RELOADED -eq 0 ]]; then + echo "Warning: could not detect PHP-FPM service to reload. Please reload PHP-FPM manually." +else + echo "PHP-FPM reloaded; blocking config is active." +fi + +echo "Rollout complete. Monitor logs and be ready to rollback if necessary." diff --git a/scripts/systemd/upload-logger-provision.service b/scripts/systemd/upload-logger-provision.service new file mode 100644 index 0000000..e943374 --- /dev/null +++ b/scripts/systemd/upload-logger-provision.service @@ -0,0 +1,11 @@ +[Unit] +Description=Upload Logger provisioning (one-shot) +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/upload-logger-provision.sh /opt/upload-logger/upload-logger.json +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..0bf7303 --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,22 @@ + ['flood' => true], + 'limits' => ['max_size' => 12345, 'nested' => ['x' => 5]], + ]; + + $cfg = new Config($data); + + $this->assertTrue($cfg->isModuleEnabled('flood')); + $this->assertEquals(12345, $cfg->get('limits.max_size')); + $this->assertEquals(5, $cfg->get('limits.nested.x')); + $this->assertNull($cfg->get('limits.nonexistent')); + } +} diff --git a/tests/ContextTest.php b/tests/ContextTest.php new file mode 100644 index 0000000..6f27fdf --- /dev/null +++ b/tests/ContextTest.php @@ -0,0 +1,21 @@ +assertEquals('r1', $ctx->getRequestId()); + $this->assertEquals('1.2.3.4', $ctx->getIp()); + $this->assertEquals('/u', $ctx->getUri()); + $this->assertEquals('POST', $ctx->getMethod()); + $this->assertEquals('application/json', $ctx->getContentType()); + $this->assertEquals(1000, $ctx->getContentLength()); + $this->assertEquals('user1', $ctx->getUser()); + $this->assertEquals('ua', $ctx->getUserAgent()); + } +} diff --git a/tests/DetectorsTest.php b/tests/DetectorsTest.php new file mode 100644 index 0000000..8f2cf28 --- /dev/null +++ b/tests/DetectorsTest.php @@ -0,0 +1,40 @@ +detect($ctx, ['name' => 'image.php.jpg', 'orig_name' => 'image.php.jpg']); + $this->assertTrue(!empty($res['suspicious'])); + } + + public function testMimeDetector(): void + { + $det = new MimeDetector(); + $ctx = new Context('r','1.1.1.1','/','POST','',0,'guest','', ''); + $res = $det->detect($ctx, ['name' => 'file.jpg', 'real_mime' => 'text/plain']); + $this->assertTrue(!empty($res['suspicious'])); + } + + public function testContentDetectorDetectsPhpTag(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'ul'); + file_put_contents($tmp, ""); + + $det = new ContentDetector(); + $ctx = new Context('r','1.1.1.1','/','POST','text/plain', 10, 'guest','', ''); + $res = $det->detect($ctx, ['tmp' => $tmp, 'size' => filesize($tmp), 'real_mime' => 'text/plain']); + + unlink($tmp); + + $this->assertTrue(!empty($res['suspicious']), 'ContentDetector should flag PHP open tag'); + } +} diff --git a/tests/smoke/create_test_files.ps1 b/tests/smoke/create_test_files.ps1 new file mode 100644 index 0000000..429be11 --- /dev/null +++ b/tests/smoke/create_test_files.ps1 @@ -0,0 +1,4 @@ +# Create sample files for upload smoke tests +Set-Content -Path .\tests\smoke\public\sample.txt -Value "hello world" +Set-Content -Path .\tests\smoke\public\suspicious.php -Value "" +Write-Host "Created sample files." \ No newline at end of file diff --git a/tests/smoke/public/sample.txt b/tests/smoke/public/sample.txt new file mode 100644 index 0000000..3b18e51 --- /dev/null +++ b/tests/smoke/public/sample.txt @@ -0,0 +1 @@ +hello world diff --git a/tests/smoke/public/suspicious.php b/tests/smoke/public/suspicious.php new file mode 100644 index 0000000..57838f1 --- /dev/null +++ b/tests/smoke/public/suspicious.php @@ -0,0 +1 @@ + diff --git a/tests/smoke/public/upload.php b/tests/smoke/public/upload.php new file mode 100644 index 0000000..57b7b2e --- /dev/null +++ b/tests/smoke/public/upload.php @@ -0,0 +1,19 @@ + $v) { + if (is_array($v['name'])) { + $count = count($v['name']); + for ($i = 0; $i < $count; $i++) { + $out[] = $v['name'][$i] . ' -> ' . $v['tmp_name'][$i]; + } + } else { + $out[] = $v['name'] . ' -> ' . $v['tmp_name']; + } +} +echo implode("\n", $out) . "\n"; diff --git a/upload-logger.json b/upload-logger.json new file mode 100644 index 0000000..9dbfb5c --- /dev/null +++ b/upload-logger.json @@ -0,0 +1,56 @@ +{ + "modules": { + "flood": true, + "filename": true, + "mime_sniff": true, + "hashing": true, + "base64_detection": true, + "raw_peek": false, + "archive_inspect": true, + "quarantine": true + }, + "paths": { + "log_file": "logs/uploads.log", + "quarantine_dir": "quarantine", + "state_dir": "state", + "allowlist_file": "allowlist.json" + }, + "limits": { + "max_size": 52428800, + "raw_body_min": 512000, + "sniff_max_bytes": 8192, + "sniff_max_filesize": 2097152, + "hash_max_filesize": 10485760, + "archive_max_inspect_size": 52428800, + "archive_max_entries": 200 + }, + "ops": { + "quarantine_owner": "root", + "quarantine_group": "www-data", + "quarantine_dir_perms": "0700", + "block_suspicious": false, + "log_rotate": { + "enabled": true, + "size": 10485760, + "keep": 7 + } + }, + "allowlists": { + "base64_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 index 7f8637c..69f396d 100644 --- a/upload-logger.php +++ b/upload-logger.php @@ -22,6 +22,24 @@ 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) @@ -112,33 +130,61 @@ if (is_file($ALLOWLIST_FILE)) { } } -function base64_is_allowlisted(string $uri, string $ctype): bool -{ - global $BASE64_ALLOWLIST_URI, $BASE64_ALLOWLIST_CTYPE; +// 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; - 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; +// 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; } } - - 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; } +$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 = get_client_ip(); + $clientIp = $REQ->getClientIp(); if (in_array($clientIp, $TRUSTED_PROXY_IPS, true)) { $headerAllow = true; } @@ -146,9 +192,7 @@ if (isset($_SERVER['HTTP_X_UPLOAD_LOGGER_PEEK']) && $_SERVER['HTTP_X_UPLOAD_LOGG 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]); - } + $BOOT_LOGGER->logEvent('config_info', ['msg' => 'peek_enabled', 'env' => $envAllow, 'file' => $fileAllow, 'header' => $headerAllow]); } // Store flood counters in a protected directory (avoid /tmp tampering) @@ -199,8 +243,8 @@ if ($QUARANTINE_ENABLED) { $mask = $perms & 0x1FF; // if any group/other bits set, warn if (($mask & 0o077) !== 0) { - if (function_exists('log_event')) { - log_event('config_warning', [ + if (isset($BOOT_LOGGER)) { + $BOOT_LOGGER->logEvent('config_warning', [ 'msg' => 'quarantine_dir_perms_not_strict', 'path' => $QUARANTINE_DIR, 'perms_octal' => sprintf('%o', $mask), @@ -209,8 +253,8 @@ if ($QUARANTINE_ENABLED) { } } } else { - if (function_exists('log_event')) { - log_event('config_error', ['msg' => 'quarantine_dir_missing', 'path' => $QUARANTINE_DIR]); + if (isset($BOOT_LOGGER)) { + $BOOT_LOGGER->logEvent('config_error', ['msg' => 'quarantine_dir_missing', 'path' => $QUARANTINE_DIR]); } } } @@ -251,157 +295,21 @@ if ($QUARANTINE_ENABLED && is_dir($QUARANTINE_DIR)) { } 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, - ]); + 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 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 { @@ -430,111 +338,7 @@ function is_suspicious_filename(string $name): bool 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 { @@ -548,158 +352,13 @@ function is_fake_image(string $name, string $realMime): bool 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(); +[$ip, $uri, $method, $ctype, $clen, $ua, $te] = $REQ->getRequestSummary($LOG_USER_AGENT); +$userId = $REQ->getUserId(); +$requestId = $REQ->generateRequestId(); $REQUEST_CTX = [ 'request_id' => $requestId, @@ -713,288 +372,66 @@ $REQUEST_CTX = [ 'transfer_encoding' => $te, ]; -// Only upload-capable methods -if (!in_array($method, ['POST', 'PUT', 'PATCH'], true)) { - return; -} +// Logger instance for structured JSON output +$CONFIG = new \UploadLogger\Core\Config($CONFIG_DATA); -// 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; +$LOGGER = new \UploadLogger\Core\Logger($logFile, $REQUEST_CTX, $CONFIG); - // 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); - } +/* + * 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); - 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; - } - } - } - } +// 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); - if ($rawSuspicious) { - log_event('raw_body', [ - 'len' => (int)$clen, - 'ctype' => $ctype, - ]); - } -} +$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')); -// multipart/form-data but no $_FILES -if ( - empty($_FILES) && - $ctype && - stripos($ctype, 'multipart/form-data') !== false -) { - log_event('multipart_no_files', []); -} +$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)); -/* ---------- Upload Handling ---------- */ +$QUARANTINE_ENABLED = $CONFIG->isModuleEnabled('quarantine') && ($CONFIG->get('ops.quarantine_enabled', $QUARANTINE_ENABLED ?? true)); +$QUARANTINE_DIR = $CONFIG->get('paths.quarantine_dir', $QUARANTINE_DIR ?? (__DIR__ . '/quarantine')); -if (!empty($_FILES)) { +$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)); - // Per request flood check: count each file below too - // (Optional: log the current counter at request-level) - $reqCount = flood_check($ip); +// Detector context and registry +$CONTEXT = new \UploadLogger\Core\Context( + $requestId, + $ip, + $uri, + $method, + $ctype, + (int)$clen, + $userId, + $ua, + $te +); - 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'); } - } +$DETECTORS = [ + new \UploadLogger\Detectors\FilenameDetector(), + new \UploadLogger\Detectors\MimeDetector(), + new \UploadLogger\Detectors\ContentDetector($CONFIG), +]; - foreach ($_FILES as $file) { +// 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); - 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'); - } - } -} +$DISPATCHER = new \UploadLogger\Core\Dispatcher($LOGGER, $CONTEXT, $DETECTORS, $CONFIG, $FLOOD_SERVICE, $SNIFFER_SERVICE, $HASH_SERVICE, $QUARANTINE_SERVICE); +$DISPATCHER->dispatch($_FILES, $_SERVER);