Harden quarantine provisioning; enforce strict permissions and update Ansible and docs
This commit is contained in:
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal 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.
|
||||
36
.github/workflows/auto-merge-dependabot.yml
vendored
Normal file
36
.github/workflows/auto-merge-dependabot.yml
vendored
Normal 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
89
.github/workflows/ci.yml
vendored
Normal 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
17
AUTO_MERGE.md
Normal 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.
|
||||
105
INTEGRATION.md
105
INTEGRATION.md
@@ -1,3 +1,99 @@
|
||||
## Integration
|
||||
|
||||
Example `upload-logger.json` (commented for easy copy/paste into your environment):
|
||||
|
||||
```json
|
||||
// {
|
||||
// "modules": {
|
||||
// "flood": true,
|
||||
// "filename": true,
|
||||
// "mime_sniff": true,
|
||||
// "hashing": true,
|
||||
// "base64_detection": true,
|
||||
// "raw_peek": false,
|
||||
// "archive_inspect": true,
|
||||
// "quarantine": true
|
||||
// },
|
||||
// "paths": {
|
||||
// "log_file": "logs/uploads.log",
|
||||
// "quarantine_dir": "quarantine",
|
||||
// "state_dir": "state",
|
||||
// "allowlist_file": "allowlist.json"
|
||||
// },
|
||||
// "limits": {
|
||||
// "max_size": 52428800,
|
||||
// "raw_body_min": 512000,
|
||||
// "sniff_max_bytes": 8192,
|
||||
// "sniff_max_filesize": 2097152,
|
||||
// "hash_max_filesize": 10485760,
|
||||
// "archive_max_inspect_size": 52428800,
|
||||
// "archive_max_entries": 200
|
||||
// },
|
||||
// "ops": {
|
||||
// "quarantine_owner": "root",
|
||||
// "quarantine_group": "www-data",
|
||||
// "quarantine_dir_perms": "0700",
|
||||
// "log_rotate": {
|
||||
// "enabled": true,
|
||||
// "size": 10485760,
|
||||
// "keep": 7
|
||||
// }
|
||||
// },
|
||||
// "allowlists": {
|
||||
// "base64_uris": [
|
||||
// "/api/uploads/avatars",
|
||||
// "/api/v1/avatars",
|
||||
// "/user/avatar",
|
||||
// "/media/upload",
|
||||
// "/api/media",
|
||||
// "/api/uploads",
|
||||
// "/api/v1/uploads",
|
||||
// "/attachments/upload",
|
||||
// "/upload",
|
||||
// "#^/internal/webhook#",
|
||||
// "#/hooks/(github|gitlab|stripe|slack)#",
|
||||
// "/services/avatars",
|
||||
// "/api/profile/photo"
|
||||
// ],
|
||||
// "ctypes": ["image/svg+xml","application/xml","text/xml"]
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Remove the leading `// ` when copying this into a real `upload-logger.json` file.
|
||||
- Adjust paths, owners, and limits to match your environment and PHP-FPM worker permissions.
|
||||
|
||||
ContentDetector tuning and false-positive guidance
|
||||
|
||||
- The repository includes a `ContentDetector` that performs a fast head-scan of uploaded files to detect PHP open-tags and common webshell indicators (for example `passthru()`, `system()`, `exec()`, `shell_exec()`, `proc_open()`, `popen()`, `base64_decode()`, `eval()`, `assert()`). It intentionally limits the scan to a small number of bytes to reduce CPU/IO overhead.
|
||||
|
||||
- Tuning options (place these in `upload-logger.json`):
|
||||
- `limits.sniff_max_bytes` (integer): number of bytes to read from the file head for scanning. Default: `8192`.
|
||||
- `limits.sniff_max_filesize` (integer): only perform head-scan on files with size <= this value. Default: `2097152` (2 MB).
|
||||
- `allowlists.ctypes` (array): content-types that should be considered trusted for base64/raw payloads (for example `image/svg+xml`, `application/xml`, `text/xml`) and may relax some detections.
|
||||
- `allowlists.base64_uris` (array): URI patterns that should be ignored for large base64 payloads (webhooks, avatar uploads, etc.).
|
||||
|
||||
- False positives: `eval(` and other tokens commonly appear in client-side JS inside SVG files or in benign templating contexts. If you observe false positives:
|
||||
- Add trusted URIs to `allowlists.base64_uris` for endpoints that legitimately accept encoded content.
|
||||
- Add trusted content-types to `allowlists.ctypes` to relax detection for XML/SVG uploads.
|
||||
- Tune `limits.sniff_max_bytes` and `limits.sniff_max_filesize` to increase or decrease sensitivity.
|
||||
|
||||
- Suggested (example) detector tuning block (commented):
|
||||
|
||||
```json
|
||||
// "detectors": {
|
||||
// "content": {
|
||||
// "enabled": true,
|
||||
// "sniff_max_bytes": 8192,
|
||||
// "sniff_max_filesize": 2097152,
|
||||
// "allow_xml_eval": false
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
Remove the leading `// ` when copying these example snippets into a real `upload-logger.json` file.
|
||||
# 🔐 Per-Site PHP Upload Guard Integration Guide
|
||||
|
||||
This guide explains how to integrate a global PHP upload monitoring script
|
||||
@@ -44,6 +140,7 @@ mkdir .security/logs
|
||||
````
|
||||
|
||||
Set secure permissions:
|
||||
- Set secure permissions:
|
||||
|
||||
```bash
|
||||
chown -R root:www-data .security
|
||||
@@ -51,6 +148,14 @@ chmod 750 .security
|
||||
chmod 750 .security/logs
|
||||
```
|
||||
|
||||
Quarantine hardening (important):
|
||||
|
||||
- Ensure the quarantine directory is owner `root`, group `www-data`, and mode `0700` so quarantined files are not accessible to other system users. Example provisioning script `scripts/provision_dirs.sh` now enforces these permissions and tightens existing files to `0600`.
|
||||
|
||||
- If using Ansible, the playbook `scripts/ansible/upload-logger-provision.yml` includes a task that sets any existing files in the quarantine directory to `0600` and enforces owner/group.
|
||||
|
||||
- Verify SELinux/AppArmor contexts after provisioning; the script attempts to register fcontext entries and calls `restorecon` when available.
|
||||
|
||||
---
|
||||
|
||||
## 📄 3. Install the Upload Guard Script
|
||||
|
||||
18
README.md
18
README.md
@@ -34,6 +34,20 @@ Integration notes
|
||||
- Preferred deployment: set `php_admin_value[auto_prepend_file]` in the site-specific PHP-FPM pool to the absolute path of `upload-logger.php` so it runs before application code.
|
||||
- If using sessions for user identification, the script safely reads `$_SESSION['user_id']` only when a session is active; do not rely on it being present unless your app starts sessions earlier.
|
||||
- The script uses `is_uploaded_file()`/`finfo` where available; ensure the PHP `fileinfo` extension is enabled for best MIME detection.
|
||||
- The script uses `is_uploaded_file()`/`finfo` where available; ensure the PHP `fileinfo` extension is enabled for best MIME detection.
|
||||
|
||||
Content detector & tuning
|
||||
|
||||
- `ContentDetector` is now included and performs a fast head-scan of uploaded files to detect PHP open-tags and common webshell indicators (e.g., `passthru()`, `system()`, `exec()`, `shell_exec()`, `proc_open()`, `popen()`, `base64_decode()`, `eval()`, `assert()`).
|
||||
- The detector only scans the first N bytes of a file to limit CPU/io work; tune these limits in `upload-logger.json`:
|
||||
- `limits.sniff_max_bytes` — number of bytes to scan from file head (default `8192`).
|
||||
- `limits.sniff_max_filesize` — only scan files up to this size in bytes (default `2097152` / 2MB).
|
||||
- Behavior note: `eval()` and similar tokens commonly appear inside SVG/JS contexts. The detector uses the detected MIME to be more permissive for XML/SVG-like content, but you should test and tune for your application's upload patterns to avoid false positives (see `INTEGRATION.md`).
|
||||
- If your application legitimately accepts encoded or templated payloads, add application-specific allowlist rules (URI or content-type) in `allowlist.json` or extend `upload-logger.json` with detector-specific tuning before enabling blocking mode.
|
||||
Further integration
|
||||
- Read the `INTEGRATION.md` for a commented example `upload-logger.json`, logrotate hints, and deployment caveats.
|
||||
- Provision the required directories (`quarantine`, `state`) and set ownership/SELinux via the included provisioning script: `scripts/provision_dirs.sh`.
|
||||
- Example automation: `scripts/ansible/upload-logger-provision.yml` and `scripts/systemd/upload-logger-provision.service` are included as examples to run provisioning at deploy-time or boot.
|
||||
|
||||
Operational recommendations
|
||||
- Place the `logs/` directory outside the webroot or deny web access to it.
|
||||
@@ -56,3 +70,7 @@ Support & changes
|
||||
|
||||
---
|
||||
Generated for upload-logger.php (v3).
|
||||
|
||||
## Additional documentation
|
||||
|
||||
- Auto-merge & Dependabot: [docs/auto-merge.md](docs/auto-merge.md)
|
||||
|
||||
22
composer.json
Normal file
22
composer.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
config/upload-logger.blocking.json
Normal file
43
config/upload-logger.blocking.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
43
config/upload-logger.prod.json
Normal file
43
config/upload-logger.prod.json
Normal 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
64
core/Config.php
Normal 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
123
core/Context.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
19
core/DetectorInterface.php
Normal file
19
core/DetectorInterface.php
Normal 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
340
core/Dispatcher.php
Normal 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
78
core/Logger.php
Normal 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);
|
||||
}
|
||||
}
|
||||
58
core/Services/FloodService.php
Normal file
58
core/Services/FloodService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
core/Services/HashService.php
Normal file
37
core/Services/HashService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
core/Services/LogService.php
Normal file
60
core/Services/LogService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
136
core/Services/QuarantineService.php
Normal file
136
core/Services/QuarantineService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
core/Services/RequestService.php
Normal file
73
core/Services/RequestService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
153
core/Services/SnifferService.php
Normal file
153
core/Services/SnifferService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
114
detectors/ContentDetector.php
Normal file
114
detectors/ContentDetector.php
Normal 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))];
|
||||
}
|
||||
}
|
||||
66
detectors/FilenameDetector.php
Normal file
66
detectors/FilenameDetector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
49
detectors/MimeDetector.php
Normal file
49
detectors/MimeDetector.php
Normal 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
17
docs/auto-merge.md
Normal 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
57
docs/release-checklist.md
Normal 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.
|
||||
|
||||
9
examples/logrotate.d/upload-logger
Normal file
9
examples/logrotate.d/upload-logger
Normal 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
|
||||
}
|
||||
43
examples/upload-logger.json
Normal file
43
examples/upload-logger.json
Normal 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
7
phpstan.neon
Normal file
@@ -0,0 +1,7 @@
|
||||
parameters:
|
||||
level: 5
|
||||
paths:
|
||||
- core
|
||||
- detectors
|
||||
bootstrapFiles:
|
||||
- %currentWorkingDirectory%/vendor/autoload.php
|
||||
8
phpunit.xml
Normal file
8
phpunit.xml
Normal 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>
|
||||
104
scripts/ansible/provision-full.yml
Normal file
104
scripts/ansible/provision-full.yml
Normal 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 }})
|
||||
63
scripts/ansible/upload-logger-provision.yml
Normal file
63
scripts/ansible/upload-logger-provision.yml
Normal 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 }}
|
||||
21
scripts/deploy_provision.sh
Normal file
21
scripts/deploy_provision.sh
Normal 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
104
scripts/provision_dirs.sh
Normal 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
|
||||
88
scripts/rollout_enable_blocking.sh
Normal file
88
scripts/rollout_enable_blocking.sh
Normal 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."
|
||||
11
scripts/systemd/upload-logger-provision.service
Normal file
11
scripts/systemd/upload-logger-provision.service
Normal 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
22
tests/ConfigTest.php
Normal 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
21
tests/ContextTest.php
Normal 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
40
tests/DetectorsTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
4
tests/smoke/create_test_files.ps1
Normal file
4
tests/smoke/create_test_files.ps1
Normal 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."
|
||||
1
tests/smoke/public/sample.txt
Normal file
1
tests/smoke/public/sample.txt
Normal file
@@ -0,0 +1 @@
|
||||
hello world
|
||||
1
tests/smoke/public/suspicious.php
Normal file
1
tests/smoke/public/suspicious.php
Normal file
@@ -0,0 +1 @@
|
||||
<?php echo 'bad'; ?>
|
||||
19
tests/smoke/public/upload.php
Normal file
19
tests/smoke/public/upload.php
Normal 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
56
upload-logger.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user