Harden quarantine provisioning; enforce strict permissions and update Ansible and docs

This commit is contained in:
2026-02-12 07:47:48 +01:00
parent 037b176892
commit 1768f61da1
44 changed files with 2587 additions and 698 deletions

13
.github/dependabot.yml vendored Normal file
View File

@@ -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.

View File

@@ -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.' });

89
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

17
AUTO_MERGE.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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)

22
composer.json Normal file
View File

@@ -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"
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

64
core/Config.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core;
/**
* Simple immutable configuration holder for the upload logger.
*/
final class Config
{
/** @var array<string, mixed> */
private array $data;
/**
* @param array<string, mixed> $data
*/
public function __construct(array $data = [])
{
$this->data = $data;
}
/**
* Check whether a module is enabled.
*/
public function isModuleEnabled(string $name): bool
{
$modules = $this->data['modules'] ?? [];
if (!is_array($modules)) return false;
return !empty($modules[$name]);
}
/**
* Get a value with optional default.
* @param mixed $default
* @return mixed
*/
public function get(string $key, mixed $default = null): mixed
{
// Support simple dot-notation for nested keys, e.g. "limits.max_size"
if (strpos($key, '.') === false) {
return $this->data[$key] ?? $default;
}
$parts = explode('.', $key);
$cur = $this->data;
foreach ($parts as $p) {
if (!is_array($cur) || !array_key_exists($p, $cur)) {
return $default;
}
$cur = $cur[$p];
}
return $cur;
}
/**
* Return the raw config array.
* @return array<string,mixed>
*/
public function toArray(): array
{
return $this->data;
}
}

123
core/Context.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core;
/**
* Immutable request context used by detectors and loggers.
*/
final class Context
{
private string $requestId;
private string $ip;
private string $uri;
private string $method;
private string $contentType;
private int $contentLength;
private string $user;
private string $userAgent;
private string $transferEncoding;
/** @var array<string, mixed> */
private array $extra;
/**
* @param array<string, mixed> $extra
*/
public function __construct(
string $requestId,
string $ip,
string $uri,
string $method,
string $contentType,
int $contentLength,
string $user,
string $userAgent,
string $transferEncoding,
array $extra = []
) {
$this->requestId = $requestId;
$this->ip = $ip;
$this->uri = $uri;
$this->method = $method;
$this->contentType = $contentType;
$this->contentLength = $contentLength;
$this->user = $user;
$this->userAgent = $userAgent;
$this->transferEncoding = $transferEncoding;
$this->extra = $extra;
}
public function getRequestId(): string
{
return $this->requestId;
}
public function getIp(): string
{
return $this->ip;
}
public function getUri(): string
{
return $this->uri;
}
public function getMethod(): string
{
return $this->method;
}
public function getContentType(): string
{
return $this->contentType;
}
public function getContentLength(): int
{
return $this->contentLength;
}
public function getUser(): string
{
return $this->user;
}
public function getUserAgent(): string
{
return $this->userAgent;
}
public function getTransferEncoding(): string
{
return $this->transferEncoding;
}
/**
* @return array<string, mixed>
*/
public function getExtra(): array
{
return $this->extra;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'request_id' => $this->requestId,
'ip' => $this->ip,
'uri' => $this->uri,
'method' => $this->method,
'ctype' => $this->contentType,
'clen' => $this->contentLength,
'user' => $this->user,
'ua' => $this->userAgent,
'transfer_encoding' => $this->transferEncoding,
'extra' => $this->extra,
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core;
/**
* Detectors analyze requests and uploads and return findings.
*/
interface DetectorInterface
{
public function getName(): string;
/**
* @param array<string, mixed> $input
* @return array<string, mixed> Structured detection output for logging.
*/
public function detect(Context $context, array $input = []): array;
}

340
core/Dispatcher.php Normal file
View File

@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core;
use UploadLogger\Core\Context;
use UploadLogger\Core\DetectorInterface;
use UploadLogger\Core\Logger;
use UploadLogger\Core\Services\FloodService;
use UploadLogger\Core\Services\SnifferService;
use UploadLogger\Core\Services\HashService;
use UploadLogger\Core\Services\QuarantineService;
/**
* Dispatches request handling, detector execution, and logging.
*/
final class Dispatcher
{
private Logger $logger;
private Context $context;
private ?Config $config = null;
/** @var DetectorInterface[] */
private array $detectors;
private ?FloodService $floodService = null;
private ?SnifferService $snifferService = null;
private ?HashService $hashService = null;
private ?QuarantineService $quarantineService = null;
/**
* @param DetectorInterface[] $detectors
*/
public function __construct(Logger $logger, Context $context, array $detectors = [], ?Config $config = null, ?FloodService $floodService = null, ?SnifferService $snifferService = null, ?HashService $hashService = null, ?QuarantineService $quarantineService = null)
{
$this->logger = $logger;
$this->context = $context;
$this->detectors = $detectors;
$this->config = $config;
$this->floodService = $floodService;
$this->snifferService = $snifferService;
$this->hashService = $hashService;
$this->quarantineService = $quarantineService;
}
private function isModuleEnabled(string $name): bool
{
if ($this->config instanceof Config) {
return $this->config->isModuleEnabled($name);
}
// Enforce config-only behavior: no config supplied => module disabled
return false;
}
/**
* @param array<int, array<string, mixed>> $files
* @param array<string, mixed> $server
*/
public function dispatch(array $files, array $server): void
{
$method = $this->context->getMethod();
if (!in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
return;
}
$ctype = $this->context->getContentType();
$clen = $this->context->getContentLength();
$te = $this->context->getTransferEncoding();
// Raw-body uploads with no multipart
if (empty($files)) {
$this->handleRawBody($ctype, $clen, $te);
}
// multipart/form-data but no $_FILES
if (empty($files) && $ctype && stripos($ctype, 'multipart/form-data') !== false) {
$this->logger->logEvent('multipart_no_files', []);
}
if (empty($files)) {
return;
}
// Per request flood check
if ($this->isModuleEnabled('flood') && $this->floodService !== null) {
$reqCount = $this->floodService->check($this->context->getIp());
$floodMax = 40;
if ($this->config instanceof Config) {
$floodMax = (int)$this->config->get('limits.flood_max_uploads', $floodMax);
}
if ($reqCount > $floodMax) {
$this->logger->logEvent('flood_alert', ['count' => (int)$reqCount]);
}
}
foreach ($files as $file) {
if (!isset($file['name'])) {
continue;
}
// Multi upload field: name[]
if (is_array($file['name'])) {
$count = count($file['name']);
for ($i = 0; $i < $count; $i++) {
$this->handleFileEntry(
(string)($file['name'][$i] ?? ''),
(string)($file['type'][$i] ?? ''),
(int)($file['size'][$i] ?? 0),
(string)($file['tmp_name'][$i] ?? ''),
(int)($file['error'][$i] ?? UPLOAD_ERR_NO_FILE)
);
}
} else {
$this->handleFileEntry(
(string)$file['name'],
(string)($file['type'] ?? ''),
(int)($file['size'] ?? 0),
(string)($file['tmp_name'] ?? ''),
(int)($file['error'] ?? UPLOAD_ERR_NO_FILE)
);
}
}
}
private function handleRawBody(string $ctype, int $clen, string $te): void
{
global $RAW_BODY_MIN, $PEEK_RAW_INPUT, $SNIFF_MAX_FILESIZE, $SNIFF_MAX_BYTES, $BASE64_FINGERPRINT_BYTES;
$rawSuspicious = false;
if ($clen >= $RAW_BODY_MIN) $rawSuspicious = true;
if ($te !== '') $rawSuspicious = true;
if (stripos($ctype, 'application/octet-stream') !== false) $rawSuspicious = true;
if (stripos($ctype, 'application/json') !== false) $rawSuspicious = true;
// Guarded peek into php://input for JSON/base64 payload detection.
if ($this->isModuleEnabled('raw_peek') && $PEEK_RAW_INPUT && $clen > 0 && $clen <= $SNIFF_MAX_FILESIZE) {
$peek = '';
$in = @fopen('php://input', 'r');
if ($in !== false) {
$peek = @stream_get_contents($in, $SNIFF_MAX_BYTES);
@fclose($in);
}
if ($peek !== false && $peek !== '') {
$b = $this->isModuleEnabled('base64_detection') && $this->snifferService !== null ? $this->snifferService->detectJsonBase64Head($peek, 1024) : ['found' => false, 'decoded_head' => null, 'reason' => null];
if (!empty($b['found'])) {
if ($this->snifferService !== null && $this->snifferService->base64IsAllowlisted($this->context->getUri(), $ctype)) {
$this->logger->logEvent('raw_body_base64_ignored', ['uri' => $this->context->getUri(), 'ctype' => $ctype]);
} else {
$fingerprints = [];
if (!empty($b['decoded_head'])) {
$decodedHead = $b['decoded_head'];
$sample = substr($decodedHead, 0, $BASE64_FINGERPRINT_BYTES);
$fingerprints['sha1'] = @sha1($sample);
$fingerprints['md5'] = @md5($sample);
if ($this->snifferService !== null && $this->snifferService->payloadContainsPhpMarkers($decodedHead, $ctype)) {
$rawSuspicious = true;
$this->logger->logEvent('raw_body_php_payload', [
'len' => (int)$clen,
'ctype' => $ctype,
'reason' => $b['reason'] ?? 'base64_embedded',
'fingerprints' => $fingerprints,
]);
} else {
$this->logger->logEvent('raw_body_base64', [
'len' => (int)$clen,
'ctype' => $ctype,
'reason' => $b['reason'] ?? 'base64_embedded',
'fingerprints' => $fingerprints,
]);
}
} else {
$this->logger->logEvent('raw_body_base64', [
'len' => (int)$clen,
'ctype' => $ctype,
'reason' => $b['reason'] ?? 'base64_embedded',
]);
}
}
} else {
if ($this->snifferService !== null && $this->snifferService->payloadContainsPhpMarkers($peek, $ctype)) {
$this->logger->logEvent('raw_body_php_payload', [
'len' => (int)$clen,
'ctype' => $ctype,
'reason' => 'head_php_markers',
]);
$rawSuspicious = true;
}
}
}
}
if ($rawSuspicious) {
$this->logger->logEvent('raw_body', [
'len' => (int)$clen,
'ctype' => $ctype,
]);
}
}
private function handleFileEntry(string $name, string $type, int $size, string $tmp, int $err): void
{
global $BLOCK_SUSPICIOUS, $MAX_SIZE, $FLOOD_MAX_UPLOADS;
global $QUARANTINE_ENABLED, $ARCHIVE_INSPECT, $ARCHIVE_BLOCK_ON_SUSPICIOUS;
if ($err !== UPLOAD_ERR_OK) {
$this->logger->logEvent('upload_error', [
'name' => $name,
'err' => (int)$err,
]);
return;
}
$origName = (string)$name;
$name = basename($origName);
$type = (string)$type;
$size = (int)$size;
$tmp = (string)$tmp;
// Flood count per file (stronger)
if ($this->isModuleEnabled('flood') && $this->floodService !== null) {
$count = $this->floodService->check($this->context->getIp());
if ($count > $FLOOD_MAX_UPLOADS) {
$this->logger->logEvent('flood_alert', ['count' => (int)$count]);
}
}
$real = $this->snifferService !== null ? $this->snifferService->detectRealMime($tmp) : 'unknown';
// If client-provided MIME `type` is empty, fall back to detected real MIME
if ($type === '' || $type === 'unknown') {
if (!empty($real) && $real !== 'unknown') {
$type = $real;
}
}
$suspicious = false;
$reasons = [];
if ($origName !== $name || strpos($origName, '/') !== false || strpos($origName, '\\') !== false) {
$suspicious = true;
$reasons[] = 'bad_name';
}
foreach ($this->detectors as $detector) {
$detectorName = $detector->getName();
if (!$this->isModuleEnabled($detectorName)) {
continue;
}
$result = $detector->detect($this->context, [
'name' => $name,
'orig_name' => $origName,
'real_mime' => $real,
'type' => $type,
'tmp' => $tmp,
'size' => $size,
]);
if (!empty($result['suspicious'])) {
$suspicious = true;
if (!empty($result['reasons']) && is_array($result['reasons'])) {
$reasons = array_merge($reasons, $result['reasons']);
}
}
}
// Hash before any quarantine move
$hashes = $this->isModuleEnabled('hashing') && $this->hashService !== null ? $this->hashService->computeHashes($tmp, $size) : [];
// Content sniffing for PHP payload (fast head scan, only for small files)
if ($this->isModuleEnabled('mime_sniff') && $this->snifferService !== null && $this->snifferService->sniffFileForPhpPayload($tmp)) {
$suspicious = true;
$reasons[] = 'php_payload';
}
// Very large file
if ($size > $MAX_SIZE) {
$this->logger->logEvent('big_upload', [
'name' => $name,
'size' => (int)$size,
]);
$reasons[] = 'big_file';
}
// Archive uploads are higher risk (often used to smuggle payloads)
if ($this->snifferService !== null && $this->snifferService->isArchive($name, $real)) {
$reasons[] = 'archive';
$this->logger->logEvent('archive_upload', [
'name' => $name,
'real_mime' => $real,
]);
if ($QUARANTINE_ENABLED && $this->isModuleEnabled('quarantine')) {
$qres = $this->quarantineService !== null ? $this->quarantineService->quarantineFile($tmp, $origName, $hashes) : ['ok' => false, 'path' => ''];
if ($qres['ok']) {
$qpath = $qres['path'];
$this->logger->logEvent('archive_quarantined', ['path' => $qpath]);
if ($this->isModuleEnabled('archive_inspect') && $ARCHIVE_INSPECT) {
$inspect = $this->quarantineService !== null ? $this->quarantineService->inspectArchiveQuarantine($qpath) : ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => true];
$this->logger->logEvent('archive_inspect', ['path' => $qpath, 'summary' => $inspect]);
if (!empty($inspect['suspicious_entries'])) {
$suspicious = true;
$reasons[] = 'archive_contains_suspicious';
if ($ARCHIVE_BLOCK_ON_SUSPICIOUS && $BLOCK_SUSPICIOUS) {
http_response_code(403);
exit('Upload blocked - suspicious archive');
}
}
}
} else {
$this->logger->logEvent('archive_quarantine_failed', ['tmp' => $tmp, 'dest' => $qres['path']]);
}
}
}
$this->logger->logEvent('upload', [
'name' => $name,
'orig_name' => $origName,
'size' => (int)$size,
'type' => $type,
'real_mime' => $real,
'tmp' => $tmp,
'hashes' => $hashes,
'flags' => $reasons,
]);
if ($suspicious) {
$this->logger->logEvent('suspicious_upload', [
'name' => $name,
'reasons' => $reasons,
]);
if ($BLOCK_SUSPICIOUS) {
http_response_code(403);
exit('Upload blocked');
}
}
}
}

78
core/Logger.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core;
/**
* Structured JSON logger with request context.
*/
final class Logger
{
private string $logFile;
/** @var array<string, mixed> */
private array $context;
/**
* @param array<string, mixed> $context
*/
public function __construct(string $logFile, array $context = [], ?Config $config = null)
{
$this->logFile = $logFile;
$this->context = $context;
// Keep optional config parameter for backward compatibility (unused here)
unset($config);
}
/**
* @param array<string, mixed> $context
*/
public function setContext(array $context): void
{
$this->context = $context;
}
/**
* @param array<string, mixed> $data
*/
public function logEvent(string $event, array $data = []): void
{
$payload = array_merge(
['ts' => gmdate('c'), 'event' => $event],
$this->context,
$data
);
$payload = $this->normalizeValue($payload);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($json === false) {
$json = json_encode([
'ts' => gmdate('c'),
'event' => 'log_error',
'error' => json_last_error_msg(),
], JSON_UNESCAPED_SLASHES);
}
@file_put_contents($this->logFile, $json . "\n", FILE_APPEND | LOCK_EX);
}
private function normalizeValue(mixed $value): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = $this->normalizeValue($v);
}
return $out;
}
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
return $value;
}
$str = (string)$value;
return preg_replace('/[\x00-\x1F\x7F]/', '_', $str);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class FloodService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
public function check(string $ip): int
{
$window = (int)$this->config->get('limits.flood_window_sec', 60);
$stateDir = (string)$this->config->get('paths.state_dir', __DIR__ . '/../../state');
$key = rtrim($stateDir, '/\\') . '/upl_' . md5('v3|' . $ip);
$now = time();
$count = 0;
$start = $now;
$fh = @fopen($key, 'c+');
if ($fh === false) {
return 1;
}
if (flock($fh, LOCK_EX)) {
$raw = stream_get_contents($fh);
if ($raw !== false) {
if (preg_match('/^(\d+):(\d+)$/', trim($raw), $m)) {
$start = (int)$m[1];
$count = (int)$m[2];
}
}
if ((($now - $start) > $window)) {
$start = $now;
$count = 0;
}
$count++;
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, $start . ':' . $count);
fflush($fh);
flock($fh, LOCK_UN);
}
fclose($fh);
return $count;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class HashService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
/**
* @param string $tmpPath
* @param int $size
* @return array<string,string>
*/
public function computeHashes(string $tmpPath, int $size): array
{
$max = (int)$this->config->get('limits.hash_max_filesize', 10 * 1024 * 1024);
if (!is_uploaded_file($tmpPath)) return [];
if ($size <= 0 || $size > $max) return [];
$sha1 = @hash_file('sha1', $tmpPath);
$md5 = @hash_file('md5', $tmpPath);
$out = [];
if (is_string($sha1)) $out['sha1'] = $sha1;
if (is_string($md5)) $out['md5'] = $md5;
return $out;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
final class LogService
{
private string $logFile;
/** @var array<string,mixed> */
private array $ctx;
/**
* @param string $logFile
* @param array<string,mixed> $ctx
*/
public function __construct(string $logFile, array $ctx = [])
{
$this->logFile = $logFile;
$this->ctx = $ctx;
}
/**
* @param string $event
* @param array<string,mixed> $data
*/
public function logEvent(string $event, array $data = []): void
{
$payload = array_merge(['ts' => gmdate('c'), 'event' => $event], $this->ctx, $data);
$payload = $this->normalize($payload);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($json === false) {
$json = json_encode([
'ts' => gmdate('c'),
'event' => 'log_error',
'error' => json_last_error_msg(),
], JSON_UNESCAPED_SLASHES);
}
@file_put_contents($this->logFile, $json . "\n", FILE_APPEND | LOCK_EX);
}
private function normalize(mixed $value): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = $this->normalize($v);
}
return $out;
}
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
return $value;
}
$str = (string)$value;
return preg_replace('/[\x00-\x1F\x7F]/', '_', $str);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class QuarantineService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
/**
* @param string $tmpPath
* @param string $origName
* @param array<string,string> $hashes
* @return array<string,mixed>
*/
public function quarantineFile(string $tmpPath, string $origName, array $hashes): array
{
$enabled = $this->config->isModuleEnabled('quarantine') && (bool)$this->config->get('ops.quarantine_enabled', true);
$quarantineDir = (string)$this->config->get('paths.quarantine_dir', __DIR__ . '/../../quarantine');
if (!$enabled) return ['ok' => false, 'path' => ''];
if (!is_uploaded_file($tmpPath)) return ['ok' => false, 'path' => ''];
if (!is_dir($quarantineDir)) return ['ok' => false, 'path' => ''];
$ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION));
if (!preg_match('/^[a-z0-9]{1,10}$/', $ext)) {
$ext = '';
}
$base = $hashes['sha1'] ?? '';
if ($base === '') {
try {
$base = bin2hex(random_bytes(16));
} catch (\Throwable $e) {
$base = uniqid('q', true);
}
}
$dest = rtrim($quarantineDir, '/\\') . '/' . $base . ($ext ? '.' . $ext : '');
$ok = @move_uploaded_file($tmpPath, $dest);
if ($ok) {
@chmod($dest, 0600);
return ['ok' => true, 'path' => $dest];
}
return ['ok' => false, 'path' => $dest];
}
/**
* @param string $path
* @return array<string,mixed>
*/
public function inspectArchiveQuarantine(string $path): array
{
$maxEntries = (int)$this->config->get('limits.archive_max_entries', 200);
$maxInspectSize = (int)$this->config->get('limits.archive_max_inspect_size', 50 * 1024 * 1024);
$fsz = @filesize($path);
if ($fsz !== false && $fsz > $maxInspectSize) {
return ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => false, 'too_large' => true];
}
$out = ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => false];
if (!is_file($path)) {
$out['unsupported'] = true;
return $out;
}
$lower = strtolower($path);
if (class_exists('ZipArchive') && preg_match('/\.zip$/i', $lower)) {
$za = new \ZipArchive();
if ($za->open($path) === true) {
$cnt = $za->numFiles;
$out['entries'] = min($cnt, $maxEntries);
$limit = $out['entries'];
for ($i = 0; $i < $limit; $i++) {
$stat = $za->statIndex($i);
if (is_array($stat)) {
$name = $stat['name'];
$entry = ['name' => $name, 'suspicious' => false, 'reason' => null];
if (strpos($name, '../') !== false || strpos($name, '..\\') !== false || strpos($name, '/') === 0 || strpos($name, '\\') === 0) {
$entry['suspicious'] = true;
$entry['reason'] = 'path_traversal';
}
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $name)) {
$entry['suspicious'] = true;
$entry['reason'] = ($entry['reason'] ? $entry['reason'] . ',ext' : 'ext');
}
if ($entry['suspicious']) $out['suspicious_entries'][] = $entry;
}
}
$za->close();
} else {
$out['unsupported'] = true;
}
return $out;
}
if (class_exists('PharData') && preg_match('/\.(tar|tar\.gz|tgz|tar\.bz2)$/i', $lower)) {
try {
$ph = new \PharData($path);
$it = new \RecursiveIteratorIterator($ph);
$count = 0;
foreach ($it as $file) {
if ($count++ >= $maxEntries) break;
$name = (string)$file;
$entry = ['name' => $name, 'suspicious' => false, 'reason' => null];
if (strpos($name, '../') !== false || strpos($name, '..\\') !== false || strpos($name, '/') === 0 || strpos($name, '\\') === 0) {
$entry['suspicious'] = true;
$entry['reason'] = 'path_traversal';
}
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $name)) {
$entry['suspicious'] = true;
$entry['reason'] = ($entry['reason'] ? $entry['reason'] . ',ext' : 'ext');
}
if ($entry['suspicious']) $out['suspicious_entries'][] = $entry;
}
$out['entries'] = $count;
} catch (\Exception $e) {
$out['unsupported'] = true;
}
return $out;
}
$out['unsupported'] = true;
return $out;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
final class RequestService
{
public function uploadClean(string $str): string
{
return str_replace(["\n", "\r", "\t"], '_', (string)$str);
}
public function normalizeValue(mixed $value): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = $this->normalizeValue($v);
}
return $out;
}
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
return $value;
}
$str = (string)$value;
return preg_replace('/[\x00-\x1F\x7F]/', '_', $str);
}
public function generateRequestId(): string
{
try {
return bin2hex(random_bytes(8));
} catch (\Throwable $e) {
return uniqid('req', true);
}
}
public function getClientIp(): string
{
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
public function getUserId(): string
{
if (isset($_SESSION) && is_array($_SESSION) && isset($_SESSION['user_id'])) {
return (string)$_SESSION['user_id'];
}
if (!empty($_SERVER['PHP_AUTH_USER'])) {
return (string)$_SERVER['PHP_AUTH_USER'];
}
return 'guest';
}
/**
* @return array{0:string,1:string,2:string,3:string,4:int,5:string,6:string}
*/
public function getRequestSummary(bool $logUserAgent = true): array
{
$ip = $this->getClientIp();
$uri = $_SERVER['REQUEST_URI'] ?? 'unknown';
$method = $_SERVER['REQUEST_METHOD'] ?? 'unknown';
$ctype = $_SERVER['CONTENT_TYPE'] ?? '';
$clen = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
$ua = $logUserAgent ? ($_SERVER['HTTP_USER_AGENT'] ?? '') : '';
$te = $_SERVER['HTTP_TRANSFER_ENCODING'] ?? '';
return [$ip, $uri, $method, $ctype, $clen, $ua, $te];
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class SnifferService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
public function detectRealMime(string $tmpPath): string
{
$real = 'unknown';
if (is_uploaded_file($tmpPath) && function_exists('finfo_open')) {
$f = @finfo_open(FILEINFO_MIME_TYPE);
if ($f) {
$m = @finfo_file($f, $tmpPath);
if (is_string($m) && $m !== '') {
$real = $m;
}
@finfo_close($f);
}
}
return $real;
}
public function payloadContainsPhpMarkers(string $text, string $contentType = ''): bool
{
$isXmlLike = false;
if ($contentType !== '') {
$isXmlLike = (bool)preg_match('/xml|svg/i', $contentType);
}
if (preg_match('/<\?php|<\?=|<\?(?!xml)/i', $text)) {
return true;
}
if (preg_match('/base64_decode\s*\(|gzinflate\s*\(|shell_exec\s*\(|passthru\s*\(|system\s*\(|proc_open\s*\(|popen\s*\(|exec\s*\(/i', $text)) {
return true;
}
if (!$isXmlLike && preg_match('/\beval\s*\(/i', $text)) {
return true;
}
return false;
}
public function sniffFileForPhpPayload(string $tmpPath): bool
{
$maxBytes = (int)$this->config->get('limits.sniff_max_bytes', 8192);
$maxFilesize = (int)$this->config->get('limits.sniff_max_filesize', 2 * 1024 * 1024);
if (!is_uploaded_file($tmpPath)) return false;
$sz = @filesize($tmpPath);
if ($sz === false) return false;
if ($sz <= 0) return false;
if ($sz > $maxFilesize) return false;
$bytes = min($maxBytes, $sz);
$maxlen = $bytes > 0 ? $bytes : null;
$head = @file_get_contents($tmpPath, false, null, 0, $maxlen);
if ($head === false) return false;
$realMime = $this->detectRealMime($tmpPath);
if ($this->payloadContainsPhpMarkers($head, $realMime)) {
return true;
}
return false;
}
/**
* @param string $head
* @param int $maxDecoded
* @return array{found:bool,decoded_head:?string,reason:?string}
*/
public function detectJsonBase64Head(string $head, int $maxDecoded = 1024): array
{
if (preg_match('/"(?:file|data|payload|content)"\s*:\s*"(?:data:[^,]+,)?([A-Za-z0-9+\/=]{200,})"/i', $head, $m)) {
$b64 = $m[1];
$chunk = substr($b64, 0, 1024);
$pad = 4 - (strlen($chunk) % 4);
if ($pad < 4) $chunk .= str_repeat('=', $pad);
$decoded = @base64_decode($chunk, true);
if ($decoded === false) return ['found' => true, 'decoded_head' => null, 'reason' => 'base64_decode_failed'];
$decoded_head = substr($decoded, 0, $maxDecoded);
return ['found' => true, 'decoded_head' => $decoded_head, 'reason' => null];
}
if (preg_match('/^\s*([A-Za-z0-9+\/=]{400,})/s', $head, $m2)) {
$b64 = $m2[1];
$chunk = substr($b64, 0, 1024);
$pad = 4 - (strlen($chunk) % 4);
if ($pad < 4) $chunk .= str_repeat('=', $pad);
$decoded = @base64_decode($chunk, true);
if ($decoded === false) return ['found' => true, 'decoded_head' => null, 'reason' => 'base64_decode_failed'];
return ['found' => true, 'decoded_head' => substr($decoded, 0, $maxDecoded), 'reason' => null];
}
return ['found' => false, 'decoded_head' => null, 'reason' => null];
}
public function base64IsAllowlisted(string $uri, string $ctype): bool
{
$uris = (array)$this->config->get('allowlists.base64_uris', []);
$ctypes = (array)$this->config->get('allowlists.ctypes', []);
if (!empty($uris)) {
foreach ($uris as $p) {
if (strlen($p) > 1 && $p[0] === '#' && substr($p, -1) === '#') {
if (@preg_match($p, $uri)) return true;
} else {
if (strpos($uri, $p) !== false) return true;
}
}
}
if (!empty($ctypes) && $ctype !== '') {
$base = explode(';', $ctype, 2)[0];
foreach ($ctypes as $ct) {
if (strtolower(trim($ct)) === strtolower(trim($base))) return true;
}
}
return false;
}
public function isFakeImage(string $name, string $realMime): bool
{
if (preg_match('/\.(png|jpe?g|gif|webp|bmp|ico|svg)$/i', $name)) {
if (!preg_match('/^image\//', $realMime)) {
return true;
}
}
return false;
}
public function isArchive(string $name, string $realMime): bool
{
if (preg_match('/\.(zip|rar|7z|tar|gz|tgz)$/i', $name)) return true;
if (preg_match('/(zip|x-7z-compressed|x-rar|x-tar|gzip)/i', $realMime)) return true;
return false;
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Detectors;
use UploadLogger\Core\Context;
use UploadLogger\Core\DetectorInterface;
use UploadLogger\Core\Config;
final class ContentDetector implements DetectorInterface
{
private ?Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config;
}
public function getName(): string
{
return 'content';
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
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 <?xml)
if (preg_match('/<\?php|<\?=|<\?(?!xml)/i', $scan)) {
$suspicious = true;
$reasons[] = 'php_tag';
}
// Built-in function patterns
$funcPatterns = [
'passthru\s*\(', 'system\s*\(', 'exec\s*\(', 'shell_exec\s*\(',
'proc_open\s*\(', 'popen\s*\(', 'pcntl_exec\s*\(',
];
foreach ($funcPatterns as $pat) {
if (preg_match('/' . $pat . '/i', $scan)) {
$suspicious = true;
$reasons[] = 'danger_func';
break;
}
}
// Base64/eval/assert patterns often indicate obfuscated payloads
if (preg_match('/base64_decode\s*\(|eval\s*\(|assert\s*\(/i', $scan)) {
$isXmlLike = preg_match('/xml|svg/i', $realMime);
if (!preg_match('/eval\s*\(/i', $scan) || !$isXmlLike || $allowXmlEval === true) {
$suspicious = true;
$reasons[] = 'obf_func';
}
}
// Custom patterns from config
foreach ($customPatterns as $p) {
try {
if (@preg_match($p, $scan)) {
$suspicious = true;
$reasons[] = 'custom_pattern';
}
} catch (\Throwable $e) {
// ignore invalid patterns
}
}
return ['suspicious' => $suspicious, 'reasons' => array_values(array_unique($reasons))];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Detectors;
use UploadLogger\Core\Context;
use UploadLogger\Core\DetectorInterface;
final class FilenameDetector implements DetectorInterface
{
public function getName(): string
{
return 'filename';
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
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;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Detectors;
use UploadLogger\Core\Context;
use UploadLogger\Core\DetectorInterface;
final class MimeDetector implements DetectorInterface
{
public function getName(): string
{
return 'mime_sniff';
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
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;
}
}

17
docs/auto-merge.md Normal file
View File

@@ -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.

57
docs/release-checklist.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,9 @@
/path/to/your/project/logs/uploads.log {
daily
rotate 14
compress
missingok
notifempty
copytruncate
create 0640 www-data www-data
}

View File

@@ -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": []
}
}
}

7
phpstan.neon Normal file
View File

@@ -0,0 +1,7 @@
parameters:
level: 5
paths:
- core
- detectors
bootstrapFiles:
- %currentWorkingDirectory%/vendor/autoload.php

8
phpunit.xml Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="upload-logger">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -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 }})

View File

@@ -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 }}

View File

@@ -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

104
scripts/provision_dirs.sh Normal file
View File

@@ -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" <<EOF
d ${QUARANTINE_DIR} ${QUARANTINE_PERMS} ${QUARANTINE_OWNER} ${QUARANTINE_GROUP} -
d ${STATE_DIR} ${STATE_PERMS} ${QUARANTINE_OWNER} ${QUARANTINE_GROUP} -
EOF
else
info "Skipping tmpfiles.d entry (no permission to write /etc/tmpfiles.d)"
fi
info "Provisioning complete. Ensure PHP-FPM worker user can write to the state directory if needed."
echo
info "Summary:"
stat -c "%U:%G %a %n" "$QUARANTINE_DIR" "$STATE_DIR" || true
exit 0

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Controlled rollout helper to enable blocking mode by swapping in a blocking config.
# Usage: sudo ./scripts/rollout_enable_blocking.sh [--dry-run] [--confirm]
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ACTIVE_CFG="$ROOT_DIR/upload-logger.json"
PROD_CFG="$ROOT_DIR/config/upload-logger.prod.json"
BLOCK_CFG="$ROOT_DIR/config/upload-logger.blocking.json"
BACKUP_DIR="$ROOT_DIR/config/backups"
DRY_RUN=0
CONFIRM=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--confirm) CONFIRM=1 ;;
-h|--help)
echo "Usage: $0 [--dry-run] [--confirm]"
exit 0 ;;
esac
done
if [[ ! -f "$BLOCK_CFG" ]]; then
echo "Blocking config not found: $BLOCK_CFG" >&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."

View File

@@ -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

22
tests/ConfigTest.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
use PHPUnit\Framework\TestCase;
use UploadLogger\Core\Config;
final class ConfigTest extends TestCase
{
public function testDotNotationGetters(): void
{
$data = [
'modules' => ['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'));
}
}

21
tests/ContextTest.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
use PHPUnit\Framework\TestCase;
use UploadLogger\Core\Context;
final class ContextTest extends TestCase
{
public function testContextAccessors(): void
{
$ctx = new Context('r1', '1.2.3.4', '/u', 'POST', 'application/json', 1000, 'user1', 'ua', '');
$this->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());
}
}

40
tests/DetectorsTest.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
use PHPUnit\Framework\TestCase;
use UploadLogger\Detectors\FilenameDetector;
use UploadLogger\Detectors\MimeDetector;
use UploadLogger\Detectors\ContentDetector;
use UploadLogger\Core\Context;
final class DetectorsTest extends TestCase
{
public function testFilenameDetector(): void
{
$det = new FilenameDetector();
$ctx = new Context('r','1.1.1.1','/','POST','',0,'guest','', '');
$res = $det->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, "<?php echo 'x'; ?>");
$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');
}
}

View File

@@ -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 "<?php echo 'bad'; ?>"
Write-Host "Created sample files."

View File

@@ -0,0 +1 @@
hello world

View File

@@ -0,0 +1 @@
<?php echo 'bad'; ?>

View File

@@ -0,0 +1,19 @@
<?php
// Simple endpoint that accepts file uploads; auto_prepend_file will run upload-logger.php
header('Content-Type: text/plain');
if (empty($_FILES)) {
echo "no files\n";
exit;
}
$out = [];
foreach ($_FILES as $k => $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";

56
upload-logger.json Normal file
View File

@@ -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"]
}
}

View File

@@ -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 '<?xml')
if (preg_match('/<\?php|<\?=|<\?(?!xml)/i', $text)) {
return true;
}
// Server-side PHP function patterns are strong indicators (always check)
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;
}
// 'eval(' is ambiguous: it commonly appears in JavaScript within SVGs.
// Only treat 'eval(' as suspicious when content is not XML/SVG.
if (!$isXmlLike && preg_match('/\beval\s*\(/i', $text)) {
return true;
}
return false;
}
/**
* Detect base64 blobs inside a JSON-like head and inspect decoded head bytes safely.
* Returns an array with keys: found(bool), decoded_head(string|null), reason(string|null)
*/
function detect_json_base64_head(string $head, int $maxDecoded = 1024): array
{
// Look for common JSON attributes that hold base64 content: file, data, payload, content
// This regex finds long base64-like sequences (>= 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);