abdian / laravel-upload-guard
Secure file upload validation for Laravel β fail-closed scanning for polyglot web shells, malicious PDFs/SVGs, zip bombs, Office macros, and spoofed MIME types.
Requires
- php: ^8.1
- ext-dom: *
- ext-fileinfo: *
- ext-libxml: *
- ext-zip: *
- enshrined/svg-sanitize: ^0.21
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- illuminate/validation: ^10.0|^11.0|^12.0
- smalot/pdfparser: ^2.5
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.0
Suggests
- ext-exif: Enables EXIF metadata inspection/stripping in the image scanner; the scanner degrades gracefully without it.
- ext-gd: Required for the optional image re-encode mode that strips embedded payloads.
- ext-imagick: Alternative backend for the optional image re-encode mode.
README
π‘οΈ 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
Always-on code scanning
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.
Structural MIME detection
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".
SVG sanitization
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).
PDF decode-before-scan
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.
Real zip-bomb detection
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.
Office macros & macro-less vectors
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.
Image hardening
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.