laravel-at/laravel-image-sanitize
Laravel middleware that re-encodes uploaded images to strip hidden malicious payloads, reducing the risk of code execution. Requires PHP 8.3+, Laravel 12/13, and Intervention Image 4.
Requirements: PHP 8.3+, Laravel 12/13, Intervention Image 4 (installed automatically as a dependency).
composer require laravel-at/laravel-image-sanitize
php artisan vendor:publish --tag=image-sanitize-config
Register an alias once in bootstrap/app.php, so upload routes read cleanly:
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'image-sanitize' => \LaravelAt\ImageSanitize\ImageSanitizeMiddleware::class,
]);
})
Where to look first, in this order:
config/image-sanitize.php — allowed_mime_types, patterns, driver, quality, auto_orientation, decode_animation, strip_metadata.LaravelAt\ImageSanitize\ImageSanitizeMiddleware — the thing you'll attach to routes.ImageSanitize facade — detect() / sanitize() for everything the middleware can't reach.First realistic use case: an avatar/product-image endpoint. Drop the alias on the route and you get, for free, a scan of each uploaded JPEG/PNG/GIF/BMP/WebP for PHP payload byte patterns (<?php, <?=, phar, case-insensitive) followed by a full re-encode through Intervention Image when a match is found — which destroys the appended payload while keeping a valid image.
Route::post('/avatar', [AvatarController::class, 'store'])
->middleware(['auth', 'image-sanitize']);
That's the entire integration. Everything else below is about the edges.
Route groups over single routes. Put it on the whole upload surface rather than remembering per-controller:
Route::middleware(['auth:sanctum', 'image-sanitize'])->group(function () {
Route::post('/files', [FileController::class, 'upload']);
Route::post('/users/{user}/avatar', [AvatarController::class, 'store']);
});
Include any first-party direct-upload endpoints (FilePond/Livewire temporary uploads, Editor.js image handlers) — they're the easiest upload routes to forget, and they receive bytes exactly like your main endpoint.
Middleware order matters. Place image-sanitize after auth (don't spend CPU decoding images for anonymous traffic) and before anything that persists or forwards the file. Validation in a FormRequest or $request->validate() happens inside the controller, so it runs after the middleware — meaning you validate already-sanitized content, which is what you want.
When there is no HTTP request. The middleware is HTTP-only. For images arriving from queue jobs, API webhooks, email attachments, S3 sync commands, or assembled chunked uploads, use the facade directly:
use LaravelAt\ImageSanitize\Facades\ImageSanitize;
$contents = file_get_contents($path);
if (ImageSanitize::detect($contents)) {
$contents = (string) ImageSanitize::sanitize($contents);
Storage::disk('private')->put($key, $contents);
}
Note detect() deliberately does not boot ImageManager (v5 change), so you can use it as a cheap pre-filter before the expensive re-encode — handy when scanning a directory of existing uploads during a one-off audit command.
Swap the driver when GD isn't enough. ImageManager is resolved from the container and respects an existing binding, so you can move to Imagick application-wide without touching the package:
$this->app->bind(\Intervention\Image\ImageManager::class, fn () => new \Intervention\Image\ImageManager(
new \Intervention\Image\Drivers\Imagick\Driver()
));
(Or just set 'driver' => \Intervention\Image\Drivers\Imagick\Driver::class in the config — but the binding wins, so don't be surprised when a config change appears to do nothing.)
Tune quality down for production. The default is 100. For user avatars and thumbnails, 82–90 gives a large size win with no visible loss; the re-encode only happens on flagged files, but those are exactly the files an attacker can make arbitrarily large.
Combine with, not instead of, validation and storage discipline. The package's own README says as much. A healthy upload endpoint looks like:
$request->validate([
'image' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
]);
$path = $request->file('image')->storeAs(
'uploads',
Str::uuid()->toString().'.'.$request->file('image')->extension(),
'private' // no execution, no public webroot
);
Serve through a controller/Storage::response() or a CDN that forces Content-Type: image/* and Content-Disposition. Middleware sanitization is the last line, not the only line.
Add your own patterns sparingly. Extending patterns is the intended extension point, but each string is matched against raw image bytes: a legitimate photo could theoretically contain onerror or <script as incidental binary. That doesn't create a vulnerability — it triggers a harmless re-encode — but it can silently alter files and inflate storage. Prefer payload markers that don't appear in compressed image data, and log when a match fires so you know your false-positive rate.
Wrap or extend the middleware for observability. Subclass it, call parent::handle(), and log the request ID + file names when a sanitize occurs. A sudden spike in re-encodes is a useful intrusion signal, and the package gives you no reporting out of the box.
Testing the detection path is the awkward part. UploadedFile::fake()->image('x.jpg') produces a clean file, so the middleware no-ops and your test proves nothing. Build a polyglot fixture and pass it with an explicit MIME type:
$path = base_path('tests/fixtures/polyglot.jpg'); // valid JPEG + "<?php system(...);"
$file = new \Illuminate\Http\UploadedFile($path, 'evil.jpg', 'image/jpeg', null, true);
$this->post('/avatar', ['image' => $file])->assertOk();
Test both branches: a clean image comes back byte-identical, and a polyglot image comes back with the payload gone but still decodable by Intervention.
Clean files are not re-encoded. By default, only files where a pattern matches get run through Intervention. If you're relying on this package to strip EXIF/GPS metadata for privacy (or to guarantee strip_metadata), it won't do that for unflagged uploads. Set auto_orientation/strip_metadata behaviour into your own pipeline (e.g., always re-encode avatars via a dedicated job) if that's a requirement.
SVG is explicitly unsupported. SVG isn't an image format here — it's an XML document that can carry <script>. The MIME allow-list will skip it entirely, so a "sanitized" upload route will happily pass a malicious SVG through untouched if your validation permits it. Either reject SVG outright (mimes:jpg,jpeg,png,webp,gif) or sanitize it with a dedicated XML/SVG sanitizer.
Files outside the MIME allow-list are skipped silently. AVIF, HEIC, TIFF, and ICO uploads aren't scanned and aren't re-encoded. If your app accepts them, add them to allowed_mime_types — but confirm your Intervention driver can actually decode them, or the sanitize step will throw.
Published configs go stale. Config files published before v5.0.1 are missing <?= from patterns. Every upgrade, diff your published patterns against the package default. And remember php artisan config:cache in your deploy script — editing config without re-caching is the classic "why didn't my change apply" afternoon.
Case-insensitivity is built in. <?php matches <?PHP. Don't add casing variants; they're redundant and just clutter the list.
Nested and repeated array uploads are only safe on v5. v1–v4 had bugs where nested uploads under repeated field names were dropped from the request. If you're upgrading from an older major, this alone is worth the jump — and it's worth a regression test with images[0][file] style payloads.
Decoding can throw. A truncated or corrupt image that happens to match a pattern will hit Intervention's decoder and raise a DecoderException, surfacing as a 500 on your upload endpoint. Catch it — either in a wrapping middleware or via Handler::render() — and return a 422. Attackers send malformed files on purpose.
Re-encoding is CPU- and memory-heavy. A 20 MP image at quality: 100 inside a synchronous request can blow past memory_limit. Enforce max: in validation before the middleware does its work where possible, raise memory_limit for the upload route, and consider pushing large-media handling to a queue with the facade instead of the middleware.
Chunked/resumable uploads bypass the middleware entirely. The chunks arrive as small requests; the file is assembled later. Sanitize at assembly time in the job, not on the chunk endpoints.
Watch the extension after sanitization. The file content is re-encoded; the client-supplied extension isn't recomputed by the package. Derive the stored extension from the file's actual MIME (or, better, from a fixed allow-list map), not from getClientOriginalExtension().
Extension points, ranked by usefulness: custom patterns → service-container binding for ImageManager → extending ImageSanitizeMiddleware for logging → facade calls in non-HTTP entry points. There is no event or hook fired on detection, so if you need telemetry, subclass.
Treat it as one layer. Pattern scanning is heuristic: base64-encoded or obfuscated payloads, payloads in formats the scanner skips, and payloads in files with unusual MIME detection can all slip past. Its real value is defence-in-depth on top of safe storage (non-executable path, hashed filenames), strict MIME/size validation, and a web server that never hands uploaded files to a PHP interpreter.
How can I help you explore Laravel packages today?