Merge branch 'harden/quarantine-provisioning' into master
Some checks failed
CI / build (8.0) (push) Has been cancelled
CI / build (8.1) (push) Has been cancelled
CI / build (8.2) (push) Has been cancelled
CI / build (8.3) (push) Has been cancelled
CI / build (8.4) (push) Has been cancelled
CI / PHP Lint & Basic Checks (matrix) (8.0) (push) Has been cancelled
CI / PHP Lint & Basic Checks (matrix) (8.1) (push) Has been cancelled
CI / PHP Lint & Basic Checks (matrix) (8.2) (push) Has been cancelled

This commit is contained in:
2026-02-12 14:51:57 +01:00
49 changed files with 3189 additions and 1433 deletions

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

@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
# Auto-merge security updates is handled separately; label security PRs with `security`.
open-pull-requests-limit: 8
rebase-strategy: "auto"
labels:
- "dependencies"
- "dependabot"
# Dependabot will create security and regular dependency update PRs.

View File

@@ -0,0 +1,36 @@
name: Auto-merge Dependabot security updates
on:
pull_request_target:
types: [opened, labeled, reopened, ready_for_review]
jobs:
enable-automerge:
name: Enable auto-merge for Dependabot security PRs
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'dependabot-preview[bot]'
steps:
- name: Check PR labels for security
id: label-check
uses: actions/github-script@v6
with:
script: |
const pr = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number });
const labels = pr.data.labels.map(l => l.name.toLowerCase());
const isSecurity = labels.includes('security') || labels.includes('dependabot-security') || pr.data.body && /security/i.test(pr.data.body);
return { isSecurity };
- name: Enable GitHub auto-merge on PR
if: steps.label-check.outputs.isSecurity == 'true'
uses: peter-evans/enable-pull-request-automerge@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
pull-request-number: ${{ github.event.pull_request.number }}
merge-method: squash
- name: Comment when auto-merge enabled
if: steps.label-check.outputs.isSecurity == 'true'
uses: actions/github-script@v6
with:
script: |
await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, body: 'Auto-merge enabled for this Dependabot security update. Merge will occur automatically once required checks pass.' });

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

@@ -0,0 +1,89 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.0', '8.1', '8.2', '8.3', '8.4']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: none
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
env:
COMPOSER_MEMORY_LIMIT: -1
run: composer install --no-progress --prefer-dist --no-interaction
- name: Dependency audit (Composer)
run: composer audit --no-interaction
- name: Run tests (PHPUnit)
run: vendor/bin/phpunit --configuration phpunit.xml --testdox
- name: Run static analysis (PHPStan)
run: vendor/bin/phpstan analyse -c phpstan.neon
lint:
name: PHP Lint & Basic Checks (matrix)
runs-on: ubuntu-latest
strategy:
matrix:
php: [ '8.0', '8.1', '8.2' ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- name: Show PHP version
run: php -v
- name: Install composer dependencies
run: |
composer --version || (curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer)
composer install --no-progress --no-suggest --prefer-dist --no-interaction
- name: PHP -l lint (all .php files)
run: |
set -euo pipefail
echo "Finding PHP files..."
find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 -P4 php -l
- name: Run PHPStan static analysis
run: |
set -euo pipefail
vendor/bin/phpstan analyse --no-progress -c phpstan.neon
- name: Run PHPUnit
run: |
set -euo pipefail
if [ -x vendor/bin/phpunit ]; then
vendor/bin/phpunit --configuration phpunit.xml --colors=always
else
echo 'phpunit not installed; skipping tests (composer install should have installed dev deps).'
exit 0
fi

4
.gitignore vendored
View File

@@ -1,4 +1,4 @@
# Upload Logger repository .gitignore
# UploadShield repository .gitignore (formerly "Upload Logger")
# Runtime logs and quarantine/state directories (do not commit)
/logs/
@@ -8,7 +8,7 @@
uploads.log
# Peek allow marker (local only)
/.upload_logger_allow_peek
/.uploadshield_allow_peek
# Local environment files
.env

17
AUTO_MERGE.md Normal file
View File

@@ -0,0 +1,17 @@
# Auto-merge & Dependabot: repository settings
This project enables automated dependency updates (Dependabot) and a workflow that will enable GitHub auto-merge for Dependabot security PRs. To ensure auto-merge works correctly, the repository must be configured as follows:
- **Allow auto-merge**: enable Allow auto-merge in the repository settings (Settings → General → Merge button → Allow auto-merge).
- **Branch protection**: configure branch protection for your main branch so that required status checks (CI) are set and required before merging. Dependabot PRs will only be merged automatically after required checks pass.
- **Required checks**: ensure the CI workflow `.github/workflows/ci.yml` is listed as a required check in your branch-protection rules (it runs PHPUnit, PHPStan and Composer Audit).
- **Bot permissions**: the default `GITHUB_TOKEN` used by workflows has permission to enable auto-merge; ensure Actions are allowed to create pull requests and manage them in repository settings if you have tightened permissions.
## How to revoke or disable auto-merge
- **Disable for a single PR**: open the PR and click the `Auto-merge` button to turn it off (or remove the `security` label). The workflow also posts a comment when it enables auto-merge.
- **Disable global auto-merge**: Repository Settings → Merge button → uncheck **Allow auto-merge**.
- **Disable the automation workflow**: remove or rename `.github/workflows/auto-merge-dependabot.yml` to stop the automatic enabling step.
- **Disable Dependabot updates**: remove or rename `.github/dependabot.yml` or change its `schedule` to `interval: "never"`.
If you want stricter control, enable protected branch rules that require review approvals before merge; auto-merge will still wait for those approvals unless allowed by your protection policy.

72
CONFIG_REFERENCE.md Normal file
View File

@@ -0,0 +1,72 @@
# Configuration Reference
This file maps the top-level configuration keys used by `uploadshield.json` (UploadShield) to their effect and defaults. Use absolute paths in production where possible.
## Top-level sections
- `modules` (object): enable or disable features by name. Keys used in code:
- `flood` (bool) — per-IP upload counting and flood alerts. Default: `true`.
- `filename` (bool) — run `FilenameDetector`. Default: `true`.
- `mime_sniff` (bool) — run `MimeDetector` & content sniffing. Default: `true`.
- `hashing` (bool) — compute hashes for forensic records. Default: `true`.
- `base64_detection` (bool) — detect JSON/base64 embedded payloads in raw bodies. Default: `true`.
- `raw_peek` (bool) — allow guarded reads of `php://input`. Disabled by default (`false`) because it may consume request bodies.
- `archive_inspect` (bool) — inspect archives moved to quarantine. Default: `true`.
- `quarantine` (bool) — enable quarantine moves. Default: `true`.
- `paths` (object): filesystem locations used by the script. Common keys:
- `log_file` (string) — path to JSON log file. Default: `logs/uploads.log` (relative to script). Recommended: absolute path under a per-site `.security` folder.
- `quarantine_dir` (string) — path to quarantine directory. Default: `quarantine`.
- `state_dir` (string) — path to store flood counters/state. Default: `state`.
- `allowlist_file` (string) — optional allowlist of URIs/content-types. Default: `allowlist.json`.
- `limits` (object): thresholds controlling scanning and resource limits.
- `max_size` (int) — bytes threshold for `big_upload` warning. Default: `52428800` (50 MB).
- `raw_body_min` (int) — min bytes for raw body events. Default: `512000` (500 KB).
- `sniff_max_bytes` (int) — bytes to read from a file head for content sniffing. Default: `8192` (8 KB).
- `sniff_max_filesize` (int) — only sniff files up to this size. Default: `2097152` (2 MB).
- `hash_max_filesize` (int) — max file size to compute hashes for. Default: `10485760` (10 MB).
- `archive_max_inspect_size` (int) — skip inspecting archives larger than this. Default: `52428800` (50 MB).
- `archive_max_entries` (int) — max entries to inspect inside an archive. Default: `200`.
- `ops` (object): operator-facing options.
- `quarantine_owner` / `quarantine_group` (string) — desired owner:group for quarantine. Default: `root`:`www-data`.
- `quarantine_dir_perms` (string) — octal string for dir perms (recommended `0700`). Default: `0700`.
- `block_suspicious` (bool) — when `true` the script returns 403 for suspicious uploads. Default: `false` (observe mode).
- `log_rotate` (object) — hints for log rotation (size, keep, enabled) used in examples.
- `trusted_proxy_ips` (array) — proxies allowed to signal buffered bodies or peek permission.
- `allowlists` (object): reduce false positives for known safe flows.
- `base64_uris` (array of strings) — substrings or PCRE (when wrapped with `#`) matching URIs to ignore for base64/raw detections.
- `ctypes` (array of strings) — content-types treated as trusted for encoded payloads (e.g., `image/svg+xml`).
## Detector-specific keys
- `detectors.content` (object)
- `sniff_max_bytes` (int) — override `limits.sniff_max_bytes` for content detector.
- `sniff_max_filesize` (int) — override `limits.sniff_max_filesize`.
- `allow_xml_eval` (bool) — relax `eval()` detection for XML/SVG content when true.
- `custom_patterns` (array) — array of PCRE patterns (as strings) applied to the file head. Invalid patterns are ignored.
## Example `uploadshield.json`
```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": "/var/www/site/.security/logs/uploads.log", "quarantine_dir": "/var/www/site/quarantine", "state_dir": "/var/www/site/state", "allowlist_file": "/var/www/site/.security/allowlist.json" },
"limits": { "max_size": 52428800, "raw_body_min": 512000, "sniff_max_bytes": 8192, "sniff_max_filesize": 2097152 },
"ops": { "quarantine_owner": "root", "quarantine_group": "www-data", "quarantine_dir_perms": "0700", "block_suspicious": false },
"allowlists": { "base64_uris": ["/api/uploads/avatars"], "ctypes": ["image/svg+xml"] }
}
```
## Operational tips
- Keep `block_suspicious` disabled while tuning; use `allowlists.base64_uris` and `ctypes` to reduce false positives.
- Avoid enabling `raw_peek` unless your front end buffers request bodies or you accept the risk of consuming `php://input`.
- Use absolute paths in `paths.*` when deploying under systemd/Ansible to avoid cwd surprises.
- Ensure `quarantine_dir` is inaccessible from the web and set to owner `root` and mode `0700`; files inside should be `0600`.
If you want, I can generate a per-site `uploadshield.json` filled with your preferred absolute paths and ownership values.
--

75
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,75 @@
# Contributing
Thanks for contributing! This document explains how to run tests, linting, and the suggested workflow for changes.
## Quick dev setup
1. Install dependencies (developer machine):
```bash
git clone <repo-url> /path/to/uploadshield
cd /path/to/uploadshield
composer install --no-interaction --prefer-dist
```
2. Run unit tests and static analysis:
```bash
vendor/bin/phpunit --configuration phpunit.xml
vendor/bin/phpstan analyse -c phpstan.neon
```
3. Run PHP lint across the project (example):
```bash
find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l
```
## Branching & PR workflow
- Create a feature branch from `main` (or `master` if your repo uses it):
```bash
git checkout -b feature/short-description
```
- Make small, focused commits with clear messages. Example:
```
Add CONFIG_REFERENCE.md mapping configuration options
```
- Push and open a pull request to `main`. Provide a short description of the change and mention testing steps.
## Tests and CI
- The repository uses GitHub Actions to run PHPUnit and PHPStan on supported PHP versions. Ensure tests pass locally before opening a PR.
- If you add new functionality, provide unit tests in `tests/` and update `phpunit.xml` if needed.
## Smoke tests
- A basic smoke harness exists under `tests/smoke/`. To run locally:
```bash
php -S 127.0.0.1:8000 -t tests/smoke/public -d auto_prepend_file=$(pwd)/uploadshield.php
# then POST files with curl or a test client
```
## Coding style
- Keep changes minimal and consistent with existing code. Avoid reformatting unrelated files.
- Follow PSR-12 style where practical for new PHP code.
## Adding docs
- For user-facing changes, update `README.md`, `docs/INSTALLATION.md` and `INTEGRATION.md` accordingly. Prefer short, copy-paste examples for operators.
## Security disclosures
- If you find a security vulnerability, do not open a public issue. Contact maintainers privately and include reproduction steps.
## Contact
- Open an issue or a PR on GitHub; maintainers will review and respond.
Thank you for helping improve UploadShield.

View File

@@ -1,359 +1,89 @@
# 🔐 Per-Site PHP Upload Guard Integration Guide
## Integration & Tuning
This guide explains how to integrate a global PHP upload monitoring script
using `auto_prepend_file`, on a **per-site basis**, with isolated security
folders.
This document complements the installation steps in [docs/INSTALLATION.md](docs/INSTALLATION.md) by focusing on detector tuning, allowlists, and advanced integrations (log forwarding, Fail2Ban, etc.).
---
## 📁 1. Recommended Folder Structure
Each website should contain its own hidden security directory:
Example `uploadshield.json` (simplified) for UploadShield:
```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
},
"ops": {
"quarantine_owner": "root",
"quarantine_group": "www-data",
"quarantine_dir_perms": "0700",
"block_suspicious": false
},
"allowlists": {
"base64_uris": [
"/api/uploads/avatars",
"#/hooks/(github|gitlab|stripe|slack)#"
],
"ctypes": ["image/svg+xml","application/xml","text/xml"]
}
}
```
/var/www/sites/example-site/
├── public/
├── app/
├── uploads/
├── .security/
│ ├── upload_guard.php
│ └── logs/
│ └── uploads.log
Notes:
- Remove the `//` comments if copying from examples. Use absolute paths in production where possible.
````
### Content detector tuning
Benefits:
- The `ContentDetector` performs a fast head-scan to detect PHP open-tags and common webshell indicators (e.g., `passthru`, `system`, `exec`, `base64_decode`, `eval`, `assert`).
- Tuning options (in `uploadshield.json`):
- `limits.sniff_max_bytes` (default 8192) — how many bytes to scan from the file head.
- `limits.sniff_max_filesize` (default 2097152) — only scan files up to this size.
- `detectors.content.allow_xml_eval` — relax `eval()` detection for XML/SVG when appropriate.
- Per-site isolation
- Easier debugging
- Independent log files
- Reduced attack surface
False positives
- `eval(` appears in benign contexts (SVG/JS). To reduce false positives:
- Add trusted URIs to `allowlists.base64_uris`.
- Add trusted content-types to `allowlists.ctypes`.
- Tune scan size limits.
---
### Allowlists
## 🔧 2. Create the Security Directory
- `allowlists.base64_uris`: URI patterns (substring or PCRE when wrapped with `#`) that should bypass base64/raw detection.
- `allowlists.ctypes`: content-types to treat as permitted for encoded payloads (e.g., `image/svg+xml`).
From the site root:
### Archive inspection & quarantine
```bash
cd /var/www/sites/example-site
- Archives uploaded are flagged and—if quarantine is enabled—moved to the quarantine directory for inspection.
- Quarantine should be owner `root`, group `www-data`, mode `0700`. Files inside should be `0600`.
mkdir .security
mkdir .security/logs
````
## Fail2Ban integration (example)
Set secure permissions:
Create a Fail2Ban filter that matches suspicious JSON log lines and captures the IP as `<HOST>`.
```bash
chown -R root:www-data .security
chmod 750 .security
chmod 750 .security/logs
```
---
## 📄 3. Install the Upload Guard Script
Create the script file:
```bash
nano .security/upload_guard.php
```
Paste your hardened upload monitoring script.
Inside the script, configure logging:
```php
$logFile = __DIR__ . '/logs/uploads.log';
```
Lock the script:
```bash
chown root:root .security/upload_guard.php
chmod 644 .security/upload_guard.php
```
---
## ⚙️ 4. Enable auto_prepend_file (Per Site)
### Option A — PHP-FPM Pool (Recommended)
Edit the sites PHP-FPM pool configuration:
```bash
nano /etc/php/8.x/fpm/pool.d/example-site.conf
```
Add:
```ini
php_admin_value[auto_prepend_file] = /var/www/sites/example-site/.security/upload_guard.php
```
Reload PHP-FPM:
```bash
systemctl reload php8.x-fpm
```
# 🔐 Per-Site PHP Upload Guard Integration Guide
This guide explains how to integrate a global PHP upload monitoring script
using `auto_prepend_file`, on a **per-site basis**, with isolated security
folders.
---
## 📁 1. Recommended Folder Structure
Each website should contain its own hidden security directory:
```text
/var/www/sites/example-site/
├── public/
├── app/
├── uploads/
├── .security/
│ ├── upload-logger.php
│ └── logs/
│ └── uploads.log
```
Benefits:
- Per-site isolation
- Easier debugging
- Independent log files
- Reduced attack surface
---
## 🔧 2. Create the Security Directory
From the site root:
```bash
cd /var/www/sites/example-site
mkdir .security
mkdir .security/logs
```
Set secure permissions:
```bash
chown -R root:www-data .security
chmod 750 .security
chmod 750 .security/logs
```
---
## 📄 3. Install the Upload Guard Script
Create the script file:
```bash
nano .security/upload-logger.php
```
Paste your hardened upload monitoring script.
Inside the script, configure logging:
```php
$logFile = __DIR__ . '/logs/uploads.log';
```
Lock the script:
```bash
chown root:root .security/upload-logger.php
chmod 644 .security/upload-logger.php
```
---
## ⚙️ 4. Enable auto_prepend_file (Per Site)
### Option A — PHP-FPM Pool (Recommended)
Edit the sites PHP-FPM pool configuration:
```bash
nano /etc/php/8.x/fpm/pool.d/example-site.conf
```
Add:
```ini
php_admin_value[auto_prepend_file] = /var/www/sites/example-site/.security/upload-logger.php
```
Reload PHP-FPM (adjust service name to match your PHP version):
```bash
systemctl reload php8.x-fpm
```
---
### Option B — Apache Virtual Host
If using a shared PHP-FPM pool, configure in the vHost:
```apache
<Directory /var/www/sites/example-site>
php_admin_value auto_prepend_file /var/www/sites/example-site/.security/upload-logger.php
</Directory>
```
Reload Apache:
```bash
systemctl reload apache2
```
---
## 🚫 5. Block Web Access to `.security`
Prevent direct HTTP access to the security folder.
In the vHost:
```apache
<Directory /var/www/sites/example-site/.security>
Require all denied
</Directory>
```
Or in `.htaccess` (if allowed):
```apache
Require all denied
```
---
## ✅ 6. Verify Installation
Create a temporary file:
```php
<?php phpinfo();
```
Open it in browser and search for:
```text
auto_prepend_file
```
Expected output:
```text
/var/www/sites/example-site/.security/upload_guard.php
```
Remove the test file after verification.
---
## 🧪 7. Test Upload Logging
Create a simple upload test:
```php
<form method="post" enctype="multipart/form-data">
<input type="file" name="testfile">
<button>Upload</button>
</form>
```
Upload any file and check logs:
```bash
cat .security/logs/uploads.log
```
You should see a new entry.
---
## 🔒 8. Disable PHP Execution in Uploads
Always block PHP execution in upload directories.
Example (Apache):
```apache
<Directory /var/www/sites/example-site/uploads>
php_admin_flag engine off
AllowOverride None
</Directory>
```
Reload Apache after changes.
---
## 🛡️ 9. Enable Blocking Mode (Optional)
After monitoring for some time, enable blocking.
Edit:
```php
$BLOCK_SUSPICIOUS = true;
```
Then reload PHP-FPM.
---
## 📊 10. (Optional) Fail2Ban Integration (JSON logs)
Create a JSON-aware filter that matches `event: "suspicious"` and extracts the IP address.
```bash
nano /etc/fail2ban/filter.d/php-upload.conf
```
Filter (`/etc/fail2ban/filter.d/php-upload.conf`):
```ini
[Definition]
# Match JSON lines where event == "suspicious" and capture the IPv4 address as <HOST>
failregex = ^.*"event"\s*:\s*"suspicious".*"ip"\s*:\s*"(?P<host>\d{1,3}(?:\.\d{1,3}){3})".*$
ignoreregex =
```
Create a jail that points to the per-site logs (or a central aggregated log):
```ini
[php-upload]
enabled = true
filter = php-upload
logpath = /var/www/sites/*/.security/logs/uploads.log
maxretry = 3
findtime = 600
bantime = 86400
action = iptables-multiport[name=php-upload, port="http,https", protocol=tcp]
```
Restart Fail2Ban:
```bash
systemctl restart fail2ban
```
### Fail2Ban action: nftables example
If your host uses nftables, prefer the `nftables` action so bans use the system firewall:
Jail example (adjust `logpath`):
```ini
[php-upload]
@@ -366,107 +96,35 @@ bantime = 86400
action = nftables[name=php-upload, port="http,https", protocol=tcp]
```
This uses Fail2Ban's `nftables` action (available on modern distributions). Adjust `port`/`protocol` to match your services.
Test with `fail2ban-regex` using a representative JSON log line.
### Central log aggregation (Filebeat / rsyslog)
## Central log aggregation (Filebeat / rsyslog)
Forwarding per-site JSON logs to a central collector simplifies alerts and Fail2Ban at scale. Two lightweight options:
- Filebeat prospector (send to Logstash/Elasticsearch):
Forward JSON logs to your aggregator to centralize alerts and analysis. Example Filebeat input:
```yaml
filebeat.inputs:
- type: log
paths:
- /var/www/sites/*/.security/logs/uploads.log
json.keys_under_root: true
json.add_error_key: true
fields:
source: php-upload-logger
paths:
- /var/www/sites/*/.security/logs/uploads.log
json.keys_under_root: true
json.add_error_key: true
fields:
source: php-uploadshield
output.logstash:
hosts: ["logserver:5044"]
```
- rsyslog `imfile` forwarding to remote syslog (central rsyslog/logstash):
## Logrotate & SELinux notes
Add to `/etc/rsyslog.d/10-upload-logger.conf`:
Per-site `logrotate` snippets are included in `examples/logrotate.d/uploadshield`. Use `copytruncate` or reload PHP-FPM after rotation.
```text
module(load="imfile" PollingInterval="10")
input(type="imfile" File="/var/www/sites/*/.security/logs/uploads.log" Tag="uploadlogger" Severity="info" Facility="local7")
*.* @@logserver:514
```
If SELinux is enabled, the provisioning script attempts to register fcontexts and run `restorecon`. Verify contexts manually as needed.
Both options keep JSON intact for downstream parsing and reduce per-host Fail2Ban complexity.
## Final notes
### Testing your Fail2Ban filter
- Use observe mode (`ops.block_suspicious: false`) while tuning.
- After tuning, enable blocking in a controlled rollout (canary hosts first).
- Keep `uploadshield.php` and `.security` owned by `root` and ensure logs and quarantine are not web-accessible.
Create a temporary file containing a representative JSON log line emitted by `upload-logger.php` and run `fail2ban-regex` against your filter to validate detection.
```bash
# create test file with a suspicious event
cat > /tmp/test_upload.log <<'JSON'
{"ts":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","event":"suspicious","ip":"1.2.3.4","user":"guest","name":"evil.php.jpg","real_mime":"application/x-php","reasons":["bad_name","php_payload"]}
JSON
# test the filter (adjust path to filter if different)
fail2ban-regex /tmp/test_upload.log /etc/fail2ban/filter.d/php-upload.conf
```
`fail2ban-regex` will report how many matches were found and display sample matched groups (including the captured `<HOST>`). Use this to iterate on the `failregex` if it doesn't extract the IP as expected.
---
## 🏁 Final Architecture
```text
Client → Web Server → PHP (auto_prepend) → Application → Disk
Log / Alert / Ban
```
This provides multi-layer upload monitoring and protection.
---
## 🗂️ Log rotation & SELinux/AppArmor notes
- Example `logrotate` snippet to rotate per-site logs weekly and keep 8 rotations:
```text
/var/www/sites/*/.security/logs/uploads.log {
weekly
rotate 8
compress
missingok
notifempty
create 0640 root adm
}
```
- If your host enforces SELinux or AppArmor, ensure the `.security` directory and log files have the correct context so PHP-FPM can read the script and write logs. For SELinux (RHEL/CentOS) you may need:
```bash
chcon -R -t httpd_sys_rw_content_t /var/www/sites/example-site/.security/logs
restorecon -R /var/www/sites/example-site/.security
```
Adjust commands to match your platform and policy. AppArmor profiles may require adding paths to the PHP-FPM profile.
## ⚠️ Security Notes
- Never use `777` permissions
- Keep `.security` owned by `root`
- Regularly review logs
- Update PHP and extensions
- Combine with OS-level auditing for best results
---
## 📌 Recommended Maintenance
Weekly:
```bash
grep ALERT .security/logs/uploads.log
```
For installation steps and per-site configuration, see `docs/INSTALLATION.md`.

View File

@@ -1,14 +1,14 @@
# Upload Logger (Hardened v3)
# UploadShield (Hardened v3)
This repository contains the v3 upload-logging helper: a hardened single-file monitor that logs uploads, detects common evasion techniques, and optionally blocks suspicious uploads.
This repository contains UploadShield (formerly "Upload Logger"): a hardened PHP upload protection helper. It provides a single-file monitor that logs uploads, detects common evasion techniques, quarantines suspicious files, and can optionally block malicious uploads.
Primary file: [upload-logger.php](upload-logger.php)
Primary file: [uploadshield.php](uploadshield.php)
Summary
- Purpose: Log normal uploads and raw-body uploads, detect double-extension tricks, fake images, PHP payloads embedded in files, and provide flood detection.
- Runs only for HTTP requests; recommended to enable via `auto_prepend_file` in a per-site PHP-FPM pool for broad coverage.
Key configuration (top of `upload-logger.php`)
Key configuration (top of `uploadshield.php`)
- `$logFile` — path to the log file (default: `__DIR__ . '/logs/uploads.log'`).
- `$BLOCK_SUSPICIOUS` — if `true` the script returns `403` and exits when suspicious uploads are detected.
- `$MAX_SIZE` — threshold for `WARN big_upload` (default 50 MB).
@@ -31,9 +31,24 @@ Logging and alerts
- Other notes: `WARN big_upload`, `NOTE archive_upload`, `MULTIPART_NO_FILES`, and `RAW_BODY` are emitted when appropriate.
Integration notes
- Preferred deployment: set `php_admin_value[auto_prepend_file]` in the site-specific PHP-FPM pool to the absolute path of `upload-logger.php` so it runs before application code.
- Preferred deployment: set `php_admin_value[auto_prepend_file]` in the site-specific PHP-FPM pool to the absolute path of `uploadshield.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 `uploadshield.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 `uploadshield.json` with detector-specific tuning before enabling blocking mode.
Further integration
- Read the `INTEGRATION.md` for detector tuning, allowlists, and examples for log forwarding and Fail2Ban.
- See `docs/INSTALLATION.md` for a step-by-step per-site install and `auto_prepend_file` examples.
- Provision the required directories (`quarantine`, `state`) and set ownership/SELinux via the included provisioning script: `scripts/provision_dirs.sh`.
- Example automation: `scripts/ansible/uploadshield-provision.yml` and `scripts/systemd/uploadshield-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.
@@ -46,13 +61,17 @@ Limitations & safety
- Content sniffing is limited to a head-scan to reduce CPU and false positives; tune `$SNIFF_MAX_BYTES` and `$SNIFF_MAX_FILESIZE` to balance coverage and performance.
Quick start
1. Place `upload-logger.php` in a per-site secure folder (see `INTEGRATION.md`).
1. Place `uploadshield.php` in a per-site secure folder (see `INTEGRATION.md`).
2. Ensure the `logs/` directory exists and is writable by PHP-FPM.
3. Enable as an `auto_prepend_file` in the site pool and reload PHP-FPM.
4. Monitor `logs/uploads.log` and adjust configuration options at the top of the script.
Support & changes
- For changes, edit configuration variables at the top of `upload-logger.php` or adapt detection helpers as needed.
- For changes, edit configuration variables at the top of `uploadshield.php` or adapt detection helpers as needed.
---
Generated for upload-logger.php (v3).
Generated for uploadshield.php (UploadShield v3).
## Additional documentation
- Auto-merge & Dependabot: [docs/auto-merge.md](docs/auto-merge.md)

22
composer.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "uploadshield/upload-shield",
"description": "UploadShield — hardened PHP upload protection with quarantine and detectors (formerly Upload Logger)",
"type": "library",
"require": {
"php": "^8.0"
},
"autoload": {
"psr-4": {
"UploadLogger\\Core\\": "core/",
"UploadLogger\\Detectors\\": "detectors/"
}
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.6"
},
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"analyze": "vendor/bin/phpstan analyse -c phpstan.neon"
}
}

View File

@@ -0,0 +1,43 @@
{
"modules": {
"flood": true,
"mime_sniff": true,
"base64_detection": true,
"quarantine": true,
"archive_inspect": true
},
"paths": {
"quarantine_dir": "/var/lib/uploadshield/quarantine",
"state_dir": "/var/lib/uploadshield/state",
"allowlist_file": "/etc/uploadshield/allowlist.json"
},
"limits": {
"max_size": 52428800,
"raw_body_min": 512000,
"sniff_max_bytes": 8192,
"sniff_max_filesize": 2097152,
"hash_max_filesize": 10485760,
"flood_max_uploads": 40,
"archive_max_entries": 200,
"archive_max_inspect_size": 52428800
},
"ops": {
"block_suspicious": true,
"quarantine_enabled": true,
"archive_block_on_suspicious": true,
"log_user_agent": true,
"trusted_proxy_ips": ["127.0.0.1", "::1"]
},
"allowlists": {
"base64_uris": [],
"ctypes": []
},
"detectors": {
"content": {
"sniff_max_bytes": 8192,
"sniff_max_filesize": 2097152,
"allow_xml_eval": false,
"custom_patterns": []
}
}
}

View File

@@ -0,0 +1,43 @@
{
"modules": {
"flood": true,
"mime_sniff": true,
"base64_detection": true,
"quarantine": true,
"archive_inspect": true
},
"paths": {
"quarantine_dir": "/var/lib/uploadshield/quarantine",
"state_dir": "/var/lib/uploadshield/state",
"allowlist_file": "/etc/uploadshield/allowlist.json"
},
"limits": {
"max_size": 52428800,
"raw_body_min": 512000,
"sniff_max_bytes": 8192,
"sniff_max_filesize": 2097152,
"hash_max_filesize": 10485760,
"flood_max_uploads": 40,
"archive_max_entries": 200,
"archive_max_inspect_size": 52428800
},
"ops": {
"block_suspicious": false,
"quarantine_enabled": true,
"archive_block_on_suspicious": false,
"log_user_agent": true,
"trusted_proxy_ips": ["127.0.0.1", "::1"]
},
"allowlists": {
"base64_uris": [],
"ctypes": []
},
"detectors": {
"content": {
"sniff_max_bytes": 8192,
"sniff_max_filesize": 2097152,
"allow_xml_eval": false,
"custom_patterns": []
}
}
}

64
core/Config.php Normal file
View File

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

123
core/Context.php Normal file
View File

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

View File

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

340
core/Dispatcher.php Normal file
View File

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

78
core/Logger.php Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Detectors;
use UploadLogger\Core\Context;
use UploadLogger\Core\DetectorInterface;
use UploadLogger\Core\Config;
final class ContentDetector implements DetectorInterface
{
private ?Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config;
}
public function getName(): string
{
return 'content';
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function detect(Context $context, array $input = []): array
{
$tmp = (string)($input['tmp'] ?? '');
$size = (int)($input['size'] ?? 0);
$realMime = (string)($input['real_mime'] ?? '');
$suspicious = false;
$reasons = [];
if ($tmp === '' || !is_file($tmp)) {
return ['suspicious' => false, 'reasons' => []];
}
// Determine limits from Config if provided, otherwise use defaults
$maxBytes = 8192;
$maxFilesize = 2 * 1024 * 1024;
$allowXmlEval = false;
$customPatterns = [];
if ($this->config instanceof Config) {
$maxBytes = (int)$this->config->get('detectors.content.sniff_max_bytes', $this->config->get('limits.sniff_max_bytes', $maxBytes));
$maxFilesize = (int)$this->config->get('detectors.content.sniff_max_filesize', $this->config->get('limits.sniff_max_filesize', $maxFilesize));
$allowXmlEval = (bool)$this->config->get('detectors.content.allow_xml_eval', false);
$customPatterns = (array)$this->config->get('detectors.content.custom_patterns', []);
}
if ($size <= 0) {
$size = @filesize($tmp) ?: 0;
}
if ($size <= 0 || $size > $maxFilesize) {
return ['suspicious' => false, 'reasons' => []];
}
$bytes = min($maxBytes, $size);
$maxlen = $bytes > 0 ? $bytes : null;
$head = @file_get_contents($tmp, false, null, 0, $maxlen);
if ($head === false || $head === '') {
return ['suspicious' => false, 'reasons' => []];
}
$scan = $head;
// Detect PHP open tags (avoid matching <?xml)
if (preg_match('/<\?php|<\?=|<\?(?!xml)/i', $scan)) {
$suspicious = true;
$reasons[] = 'php_tag';
}
// Built-in function patterns
$funcPatterns = [
'passthru\s*\(', 'system\s*\(', 'exec\s*\(', 'shell_exec\s*\(',
'proc_open\s*\(', 'popen\s*\(', 'pcntl_exec\s*\(',
];
foreach ($funcPatterns as $pat) {
if (preg_match('/' . $pat . '/i', $scan)) {
$suspicious = true;
$reasons[] = 'danger_func';
break;
}
}
// Base64/eval/assert patterns often indicate obfuscated payloads
if (preg_match('/base64_decode\s*\(|eval\s*\(|assert\s*\(/i', $scan)) {
$isXmlLike = preg_match('/xml|svg/i', $realMime);
if (!preg_match('/eval\s*\(/i', $scan) || !$isXmlLike || $allowXmlEval === true) {
$suspicious = true;
$reasons[] = 'obf_func';
}
}
// Custom patterns from config
foreach ($customPatterns as $p) {
try {
if (@preg_match($p, $scan)) {
$suspicious = true;
$reasons[] = 'custom_pattern';
}
} catch (\Throwable $e) {
// ignore invalid patterns
}
}
return ['suspicious' => $suspicious, 'reasons' => array_values(array_unique($reasons))];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Detectors;
use UploadLogger\Core\Context;
use UploadLogger\Core\DetectorInterface;
final class FilenameDetector implements DetectorInterface
{
public function getName(): string
{
return 'filename';
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function detect(Context $context, array $input = []): array
{
$name = (string)($input['name'] ?? '');
$origName = (string)($input['orig_name'] ?? $name);
$suspicious = false;
$reasons = [];
if ($origName !== $name || strpos($origName, '/') !== false || strpos($origName, '\\') !== false) {
$suspicious = true;
$reasons[] = 'bad_name';
}
if ($this->isSuspiciousFilename($name)) {
$suspicious = true;
$reasons[] = 'bad_name';
}
return [
'suspicious' => $suspicious,
'reasons' => $reasons,
];
}
private function isSuspiciousFilename(string $name): bool
{
$n = strtolower($name);
if (strpos($n, '../') !== false || strpos($n, '..\\') !== false || strpos($n, "\0") !== false) {
return true;
}
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $n)) {
return true;
}
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)\./i', $n)) {
return true;
}
if (preg_match('/^\.(php|phtml|phar|php\d)/i', $n)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Detectors;
use UploadLogger\Core\Context;
use UploadLogger\Core\DetectorInterface;
final class MimeDetector implements DetectorInterface
{
public function getName(): string
{
return 'mime_sniff';
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function detect(Context $context, array $input = []): array
{
$name = (string)($input['name'] ?? '');
$realMime = (string)($input['real_mime'] ?? 'unknown');
$suspicious = false;
$reasons = [];
if ($this->isFakeImage($name, $realMime)) {
$suspicious = true;
$reasons[] = 'fake_image';
}
return [
'suspicious' => $suspicious,
'reasons' => $reasons,
];
}
private function isFakeImage(string $name, string $realMime): bool
{
if (preg_match('/\.(png|jpe?g|gif|webp|bmp|ico|svg)$/i', $name)) {
if (!preg_match('/^image\//', $realMime)) {
return true;
}
}
return false;
}
}

175
docs/INSTALLATION.md Normal file
View File

@@ -0,0 +1,175 @@
# Installation & Production Deployment Guide
This guide shows a minimal, secure installation and rollout path for UploadShield's primary script (`uploadshield.php`).
Follow these steps in a staging environment first; do not enable blocking until detectors are tuned.
**Prerequisites**
- A Linux host running PHP-FPM (PHP 8.0+ recommended).
- `composer` available for dev tasks.
- SSH/privileged access to configure the site pool and run provisioning scripts.
**Quick overview**
1. Place `uploadshield.php` in a secure per-site folder (recommended `.security`).
2. Create `logs/`, `quarantine/`, `state/` and set strict ownership and permissions.
3. Configure `uploadshield.json` for your environment; keep `ops.block_suspicious` off for initial tuning.
4. Enable `auto_prepend_file` in the site PHP-FPM pool to run the logger before application code.
5. Verify logging, tune detectors, deploy log rotation, and enable alerting.
**1. Clone & dependencies (developer workstation)**
- Clone the repository and install dev deps (for tests/static analysis):
```bash
git clone <repo-url> /srv/uploadshield
cd /srv/uploadshield
composer install --no-interaction --prefer-dist
```
Run tests locally:
```bash
vendor/bin/phpunit --configuration phpunit.xml
vendor/bin/phpstan analyse -c phpstan.neon
```
**2. Recommended file layout (per-site)**
Use a hidden per-site security folder so `uploadshield.php` is not web-accessible.
Example layout:
```
/var/www/sites/example-site/
├── public/
├── app/
├── uploads/
├── .security/
│ ├── uploadshield.php
│ └── logs/
│ └── uploads.log
├── quarantine/
└── state/
Place `uploadshield.php` into `.security/uploadshield.php`.
**3. Copy files & configure**
- Place `uploadshield.php` into `.security/uploadshield.php`.
- Copy `uploadshield.json` from the repository to the same directory and edit paths to absolute values, e.g.:
- `paths.log_file` → `/var/www/sites/example-site/.security/logs/uploads.log`
- `paths.quarantine_dir` → `/var/www/sites/example-site/quarantine`
- `paths.state_dir` → `/var/www/sites/example-site/state`
- Ensure `ops.block_suspicious` is `false` initially (observe mode).
**4. Create directories & set permissions (run as root)**
Adjust user/group to your site environment (`www-data` used in examples):
```bash
# example: run on target host as root
mkdir -p /var/www/sites/example-site/.security/logs
mkdir -p /var/www/sites/example-site/quarantine
mkdir -p /var/www/sites/example-site/state
chown -R root:www-data /var/www/sites/example-site/.security
chmod 750 /var/www/sites/example-site/.security
chmod 750 /var/www/sites/example-site/.security/logs
# quarantine must be restrictive
chown -R root:www-data /var/www/sites/example-site/quarantine
chmod 0700 /var/www/sites/example-site/quarantine
# state directory writable by PHP-FPM if required (group-write)
chown -R root:www-data /var/www/sites/example-site/state
chmod 0750 /var/www/sites/example-site/state
# ensure log file exists with safe perms
touch /var/www/sites/example-site/.security/logs/uploads.log
chown root:www-data /var/www/sites/example-site/.security/logs/uploads.log
chmod 0640 /var/www/sites/example-site/.security/logs/uploads.log
```
Alternatively use the included provisioning scripts on the host:
- `scripts/provision_dirs.sh` — run as root; idempotent and attempts to set SELinux fcontext.
- `scripts/ansible/uploadshield-provision.yml` — Ansible playbook for bulk provisioning.
**5. PHPFPM configuration (per-site pool)**
Edit the site's FPM pool (example: `/etc/php/8.1/fpm/pool.d/example-site.conf`) and add:
php_admin_value[auto_prepend_file] = /var/www/sites/example-site/.security/uploadshield.php
```ini
; Ensure uploadshield runs before application code
php_admin_value[auto_prepend_file] = /var/www/sites/example-site/.security/uploadshield.php
```
Then reload PHP-FPM:
```bash
sudo systemctl reload php8.1-fpm
```
Notes:
- Use the absolute path to `uploadshield.php`.
- If your host uses a shared PHP-FPM pool, consider enabling per-vhost `auto_prepend_file` via nginx/apache or create a dedicated pool for the site.
**6. Log rotation**
Create `/etc/logrotate.d/uploadshield` with content adapted to your paths:
```
/var/www/sites/example-site/.security/logs/uploads.log {
rotate 7
size 10M
compress
missingok
notifempty
copytruncate
create 0640 root www-data
sharedscripts
postrotate
if systemctl is-active --quiet php8.1-fpm; then
systemctl reload php8.1-fpm >/dev/null 2>&1 || true
fi
endscript
}
```
`copytruncate` is safe and avoids needing to stop PHP; alternatively use `postrotate` to reload FPM.
**7. Verify installation (smoke tests)**
- Upload a benign file via your app or via a simple test form and confirm a JSON `upload` line appears in the log.
- Upload a file containing `<?php` to confirm detectors log `suspicious_upload` entries.
- Check `logs/uploads.log` for events.
php -S 127.0.0.1:8000 -t . -d auto_prepend_file=/var/www/sites/example-site/.security/uploadshield.php
```bash
# from site root
php -S 127.0.0.1:8000 -t . -d auto_prepend_file=/var/www/sites/example-site/.security/uploadshield.php
# then curl a file POST to your test endpoint
curl -F "file=@/path/to/sample.txt" http://127.0.0.1:8000/upload_test.php
```
**8. Tuning & staging**
- Keep `ops.block_suspicious` set to `false` while monitoring logs and tuning:
- `allowlists.base64_uris` — add URIs for trusted base64 endpoints.
- `allowlists.ctypes` — add trusted content-types such as `image/svg+xml` if needed.
- `limits.sniff_max_bytes` / `limits.sniff_max_filesize` — tune scanning cost vs coverage.
- Run the system under representative traffic and check for false positives.
**9. Gradual enable blocking**
- After tuning, enable blocking in a controlled manner:
1. Set `ops.block_suspicious``true` in `uploadshield.json` on a small subset of sites or a canary host.
2. Monitor errors, rollback quickly if issues appear.
3. Gradually roll out to remaining hosts.
**10. Monitoring & alerting**
- Forward `logs/uploads.log` to your SIEM or log aggregator (Filebeat/Fluentd).
- Create alerts for `event == suspicious_upload` or `event == raw_body` and for rapid flood counts.
- Monitor disk usage for `logs/` and ensure `logrotate` is active.
**11. Security & operational checklist**
- Ensure `quarantine/` and `.security/logs` are not accessible from the web server.
- Verify SELinux/AppArmor contexts after running provisioning.
- Ensure owner/group are set to root and web group (e.g., `root:www-data`) and modes match the guide.
Keep `uploadshield.php` readable by root (644) and the logs readable only by the intended group (640).
**Rollback**
- Disable `php_admin_value[auto_prepend_file]` in the pool and reload PHP-FPM.
- Remove or rotate the `uploadshield` files if needed.
**Further reading & files**
- Integration notes: [INTEGRATION.md](INTEGRATION.md)
- Provisioning script: `scripts/provision_dirs.sh`
- Ansible playbook: `scripts/ansible/uploadshield-provision.yml`
- Example configuration: `uploadshield.json`
---
If you want, I can: (a) generate a site-specific copy of these snippets for your exact paths/PHP version, (b) open a PR with the updated documentation, or (c) produce a one-command installer playbook that runs the provisioning and copies files to a remote host. Tell me which option you prefer.

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

@@ -0,0 +1,17 @@
# Auto-merge & Dependabot: repository settings
This project enables automated dependency updates (Dependabot) and a workflow that will enable GitHub auto-merge for Dependabot security PRs. To ensure auto-merge works correctly, the repository must be configured as follows:
- **Allow auto-merge**: enable Allow auto-merge in the repository settings (Settings → General → Merge button → Allow auto-merge).
- **Branch protection**: configure branch protection for your main branch so that required status checks (CI) are set and required before merging. Dependabot PRs will only be merged automatically after required checks pass.
- **Required checks**: ensure the CI workflow `.github/workflows/ci.yml` is listed as a required check in your branch-protection rules (it runs PHPUnit, PHPStan and Composer Audit).
- **Bot permissions**: the default `GITHUB_TOKEN` used by workflows has permission to enable auto-merge; ensure Actions are allowed to create pull requests and manage them in repository settings if you have tightened permissions.
## How to revoke or disable auto-merge
- **Disable for a single PR**: open the PR and click the `Auto-merge` button to turn it off (or remove the `security` label). The workflow also posts a comment when it enables auto-merge.
- **Disable global auto-merge**: Repository Settings → Merge button → uncheck **Allow auto-merge**.
- **Disable the automation workflow**: remove or rename `.github/workflows/auto-merge-dependabot.yml` to stop the automatic enabling step.
- **Disable Dependabot updates**: remove or rename `.github/dependabot.yml` or change its `schedule` to `interval: "never"`.
If you want stricter control, enable protected branch rules that require review approvals before merge; auto-merge will still wait for those approvals unless allowed by your protection policy.

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

@@ -0,0 +1,57 @@
# Release & Deploy Checklist
This checklist helps you deploy UploadShield's primary script (`uploadshield.php`) to production safely.
## Pre-release
- [ ] Review and pin configuration in `uploadshield.json` (see `examples/uploadshield.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 `uploadshield.json` from `examples/uploadshield.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 `uploadshield.php` (UploadShield).
- [ ] 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/uploadshield`).
- [ ] Set up monitoring/alerting on log file growth, error events, and flood alerts.
- [ ] Schedule periodic dependency checks (Dependabot and weekly `composer audit`).
- [ ] Periodically review `allowlist.json` and detector tuning to reduce false positives.

View File

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

View File

@@ -0,0 +1,43 @@
{
"modules": {
"flood": true,
"mime_sniff": true,
"base64_detection": true,
"quarantine": true,
"archive_inspect": true
},
"paths": {
"quarantine_dir": "./quarantine",
"state_dir": "./state",
"allowlist_file": "./allowlist.json"
},
"limits": {
"max_size": 52428800,
"raw_body_min": 512000,
"sniff_max_bytes": 8192,
"sniff_max_filesize": 2097152,
"hash_max_filesize": 10485760,
"flood_max_uploads": 40,
"archive_max_entries": 200,
"archive_max_inspect_size": 52428800
},
"ops": {
"block_suspicious": false,
"quarantine_enabled": true,
"archive_block_on_suspicious": false,
"log_user_agent": true,
"trusted_proxy_ips": ["127.0.0.1", "::1"]
},
"allowlists": {
"base64_uris": [],
"ctypes": []
},
"detectors": {
"content": {
"sniff_max_bytes": 8192,
"sniff_max_filesize": 2097152,
"allow_xml_eval": false,
"custom_patterns": []
}
}
}

7
phpstan.neon Normal file
View File

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

8
phpunit.xml Normal file
View File

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

View File

@@ -0,0 +1,104 @@
---
# Full Ansible playbook to provision UploadShield directories, permissions, tmpfiles and logrotate.
# Usage: ansible-playbook -i inventory scripts/ansible/provision-full.yml
- hosts: web
become: true
vars:
uploadshield_root: "{{ playbook_dir | default('.') | dirname | realpath }}"
logs_dir: "{{ uploadshield_root }}/logs"
quarantine_dir: "{{ uploadshield_root }}/quarantine"
state_dir: "{{ uploadshield_root }}/state"
examples_dir: "{{ uploadshield_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/uploadshield.conf"
logrotate_dest: "/etc/logrotate.d/uploadshield"
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 uploadshield.json is copied (only when missing)
copy:
src: "{{ examples_dir }}/uploadshield.json"
dest: "{{ uploadshield_root }}/uploadshield.json"
owner: "{{ quarantine_owner }}"
group: "{{ quarantine_group }}"
mode: "0644"
when: not (uploadshield_root + '/uploadshield.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/uploadshield"
dest: "{{ logrotate_dest }}"
owner: root
group: root
mode: '0644'
when: (examples_dir + '/logrotate.d/uploadshield') | path_exists
- name: Set SELinux fcontext for directories when selinux enabled
when: ansible_selinux.status == 'enabled'
sefcontext:
target: "{{ item }}(/.*)?"
setype: "{{ selinux_fcontext }}"
loop:
- "{{ quarantine_dir }}"
- "{{ state_dir }}"
- "{{ logs_dir }}"
- name: Apply SELinux contexts
when: ansible_selinux.status == 'enabled'
command: restorecon -Rv {{ quarantine_dir }} {{ state_dir }} {{ logs_dir }}
- name: Ensure log file exists with correct mode (touch)
file:
path: "{{ logs_dir }}/uploads.log"
state: touch
owner: "{{ quarantine_owner }}"
group: "{{ quarantine_group }}"
mode: "{{ log_file_mode }}"
- name: Summary - show directories
debug:
msg: |
Provisioned:
- logs: {{ logs_dir }} (owner={{ quarantine_owner }} group={{ quarantine_group }} mode={{ logs_perms }})
- quarantine: {{ quarantine_dir }} (owner={{ quarantine_owner }} group={{ quarantine_group }} mode={{ quarantine_perms }})
- state: {{ state_dir }} (owner={{ quarantine_owner }} group={{ quarantine_group }} mode={{ state_perms }})

View File

@@ -0,0 +1,63 @@
---
# Ansible playbook snippet to provision UploadShield directories and permissions.
# Usage: ansible-playbook -i inventory scripts/ansible/uploadshield-provision.yml
- hosts: web
become: true
vars:
uploadshield_root: "{{ playbook_dir | default('.') | dirname | realpath }}"
quarantine_dir: "{{ uploadshield_root }}/quarantine"
state_dir: "{{ uploadshield_root }}/state"
quarantine_owner: "root"
quarantine_group: "www-data"
quarantine_perms: "0700"
state_perms: "0750"
selinux_fcontext: "httpd_sys_rw_content_t"
tasks:
- name: Ensure quarantine directory exists
file:
path: "{{ quarantine_dir }}"
state: directory
owner: "{{ quarantine_owner }}"
group: "{{ quarantine_group }}"
mode: "{{ quarantine_perms }}"
- name: Ensure state directory exists
file:
path: "{{ state_dir }}"
state: directory
owner: "{{ quarantine_owner }}"
group: "{{ quarantine_group }}"
mode: "{{ state_perms }}"
- name: Ensure quarantined files have strict permissions (files -> 0600)
find:
paths: "{{ quarantine_dir }}"
file_type: file
register: quarantine_files
- name: Set strict mode on existing quarantined files
file:
path: "{{ item.path }}"
mode: '0600'
owner: "{{ quarantine_owner }}"
group: "{{ quarantine_group }}"
loop: "{{ quarantine_files.files }}"
when: quarantine_files.matched > 0
- name: Set SELinux fcontext for quarantine dir (when selinux enabled)
when: ansible_selinux.status == 'enabled'
sefcontext:
target: "{{ quarantine_dir }}(/.*)?"
setype: "{{ selinux_fcontext }}"
- name: Set SELinux fcontext for state dir (when selinux enabled)
when: ansible_selinux.status == 'enabled'
sefcontext:
target: "{{ state_dir }}(/.*)?"
setype: "{{ selinux_fcontext }}"
- name: Apply SELinux contexts
when: ansible_selinux.status == 'enabled'
command: restorecon -Rv {{ quarantine_dir }} {{ state_dir }}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Wrapper to provision UploadShield directories using Ansible if available,
# otherwise falling back to the included provision_dirs.sh script.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ANSIBLE_PLAYBOOK="$(command -v ansible-playbook || true)"
PLAYBOOK_PATH="$ROOT_DIR/scripts/ansible/provision-full.yml"
FALLBACK_SCRIPT="$ROOT_DIR/scripts/provision_dirs.sh"
if [[ -n "$ANSIBLE_PLAYBOOK" && -f "$PLAYBOOK_PATH" ]]; then
echo "Running Ansible playbook: $PLAYBOOK_PATH"
# Use local connection if running on the target host
if [[ "$1" == "local" ]]; then
sudo ansible-playbook -i localhost, -c local "$PLAYBOOK_PATH"
else
sudo ansible-playbook "$PLAYBOOK_PATH"
fi
else
echo "Ansible not available or playbook missing; using fallback script"
sudo "$FALLBACK_SCRIPT" "$@"
fi

104
scripts/provision_dirs.sh Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
# Provision quarantine and state directories for UploadShield (uploadshield.php)
# Usage: sudo ./provision_dirs.sh [--config path/to/uploadshield.json]
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CFG="${1:-$ROOT_DIR/uploadshield.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/uploadshield.conf"
if [[ -w /etc/tmpfiles.d || $QUIET -eq 1 ]]; then
info "Writing tmpfiles.d entry to ${TMPFILE}"
cat > "$TMPFILE" <<EOF
d ${QUARANTINE_DIR} ${QUARANTINE_PERMS} ${QUARANTINE_OWNER} ${QUARANTINE_GROUP} -
d ${STATE_DIR} ${STATE_PERMS} ${QUARANTINE_OWNER} ${QUARANTINE_GROUP} -
EOF
else
info "Skipping tmpfiles.d entry (no permission to write /etc/tmpfiles.d)"
fi
info "Provisioning complete. Ensure PHP-FPM worker user can write to the state directory if needed."
echo
info "Summary:"
stat -c "%U:%G %a %n" "$QUARANTINE_DIR" "$STATE_DIR" || true
exit 0

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Controlled rollout helper to enable blocking mode by swapping in a blocking config.
# Usage: sudo ./scripts/rollout_enable_blocking.sh [--dry-run] [--confirm]
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ACTIVE_CFG="$ROOT_DIR/uploadshield.json"
PROD_CFG="$ROOT_DIR/config/uploadshield.prod.json"
BLOCK_CFG="$ROOT_DIR/config/uploadshield.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/uploadshield.json.bak.$TS"
echo "Backed up current config to $BACKUP_DIR/uploadshield.json.bak.$TS"
fi
cp -a "$BLOCK_CFG" "$ACTIVE_CFG"
echo "Copied blocking config to $ACTIVE_CFG"
# Try to reload PHP-FPM gracefully using common service names
RELOADED=0
if command -v systemctl >/dev/null 2>&1; then
for svc in php-fpm php7.4-fpm php8.0-fpm php8.1-fpm php8.2-fpm; do
if systemctl list-units --full -all | grep -q "^${svc}\.service"; then
echo "Reloading $svc"
systemctl reload "$svc" || systemctl restart "$svc"
RELOADED=1
break
fi
done
fi
if [[ $RELOADED -eq 0 ]]; then
if command -v service >/dev/null 2>&1; then
for svc in php7.4-fpm php8.0-fpm php8.1-fpm php8.2-fpm php-fpm; do
if service --status-all 2>&1 | grep -q "$svc"; then
echo "Reloading $svc via service"
service "$svc" reload || service "$svc" restart
RELOADED=1
break
fi
done
fi
fi
if [[ $RELOADED -eq 0 ]]; then
echo "Warning: could not detect PHP-FPM service to reload. Please reload PHP-FPM manually."
else
echo "PHP-FPM reloaded; blocking config is active."
fi
echo "Rollout complete. Monitor logs and be ready to rollback if necessary."

View File

@@ -0,0 +1,11 @@
[Unit]
Description=UploadShield provisioning (one-shot)
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/uploadshield-provision.sh /opt/uploadshield/uploadshield.json
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

22
tests/ConfigTest.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
use PHPUnit\Framework\TestCase;
use UploadLogger\Core\Config;
final class ConfigTest extends TestCase
{
public function testDotNotationGetters(): void
{
$data = [
'modules' => ['flood' => true],
'limits' => ['max_size' => 12345, 'nested' => ['x' => 5]],
];
$cfg = new Config($data);
$this->assertTrue($cfg->isModuleEnabled('flood'));
$this->assertEquals(12345, $cfg->get('limits.max_size'));
$this->assertEquals(5, $cfg->get('limits.nested.x'));
$this->assertNull($cfg->get('limits.nonexistent'));
}
}

21
tests/ContextTest.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
use PHPUnit\Framework\TestCase;
use UploadLogger\Core\Context;
final class ContextTest extends TestCase
{
public function testContextAccessors(): void
{
$ctx = new Context('r1', '1.2.3.4', '/u', 'POST', 'application/json', 1000, 'user1', 'ua', '');
$this->assertEquals('r1', $ctx->getRequestId());
$this->assertEquals('1.2.3.4', $ctx->getIp());
$this->assertEquals('/u', $ctx->getUri());
$this->assertEquals('POST', $ctx->getMethod());
$this->assertEquals('application/json', $ctx->getContentType());
$this->assertEquals(1000, $ctx->getContentLength());
$this->assertEquals('user1', $ctx->getUser());
$this->assertEquals('ua', $ctx->getUserAgent());
}
}

40
tests/DetectorsTest.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
use PHPUnit\Framework\TestCase;
use UploadLogger\Detectors\FilenameDetector;
use UploadLogger\Detectors\MimeDetector;
use UploadLogger\Detectors\ContentDetector;
use UploadLogger\Core\Context;
final class DetectorsTest extends TestCase
{
public function testFilenameDetector(): void
{
$det = new FilenameDetector();
$ctx = new Context('r','1.1.1.1','/','POST','',0,'guest','', '');
$res = $det->detect($ctx, ['name' => 'image.php.jpg', 'orig_name' => 'image.php.jpg']);
$this->assertTrue(!empty($res['suspicious']));
}
public function testMimeDetector(): void
{
$det = new MimeDetector();
$ctx = new Context('r','1.1.1.1','/','POST','',0,'guest','', '');
$res = $det->detect($ctx, ['name' => 'file.jpg', 'real_mime' => 'text/plain']);
$this->assertTrue(!empty($res['suspicious']));
}
public function testContentDetectorDetectsPhpTag(): void
{
$tmp = tempnam(sys_get_temp_dir(), 'ul');
file_put_contents($tmp, "<?php echo 'x'; ?>");
$det = new ContentDetector();
$ctx = new Context('r','1.1.1.1','/','POST','text/plain', 10, 'guest','', '');
$res = $det->detect($ctx, ['tmp' => $tmp, 'size' => filesize($tmp), 'real_mime' => 'text/plain']);
unlink($tmp);
$this->assertTrue(!empty($res['suspicious']), 'ContentDetector should flag PHP open tag');
}
}

View File

@@ -0,0 +1,4 @@
# Create sample files for upload smoke tests
Set-Content -Path .\tests\smoke\public\sample.txt -Value "hello world"
Set-Content -Path .\tests\smoke\public\suspicious.php -Value "<?php echo 'bad'; ?>"
Write-Host "Created sample files."

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
<?php
// Simple endpoint that accepts file uploads; auto_prepend_file will run uploadshield.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";

File diff suppressed because it is too large Load Diff

56
uploadshield.json Normal file
View File

@@ -0,0 +1,56 @@
{
"modules": {
"flood": true,
"filename": true,
"mime_sniff": true,
"hashing": true,
"base64_detection": true,
"raw_peek": false,
"archive_inspect": true,
"quarantine": true
},
"paths": {
"log_file": "logs/uploads.log",
"quarantine_dir": "quarantine",
"state_dir": "state",
"allowlist_file": "allowlist.json"
},
"limits": {
"max_size": 52428800,
"raw_body_min": 512000,
"sniff_max_bytes": 8192,
"sniff_max_filesize": 2097152,
"hash_max_filesize": 10485760,
"archive_max_inspect_size": 52428800,
"archive_max_entries": 200
},
"ops": {
"quarantine_owner": "root",
"quarantine_group": "www-data",
"quarantine_dir_perms": "0700",
"block_suspicious": false,
"log_rotate": {
"enabled": true,
"size": 10485760,
"keep": 7
}
},
"allowlists": {
"base64_uris": [
"/api/uploads/avatars",
"/api/v1/avatars",
"/user/avatar",
"/media/upload",
"/api/media",
"/api/uploads",
"/api/v1/uploads",
"/attachments/upload",
"/upload",
"#^/internal/webhook#",
"#/hooks/(github|gitlab|stripe|slack)#",
"/services/avatars",
"/api/profile/photo"
],
"ctypes": ["image/svg+xml","application/xml","text/xml"]
}
}

428
uploadshield.php Normal file
View File

@@ -0,0 +1,428 @@
<?php
/**
* UploadShield runtime
* Purpose:
* - Log uploads and detect suspicious uploads before application code runs
* - Configured via `uploadshield.json` (env `UPLOADSHIELD_CONFIG` supported)
*
* Install as PHP-FPM pool `auto_prepend_file=/path/to/uploadshield.php`
*/
// Ignore CLI
if (PHP_SAPI === 'cli') {
return;
}
// Core classes (modular detectors)
require_once __DIR__ . '/core/Context.php';
require_once __DIR__ . '/core/DetectorInterface.php';
require_once __DIR__ . '/core/Logger.php';
require_once __DIR__ . '/core/Dispatcher.php';
require_once __DIR__ . '/core/Config.php';
require_once __DIR__ . '/detectors/FilenameDetector.php';
require_once __DIR__ . '/detectors/MimeDetector.php';
require_once __DIR__ . '/detectors/ContentDetector.php';
require_once __DIR__ . '/core/Services/FloodService.php';
require_once __DIR__ . '/core/Services/SnifferService.php';
require_once __DIR__ . '/core/Services/HashService.php';
require_once __DIR__ . '/core/Services/QuarantineService.php';
require_once __DIR__ . '/core/Services/RequestService.php';
require_once __DIR__ . '/core/Services/LogService.php';
$REQ = new \UploadLogger\Core\Services\RequestService();
/* ================= CONFIG ================= */
// Log file path (prefer per-vhost path outside webroot if possible)
$logFile = __DIR__ . '/logs/uploads.log';
// Block suspicious uploads (true = block request, false = log only)
$BLOCK_SUSPICIOUS = false;
// Warn if file > 50MB
$MAX_SIZE = 50 * 1024 * 1024;
// Treat payload > 500KB with no $_FILES as suspicious "raw upload"
$RAW_BODY_MIN = 500 * 1024;
// Flood detection (per-IP uploads per window)
$FLOOD_WINDOW_SEC = 60;
$FLOOD_MAX_UPLOADS = 40;
// Content sniffing: scan first N bytes for PHP/shell patterns (keep small for performance)
$SNIFF_MAX_BYTES = 8192; // 8KB
$SNIFF_MAX_FILESIZE = 2 * 1024 * 1024; // only sniff files up to 2MB
// If true, also log request headers that are useful in forensics (careful with privacy)
$LOG_USER_AGENT = true;
// Whether the logger may peek into php://input for a small head scan.
// WARNING: reading php://input can consume the request body for the application.
// Keep this false unless you accept the risk or run behind a proxy that buffers request bodies.
$PEEK_RAW_INPUT = false;
// Trusted proxy IPs that may set a header to indicate the request body was buffered
$TRUSTED_PROXY_IPS = ['127.0.0.1', '::1'];
// Environment variable name or marker file to explicitly allow peeking
$ALLOW_PEEK_ENV = 'UPLOADSHIELD_ALLOW_PEEK';
$PEEK_ALLOW_FILE = __DIR__ . '/.uploadshield_allow_peek';
// Auto-enable peek only when explicitly allowed by environment/file or when a
// trusted frontend indicates the body was buffered via header `X-UploadShield-Peek: 1`.
// This avoids consuming request bodies unexpectedly.
try {
$envAllow = getenv($ALLOW_PEEK_ENV) === '1';
} catch (Throwable $e) {
$envAllow = false;
}
// Base64/JSON detection thresholds
$BASE64_MIN_CHARS = 200; // minimum base64 chars to consider a blob
$BASE64_DECODE_CHUNK = 1024; // how many base64 chars to decode for fingerprinting
$BASE64_FINGERPRINT_BYTES = 128; // bytes of decoded head to hash for fingerprint
// Allowlist for known benign base64 sources. Patterns can be simple substrings
// (checked with `strpos`) or PCRE regex when wrapped with '#', e.g. '#^/internal/webhook#'.
// Default in-code allowlist (used if no allowlist file is present)
$BASE64_ALLOWLIST_URI = [
'/api/uploads/avatars',
'/api/v1/avatars',
'/user/avatar',
'/media/upload',
'/api/media',
'/api/uploads',
'/api/v1/uploads',
'/attachments/upload',
'/upload',
'#^/internal/webhook#',
'#/hooks/(github|gitlab|stripe|slack)#',
'/services/avatars',
'/api/profile/photo'
];
// Optional allowlist of content-types (exact match, without params)
$BASE64_ALLOWLIST_CTYPE = [];
// Allowlist file location and environment override
$ALLOWLIST_FILE_DEFAULT = __DIR__ . '/allowlist.json';
$ALLOWLIST_FILE = getenv('UPLOADSHIELD_ALLOWLIST') ?: $ALLOWLIST_FILE_DEFAULT;
if (is_file($ALLOWLIST_FILE)) {
$raw = @file_get_contents($ALLOWLIST_FILE);
$json = @json_decode($raw, true);
if (is_array($json)) {
if (!empty($json['uris']) && is_array($json['uris'])) {
$BASE64_ALLOWLIST_URI = $json['uris'];
}
if (!empty($json['ctypes']) && is_array($json['ctypes'])) {
$BASE64_ALLOWLIST_CTYPE = $json['ctypes'];
}
}
}
// Load config (JSON) or fall back to inline defaults.
// Config file path may be overridden with env `UPLOADSHIELD_CONFIG`.
$CONFIG_FILE_DEFAULT = __DIR__ . '/uploadshield.json';
$CONFIG_FILE = getenv('UPLOADSHIELD_CONFIG') ?: $CONFIG_FILE_DEFAULT;
// Default modules and settings
$DEFAULT_CONFIG = [
'modules' => [
'flood' => true,
'filename' => true,
'mime_sniff' => true,
'hashing' => true,
'base64_detection' => true,
'raw_peek' => false,
'archive_inspect' => true,
'quarantine' => true,
],
];
$CONFIG_DATA = $DEFAULT_CONFIG;
if (is_file($CONFIG_FILE)) {
$rawCfg = @file_get_contents($CONFIG_FILE);
$jsonCfg = @json_decode($rawCfg, true);
if (is_array($jsonCfg)) {
// Merge modules if present
if (isset($jsonCfg['modules']) && is_array($jsonCfg['modules'])) {
$CONFIG_DATA['modules'] = array_merge($CONFIG_DATA['modules'], $jsonCfg['modules']);
}
// Merge other top-level keys
foreach ($jsonCfg as $k => $v) {
if ($k === 'modules') continue;
$CONFIG_DATA[$k] = $v;
}
}
}
$cfgLogFile = $CONFIG_DATA['paths']['log_file'] ?? null;
if (is_string($cfgLogFile) && $cfgLogFile !== '') {
$isAbs = preg_match('#^[A-Za-z]:[\/]#', $cfgLogFile) === 1
|| (strlen($cfgLogFile) > 0 && ($cfgLogFile[0] === '/' || $cfgLogFile[0] === '\\'));
if ($isAbs) {
$logFile = $cfgLogFile;
} else {
$logFile = __DIR__ . '/' . ltrim($cfgLogFile, '/\\');
}
}
$BOOT_LOGGER = new \UploadLogger\Core\Services\LogService($logFile, []);
$fileAllow = is_file($PEEK_ALLOW_FILE);
$headerAllow = false;
if (isset($_SERVER['HTTP_X_UPLOADSHIELD_PEEK']) && $_SERVER['HTTP_X_UPLOADSHIELD_PEEK'] === '1') {
$clientIp = $REQ->getClientIp();
if (in_array($clientIp, $TRUSTED_PROXY_IPS, true)) {
$headerAllow = true;
}
}
if ($envAllow || $fileAllow || $headerAllow) {
$PEEK_RAW_INPUT = true;
$BOOT_LOGGER->logEvent('config_info', ['msg' => 'peek_enabled', 'env' => $envAllow, 'file' => $fileAllow, 'header' => $headerAllow]);
}
// Store flood counters in a protected directory (avoid /tmp tampering)
$STATE_DIR = __DIR__ . '/state';
// Hash files up to this size for forensics
$HASH_MAX_FILESIZE = 10 * 1024 * 1024; // 10MB
// Quarantine suspicious uploads (move outside webroot, restrictive perms)
$QUARANTINE_ENABLED = true; // enabled by default for hardened deployments
$QUARANTINE_DIR = __DIR__ . '/quarantine';
// Archive inspection
$ARCHIVE_INSPECT = true; // inspect archives moved to quarantine
$ARCHIVE_BLOCK_ON_SUSPICIOUS = false; // optionally block request when archive contains suspicious entries
$ARCHIVE_MAX_ENTRIES = 200; // max entries to inspect in an archive
// Max archive file size to inspect (bytes). Larger archives will be skipped to avoid CPU/IO costs.
$ARCHIVE_MAX_INSPECT_SIZE = 50 * 1024 * 1024; // 50 MB
/* ========================================== */
// Ensure log dir
$logDir = dirname($logFile);
if (!is_dir($logDir)) {
@mkdir($logDir, 0750, true);
}
// Ensure state dir
if (!is_dir($STATE_DIR)) {
@mkdir($STATE_DIR, 0750, true);
}
// Ensure quarantine dir if enabled and enforce strict permissions
if ($QUARANTINE_ENABLED) {
if (!is_dir($QUARANTINE_DIR)) {
@mkdir($QUARANTINE_DIR, 0700, true);
}
if (is_dir($QUARANTINE_DIR)) {
// attempt to enforce strict permissions (owner only)
@chmod($QUARANTINE_DIR, 0700);
// verify perms: group/other bits must be zero
$perms = @fileperms($QUARANTINE_DIR);
if ($perms !== false) {
// mask to rwxrwxrwx (lower 9 bits)
$mask = $perms & 0x1FF;
// if any group/other bits set, warn
if (($mask & 0o077) !== 0) {
if (isset($BOOT_LOGGER)) {
$BOOT_LOGGER->logEvent('config_warning', [
'msg' => 'quarantine_dir_perms_not_strict',
'path' => $QUARANTINE_DIR,
'perms_octal' => sprintf('%o', $mask),
]);
}
}
}
} else {
if (isset($BOOT_LOGGER)) {
$BOOT_LOGGER->logEvent('config_error', ['msg' => 'quarantine_dir_missing', 'path' => $QUARANTINE_DIR]);
}
}
}
// Attempt to enforce owner:group for quarantine directory when possible
$DESIRED_QUARANTINE_OWNER = 'root';
$DESIRED_QUARANTINE_GROUP = 'www-data';
if ($QUARANTINE_ENABLED && is_dir($QUARANTINE_DIR)) {
// If running as root, attempt to chown/chgrp to desired values
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
@chown($QUARANTINE_DIR, $DESIRED_QUARANTINE_OWNER);
@chgrp($QUARANTINE_DIR, $DESIRED_QUARANTINE_GROUP);
} elseif (function_exists('posix_getegid') && function_exists('posix_getgrgid')) {
// Not root: try at least to set group to the process group
$egid = posix_getegid();
$gr = posix_getgrgid($egid);
if ($gr && isset($gr['name'])) {
@chgrp($QUARANTINE_DIR, $gr['name']);
}
}
// Verify owner/group and log if not matching desired values
$ownerOk = false;
$groupOk = false;
$statUid = @fileowner($QUARANTINE_DIR);
$statGid = @filegroup($QUARANTINE_DIR);
if ($statUid !== false && function_exists('posix_getpwuid')) {
$pw = posix_getpwuid($statUid);
if ($pw && isset($pw['name']) && $pw['name'] === $DESIRED_QUARANTINE_OWNER) {
$ownerOk = true;
}
}
if ($statGid !== false && function_exists('posix_getgrgid')) {
$gg = posix_getgrgid($statGid);
if ($gg && isset($gg['name']) && $gg['name'] === $DESIRED_QUARANTINE_GROUP) {
$groupOk = true;
}
}
if (!($ownerOk && $groupOk)) {
if (isset($BOOT_LOGGER)) {
$BOOT_LOGGER->logEvent('config_warning', [
'msg' => 'quarantine_owner_group_mismatch',
'path' => $QUARANTINE_DIR,
'desired_owner' => $DESIRED_QUARANTINE_OWNER,
'desired_group' => $DESIRED_QUARANTINE_GROUP,
'current_uid' => $statUid,
'current_gid' => $statGid,
]);
}
}
}
/* ---------- Utils ---------- */
function is_suspicious_filename(string $name): bool
{
$n = strtolower($name);
// Path traversal / weird separators in filename
if (strpos($n, '../') !== false || strpos($n, '..\\') !== false || strpos($n, "\0") !== false) {
return true;
}
// Dangerous extensions (final)
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $n)) {
return true;
}
// Double-extension tricks anywhere (e.g., image.php.jpg or image.jpg.php)
if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)\./i', $n)) {
return true;
}
// Hidden dotfile php-like names
if (preg_match('/^\.(php|phtml|phar|php\d)/i', $n)) {
return true;
}
return false;
}
function is_fake_image(string $name, string $realMime): bool
{
// If filename looks like image but real mime is not image/*
if (preg_match('/\.(png|jpe?g|gif|webp|bmp|ico|svg)$/i', $name)) {
// SVG often returns image/svg+xml; still image/*
if (!preg_match('/^image\//', $realMime)) {
return true;
}
}
return false;
}
/* ---------- Context ---------- */
[$ip, $uri, $method, $ctype, $clen, $ua, $te] = $REQ->getRequestSummary($LOG_USER_AGENT);
$userId = $REQ->getUserId();
$requestId = $REQ->generateRequestId();
$REQUEST_CTX = [
'request_id' => $requestId,
'ip' => $ip,
'uri' => $uri,
'method' => $method,
'ctype' => $ctype,
'clen' => (int)$clen,
'user' => $userId,
'ua' => $ua,
'transfer_encoding' => $te,
];
// Logger instance for structured JSON output
$CONFIG = new \UploadLogger\Core\Config($CONFIG_DATA);
$LOGGER = new \UploadLogger\Core\Logger($logFile, $REQUEST_CTX, $CONFIG);
/*
* Map frequently-used legacy globals to values from `Config` so the rest of
* the procedural helpers can continue to reference globals but operators
* may control behavior via `uploadshield.json`.
*/
$BLOCK_SUSPICIOUS = $CONFIG->get('ops.block_suspicious', $BLOCK_SUSPICIOUS ?? false);
$MAX_SIZE = (int)$CONFIG->get('limits.max_size', $MAX_SIZE ?? (50 * 1024 * 1024));
$RAW_BODY_MIN = (int)$CONFIG->get('limits.raw_body_min', $RAW_BODY_MIN ?? (500 * 1024));
$FLOOD_WINDOW_SEC = (int)$CONFIG->get('limits.flood_window_sec', $FLOOD_WINDOW_SEC ?? 60);
$FLOOD_MAX_UPLOADS = (int)$CONFIG->get('limits.flood_max_uploads', $FLOOD_MAX_UPLOADS ?? 40);
$SNIFF_MAX_BYTES = (int)$CONFIG->get('limits.sniff_max_bytes', $SNIFF_MAX_BYTES ?? 8192);
$SNIFF_MAX_FILESIZE = (int)$CONFIG->get('limits.sniff_max_filesize', $SNIFF_MAX_FILESIZE ?? (2 * 1024 * 1024));
$LOG_USER_AGENT = (bool)$CONFIG->get('ops.log_user_agent', $LOG_USER_AGENT ?? true);
// Determine whether peeking into php://input may be used (module + runtime allow)
$PEEK_RAW_INPUT = ($CONFIG->isModuleEnabled('raw_peek') || ($PEEK_RAW_INPUT ?? false)) ? ($PEEK_RAW_INPUT ?? false) : ($PEEK_RAW_INPUT ?? false);
$TRUSTED_PROXY_IPS = $CONFIG->get('ops.trusted_proxy_ips', $TRUSTED_PROXY_IPS ?? ['127.0.0.1', '::1']);
$ALLOWLIST_FILE = $CONFIG->get('paths.allowlist_file', $ALLOWLIST_FILE ?? (__DIR__ . '/allowlist.json'));
$STATE_DIR = $CONFIG->get('paths.state_dir', $STATE_DIR ?? (__DIR__ . '/state'));
$HASH_MAX_FILESIZE = (int)$CONFIG->get('limits.hash_max_filesize', $HASH_MAX_FILESIZE ?? (10 * 1024 * 1024));
$QUARANTINE_ENABLED = $CONFIG->isModuleEnabled('quarantine') && ($CONFIG->get('ops.quarantine_enabled', $QUARANTINE_ENABLED ?? true));
$QUARANTINE_DIR = $CONFIG->get('paths.quarantine_dir', $QUARANTINE_DIR ?? (__DIR__ . '/quarantine'));
$ARCHIVE_INSPECT = $CONFIG->isModuleEnabled('archive_inspect') || ($ARCHIVE_INSPECT ?? false);
$ARCHIVE_BLOCK_ON_SUSPICIOUS = (bool)$CONFIG->get('ops.archive_block_on_suspicious', $ARCHIVE_BLOCK_ON_SUSPICIOUS ?? false);
$ARCHIVE_MAX_ENTRIES = (int)$CONFIG->get('limits.archive_max_entries', $ARCHIVE_MAX_ENTRIES ?? 200);
$ARCHIVE_MAX_INSPECT_SIZE = (int)$CONFIG->get('limits.archive_max_inspect_size', $ARCHIVE_MAX_INSPECT_SIZE ?? (50 * 1024 * 1024));
// Detector context and registry
$CONTEXT = new \UploadLogger\Core\Context(
$requestId,
$ip,
$uri,
$method,
$ctype,
(int)$clen,
$userId,
$ua,
$te
);
$DETECTORS = [
new \UploadLogger\Detectors\FilenameDetector(),
new \UploadLogger\Detectors\MimeDetector(),
new \UploadLogger\Detectors\ContentDetector($CONFIG),
];
// Dispatch request processing
$FLOOD_SERVICE = new \UploadLogger\Core\Services\FloodService($CONFIG);
$SNIFFER_SERVICE = new \UploadLogger\Core\Services\SnifferService($CONFIG);
$HASH_SERVICE = new \UploadLogger\Core\Services\HashService($CONFIG);
$QUARANTINE_SERVICE = new \UploadLogger\Core\Services\QuarantineService($CONFIG);
$DISPATCHER = new \UploadLogger\Core\Dispatcher($LOGGER, $CONTEXT, $DETECTORS, $CONFIG, $FLOOD_SERVICE, $SNIFFER_SERVICE, $HASH_SERVICE, $QUARANTINE_SERVICE);
$DISPATCHER->dispatch($_FILES, $_SERVER);