DEV Community

Cover image for Sanitizing Image Uploads in Laravel: Stopping PHP Payload Injection via Image Files
Mathias Onea
Mathias Onea

Posted on

Sanitizing Image Uploads in Laravel: Stopping PHP Payload Injection via Image Files

TL;DR

Image uploads aren't just images. A JPEG can carry a PHP web shell in its EXIF comment and still pass MIME checks, extension checks, and even getimagesize(). If that file ever lands somewhere executable - a misconfigured public/uploads, a .htaccess override, a chained LFI - you've got remote code execution.

laravel-at/laravel-image-sanitize is a small middleware that scans uploaded images for payload markers (<?php, phar) and re-encodes anything suspicious through Intervention Image, stripping the payload before your controller ever sees the file.

composer require laravel-at/laravel-image-sanitize
Enter fullscreen mode Exit fullscreen mode

It's defense-in-depth, not a replacement for validation. Here's why you need it anyway, and how it works.

The problem: images that aren't just images

A valid image file is just bytes with a known header. Nothing stops you from appending arbitrary text after that header. The image still renders fine in a browser - image decoders ignore trailing garbage.

This is the classic "GIFAR" / polyglot trick, still alive in 2026:

exiftool -Comment='<?php system($_GET["c"]); ?>' shell.jpg
Enter fullscreen mode Exit fullscreen mode

That file:

  • Has a real JPEG header. file shell.jpg says JPEG image data.

  • Passes getimagesize(). PHP reads the header, never the comment.

  • Passes $request->file('avatar')->isValid() and 'image' validation rules.

  • Renders normally in <img> tags.

  • Contains a working PHP payload in its EXIF data.

If an attacker can get this file written with a .php extension somewhere reachable - via a separate path traversal bug, a misconfigured rename, or a server that executes .jpg.php - they get code execution. Plenty of real-world breaches chain exactly this: an "unrelated" upload bug plus a permissive storage path.

Why MIME and extension checks aren't enough

Laravel's standard validation:

$request->validate([
    'avatar' => 'required|image|mimes:jpg,png,gif|max:2048',
]);
Enter fullscreen mode Exit fullscreen mode

This checks the container, not the contents. mimes: looks at the file extension and a magic-byte sniff. image confirms it decodes as an image. None of that inspects what else is sitting inside the file alongside valid image data.

MIME sniffing answers "is this technically an image?" It doesn't answer "is this image free of embedded code?" Those are different questions, and most upload pipelines only ask the first one.

The fix: detect and re-encode

laravel-image-sanitize adds a layer that asks the second question. The flow is four steps:

  1. Filter by MIME type. The middleware only inspects files matching your allow-list (JPEG, PNG, GIF, BMP, WebP by default).

  2. Scan for payload markers. File contents are checked against configured patterns - <?php and phar out of the box.

  3. Re-encode on match. If a marker is found, the image is decoded and re-encoded from scratch through Intervention Image. Decoding only reads pixel data; anything appended outside the actual image stream gets dropped on re-encode.

  4. Replace before the controller runs. The rewritten bytes replace the original upload content. Your controller, your validation rules, your storage logic - none of it changes.

The key insight: a genuine image decoder never reads the EXIF comment as executable. Re-encoding rebuilds the file from pixel data only, so anything appended after the image stream - your PHP payload - never makes it into the output file.

Middleware usage

Attach it directly to upload routes:

use App\Http\Controllers\FileController;
use LaravelAt\ImageSanitize\ImageSanitizeMiddleware;

Route::post('/files', [FileController::class, 'upload'])
    ->name('file.upload')
    ->middleware(ImageSanitizeMiddleware::class);
Enter fullscreen mode Exit fullscreen mode

Or register a readable alias in bootstrap/app.php (Laravel 12/13):

use Illuminate\Foundation\Configuration\Middleware;
use LaravelAt\ImageSanitize\ImageSanitizeMiddleware;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'image-sanitize' => ImageSanitizeMiddleware::class,
    ]);
})
Enter fullscreen mode Exit fullscreen mode
Route::post('/files', [FileController::class, 'upload'])
    ->name('file.upload')
    ->middleware('image-sanitize');
Enter fullscreen mode Exit fullscreen mode

Direct usage outside the middleware

Handling raw image bytes in a job, an API client, or a CLI import? Call the sanitizer directly:

if (ImageSanitize::detect($contents)) {
    $contents = (string) ImageSanitize::sanitize($contents);
}
Enter fullscreen mode Exit fullscreen mode

detect() runs the pattern scan; sanitize() does the decode/re-encode pass. Useful for queued processing where the file never goes through an HTTP route.

Configuration

Publish the config when you need to change defaults:

php artisan vendor:publish --tag=image-sanitize-config
Enter fullscreen mode Exit fullscreen mode

Defaults:

return [
    'allowed_mime_types' => [
        'image/jpeg',
        'image/png',
        'image/gif',
        'image/bmp',
        'image/webp',
    ],

    'patterns' => [
        '<?php',
        'phar',
    ],

    'driver' => \Intervention\Image\Drivers\Gd\Driver::class,
    'quality' => 100,
    'auto_orientation' => true,
    'decode_animation' => true,
    'strip_metadata' => true,
];
Enter fullscreen mode Exit fullscreen mode

A few notes:

  • quality => 100 means JPEG re-encoding is near-lossless by default - bump it down if you also want smaller files.

  • strip_metadata => true removes EXIF data on re-encode, which is also a privacy win (no leaked GPS coordinates from phone uploads).

  • decode_animation => true handles animated GIF/WebP correctly instead of flattening to one frame.

  • SVG is not in the allow-list. SVG can carry active content (<script>, event handlers) and needs a different threat model than raster re-encoding solves. Don't add it without your own sanitization for that format.

What this doesn't do

Be clear-eyed about scope. This package:

  • Doesn't replace mimes:/image validation rules - keep them.

  • Doesn't do authorization, rate limiting, or virus scanning.

  • Doesn't make storing uploads inside a public, executable path safe. Keep uploaded files out of public/ execution paths regardless.

  • Doesn't cover SVG, PDF, or non-image uploads.

It's one layer that closes a specific gap: code smuggled inside otherwise-valid image bytes. Stack it with proper storage isolation (uploads on non-executable disks, served via signed URLs or a controller, not direct web-root access) and you've meaningfully reduced the blast radius of a polyglot file slipping through.

Requirements: Laravel ^12.0 | ^13.0, PHP ^8.3, MIT licensed.

FAQ

Does this replace Laravel's image validation rule? No. Run both. Validation confirms the file is a structurally valid image; the sanitizer checks for embedded payload markers and re-encodes when found. They cover different failure modes.

Will re-encoding degrade my images? Quality defaults to 100, so JPEG loss is minimal. PNG/GIF/WebP re-encoding via GD is lossless for typical use. If you need smaller output, lower quality in the config.

Why isn't SVG supported? SVG is XML and can contain <script> tags or event handlers that execute in some rendering contexts. Re-encoding a raster image doesn't translate to "sanitizing" markup - SVG needs its own sanitizer (e.g., stripping script/event-handler nodes), which is out of scope here.

Does this stop every upload-based attack? No single package does. This closes the "PHP/PHAR embedded in image bytes" gap specifically. Combine it with storage isolation, strict MIME/extension validation, and not executing PHP from upload directories.

Join the discussion

  • What's the worst upload-based exploit you've seen chained with a storage misconfig?

  • quality => 100 by default - smart, or should it optimize for file size out of the box? Disagree below.

  • Hit a case where you needed SVG sanitization? What blocked you from shipping it?

Sources

Top comments (1)

Collapse
 
mathiasonea profile image
Mathias Onea

For me, the worst cases are usually upload + bad storage config. A “valid” image contains PHP code, gets stored somewhere executable, and suddenly you're busy trying to clean up a mess that shouldn't exist in the first place.