laravel-upload-guard maintained by abdian
🛡️ Laravel Upload Guard
Fail-closed file-upload validation for Laravel.
Stops polyglot web shells, malicious PDFs & SVGs, zip bombs, Office macros, and spoofed MIME types — using structural parsing and content sanitization, not just regex.
// One rule. Fail-closed by default.
$request->validate([
'file' => 'required|safeguard',
]);
Why?
Laravel's built-in mimes / mimetypes rules trust the client-declared type
and a coarse extension map. An attacker can upload shell.php renamed to
avatar.jpg, a real JPEG with PHP appended after the image data (a polyglot
web shell), an SVG carrying <script>, a PDF with an auto-run /JavaScript
action, or a 42 KB zip that expands to petabytes. None of those are caught by
extension checks.
Upload Guard inspects the actual bytes — magic structure, decoded PDF/zip streams, sanitized SVG/Office internals — and blocks anything it cannot prove is safe.
🔒 Design principle: fail closed
When the package cannot be sure a file is safe, it blocks the upload. Unknown content types, unparsable containers, and scanner exceptions all resolve to reject — never to allow. Stricter than lax validators by design.
Threat coverage
| Threat | How Upload Guard handles it |
|---|---|
| 🐚 Polyglot web shells (PHP in JPEG / PDF / ZIP) | Always-on code scan on every upload, regardless of detected type |
| 🎭 Spoofed MIME / double extension | Structural byte detection + strict extension ↔ content matching |
| 🖼️ Malicious SVG (XSS / XXE) | Allowlist sanitization; DOCTYPE/entity/script stripping; stored clean |
📄 Malicious PDF (/JavaScript, /OpenAction, /Launch) |
Decode-before-scan, indirect-/Filter resolution, bounded inflation |
| 💣 Zip bombs & zip-slip | Global actual-bytes cap across nested archives; traversal / symlink / NTFS-ADS rejection |
| 📎 Office macros + macro-less RCE | OOXML and legacy OLE/CFB; VBA, ActiveX, DDE/DDEAUTO, remote attachedTemplate |
| 🧨 Image decompression bombs | Header pixel/byte cap before any decode; optional re-encode to strip payloads |
| 🌊 Upload DoS | Hard size caps + optional per-IP rate limiting + opt-in forensic quarantine |
Table of contents
- Installation
- Quick start
- Usage
- Fluent API reference
- Configuration
- How it works
- Hardening notes
- Testing
- Security
- License
Installation
composer require abdian/laravel-upload-guard
The service provider is auto-discovered. Publish the (fully commented) config to tune behavior:
php artisan vendor:publish --tag=safeguard-config
Requirements
| PHP | 8.1 · 8.2 · 8.3 · 8.4 |
| Laravel | 10 · 11 · 12 |
| Required extensions | fileinfo, zip, dom, libxml |
| Optional extensions | exif (EXIF inspection/stripping) · gd or imagick (image re-encode mode) |
Optional extensions degrade gracefully — the package installs and runs without them.
Quick start
public function store(\Illuminate\Http\Request $request)
{
$request->validate([
'file' => 'required|safeguard',
]);
$request->file('file')->store('uploads');
}
The single safeguard rule runs — by default, no fluent calls required:
✅ structural MIME detection + dangerous-type blocking · ✅ strict extension/content matching · ✅ always-on code scanning · ✅ SVG sanitization · ✅ image & PDF scanning · ✅ archive and Office-macro scanning.
Usage
With Laravel's mimes rule
$request->validate([
'file' => 'required|safeguard|mimes:jpg,png,pdf',
]);
safeguard reads the allowed extensions and enforces that the file's real
content type matches them.
Fluent configuration
use Abdian\UploadGuard\Rules\Safeguard;
$request->validate([
'avatar' => ['required', (new Safeguard)
->imagesOnly()
->maxDimensions(1920, 1080)
->blockGps()
->stripMetadata(),
],
'document' => ['required', (new Safeguard)
->pdfsOnly()
->maxPages(50)
->blockJavaScript()
->blockExternalLinks(),
],
'report' => ['required', (new Safeguard)
->documentsOnly(), // archive + macro scanning are already on by default
],
]);
Individual rules
Compose only the scanners you need:
$request->validate([
'avatar' => 'required|safeguard_mime:image/jpeg,image/png|safeguard_image',
'icon' => 'required|safeguard_svg',
'document' => 'required|safeguard_pdf|safeguard_pages:1,10',
'photo' => 'required|safeguard_dimensions:100,100,4000,4000',
'archive' => 'required|safeguard_archive',
'report' => 'required|safeguard_office',
]);
| Rule | Description |
|---|---|
safeguard |
All-in-one, fail-closed pipeline |
safeguard_mime:type1,type2 |
Real content-type allowlist (+ dangerous-type block) |
safeguard_php |
Always-on PHP/script code scan |
safeguard_svg |
Allowlist SVG sanitization |
safeguard_image |
Image bomb / metadata / byte / trailing-data scan |
safeguard_pdf |
Decode-before-scan PDF analysis |
safeguard_archive |
Streaming archive inspection (zip/tar/gz) |
safeguard_office |
OOXML + legacy OLE macro / DDE / template detection |
safeguard_dimensions:maxW,maxH,minW,minH |
Image dimension limits |
safeguard_pages:min,max |
PDF page-count limits |
Note on
safeguard_archivestring params: parameters are added to the block list (e.g.safeguard_archive:iso,binalso blocks.iso/.bin). To allow an otherwise-blocked extension, use the fluent rule:(new SafeguardArchive)->allow(['sh']).
Fluent API reference
All methods on Abdian\UploadGuard\Rules\Safeguard return $this (chainable).
| Method | Effect |
|---|---|
allowedMimes(array $mimes) |
Restrict to a real-content-type allowlist ('image/*' wildcards supported) |
imagesOnly() / pdfsOnly() / documentsOnly() / archivesOnly() |
Restrict to a file family |
maxDimensions(int $w, int $h) / minDimensions(int $w, int $h) |
Image dimension bounds |
dimensions(int $minW, int $minH, int $maxW, int $maxH) |
All four bounds at once |
maxPages(int) / minPages(int) / pages(int $min, int $max) |
PDF page-count bounds |
blockGps() |
Reject images that contain GPS/EXIF location data |
stripMetadata() |
Strip metadata from images |
blockJavaScript() |
Reject PDFs containing JavaScript |
blockExternalLinks() |
Reject PDFs containing external links |
strictExtensionMatching(bool = true) |
Force/disable extension ↔ content matching |
scanArchives(bool = true) |
Toggle archive scanning (on by default) |
blockMacros(bool = true) / allowMacros() |
Toggle Office-macro blocking (on by default) |
Configuration
The published config/safeguard.php is fully commented; highlights:
'max_scan_size' => 25 * 1024 * 1024, // files larger than this are rejected
'over_cap_policy' => 'reject', // or 'header_only'
'mime_validation' => [
'strict_check' => true,
'block_dangerous' => true,
'block_undetectable' => false, // set true to reject unknown content types
],
'archive_scanning' => [
'enabled' => true, // ON by default
'max_decompressed_size' => 500 * 1024 * 1024, // hard cap on ACTUAL bytes (global)
'max_files_count' => 10000,
'max_nesting_depth' => 3,
],
'office_scanning' => [
'enabled' => true, // ON by default
'block_macros' => true,
'block_activex' => true,
],
'svg_scanning' => ['mode' => 'sanitize'], // or 'reject'
'image_scanning' => ['max_pixels' => 64_000_000, 'reencode' => false],
'rate_limiting' => ['enabled' => false], // DoS guard (opt-in)
'quarantine' => ['enabled' => false], // forensic quarantine (opt-in)
Every key is also overridable via environment variables (e.g.
SAFEGUARD_ARCHIVE_SCAN, SAFEGUARD_SVG_MODE, SAFEGUARD_IMAGE_REENCODE).
How it works
Every upload is scanned for PHP/script openers (<?php, <?=, bare <?,
<script language=php>, <%, __halt_compiler) regardless of detected type —
a valid image/PDF/ZIP header never exempts a file, so polyglot web shells appended
after a magic header are caught. The dangerous-function layer only triggers inside
real PHP regions, so .js/.py/.csv text never false-positives.
Classifies by byte structure (≥512-byte header window), disambiguates
OLE/ftyp/RIFF/ZIP families (real .xls → Excel, JAR/APK detected), validates short
signatures, and returns untrusted (null) for unknown content — never
"binary safe".
SVGs run through an allowlist sanitizer and the cleaned output is stored.
Unquoted handlers, encoded javascript: URIs, <script>, and all
DTD/DOCTYPE/entities are removed. XML parsing installs a denying external-entity
loader (XXE-safe).
Flate/LZW/ASCII85/ASCIIHex and object streams are inflated (with bounded output)
and #xx names decoded before matching /JavaScript, /JS, /OpenAction,
/AA, /Launch, /EmbeddedFile. Indirect and decoy /Filter references are
resolved so compressed payloads can't hide. Matches are delimiter-anchored and
case-sensitive. Encrypted PDFs that can't be inspected are rejected.
Archives are streamed against a hard cap on actual decompressed bytes that is global across the whole nested-archive tree (nested fan-out can't multiply it); forged central-directory / TAR sizes can't bypass it. Traversal (both separators), absolute paths, NTFS ADS, dangerous extensions on any name segment, symlinks, and unreadable entries are all rejected.
VBA/OLE/ActiveX in both OOXML and legacy OLE/CFB (.doc/.xls/.ppt), resolved via
relationships and content types (case-insensitive). Also detects DDE/DDEAUTO
field codes and external/remote-template (attachedTemplate) injection. The CFB
reader follows the full DIFAT chain and fails closed on truncated containers.
Decompression-bomb guard enforced from the header before any decode (also
inside the re-encode path), full EXIF/metadata + byte scanning (works without
ext-exif), trailing-data detection, and an optional GD/Imagick re-encode that
strips appended payloads.
Hardening notes
- What it is not: a synchronous validation library, not an antivirus — no AV signatures, sandboxed detonation, or ML classification.
- TOCTOU: scanning the temp file doesn't close the temp→storage window. Move
validated uploads carefully and prefer enabling
image_scanning.reencode. - SVG storage: in
sanitizemode the uploaded file is rewritten in place with the cleaned SVG, so->store()persists the safe version. - Workers: rate-limiter counters are atomic, the MIME cache is bounded, and per-instance rule overrides are restored after each validation — safe under Octane/queue workers.
Testing
composer test # PHPUnit (Testbench) — 142 tests with malicious fixtures
composer analyse # PHPStan (level 5)
composer check # validate + analyse + test
Security
Please report vulnerabilities privately — see SECURITY.md (email esanjdev@gmail.com or open a private GitHub Security Advisory). Please do not open public issues for security reports.
Contributing
Contributions are welcome — see CONTRIBUTING.md. Run
composer check before opening a PR.
License
Open-sourced under the MIT license.