VYPR
High severity8.8NVD Advisory· Published May 29, 2026· Updated May 29, 2026

CVE-2026-48557

CVE-2026-48557

Description

Spatie Laravel Media Library before version 11.23.0 contains a file upload restriction bypass in FileAdder::defaultSanitizer(). The sanitizer checks only the final filename suffix, allowing double-extension filenames such as shell.php.jpg to bypass the blocklist, with pathinfo() preserving inner .php stems in saved filenames. The blocklist also omits executable extensions including .php6, .shtml, and .htaccess. The double-extension bypass requires a legacy Apache AddHandler configuration to achieve PHP execution; the incomplete blocklist bypass does not.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Spatie Laravel Media Library < 11.23.0 allows file upload restriction bypass via double-extension filenames and incomplete blocklist, enabling potential PHP execution.

Vulnerability

The vulnerability resides in the FileAdder::defaultSanitizer() method of Spatie Laravel Media Library before version 11.23.0. The sanitizer only checks the final filename suffix, allowing double-extension filenames such as shell.php.jpg to bypass the blocklist. Additionally, the built-in blocklist omits executable extensions including .php6, .shtml, .htaccess, .pht, .phps, .phtm, .cgi, .pl, .asp, .aspx, .jsp, and .jspx [1][2][4]. The pathinfo() function preserves inner .php stems in saved filenames, enabling the bypass. The fix in version 11.23.0 replaces the trailing-extension check with a per-segment check and expands the blocked extensions list [3].

Exploitation

An attacker who can upload files through the application can send a file with a double extension like shell.php.jpg. The sanitizer, checking only the last extension (.jpg), permits the upload. On servers with a legacy Apache AddHandler configuration, the inner .php extension may be executed as PHP by the server, leading to code execution. The incomplete blocklist bypass (missing extensions like .php6, .shtml) does not require any special server configuration and can be exploited directly to upload files with those extensions [4]. The attacker needs upload access but no special privileges beyond that.

Impact

Successful exploitation allows an attacker to upload and potentially execute arbitrary PHP code on the server. For the double-extension bypass, the impact is limited to servers with specific Apache AddHandler directives. The incomplete blocklist bypass enables direct upload of files with executable extensions (e.g., .php6, .shtml) that could be served or processed by the web server, leading to remote code execution, data disclosure, or full server compromise [4]. The privilege level achieved is that of the web server user.

Mitigation

Upgrade to Spatie Laravel Media Library version 11.23.0 or later, released on 2026-05-29, which introduces per-segment filename validation and an expanded disallowed extensions list including php6, pht, phps, phtm, shtml, shtm, stm, htaccess, htpasswd, cgi, pl, asp, aspx, jsp, and jspx [1][2][3]. The release also adds configurable disallowed_extensions and allowed_extensions keys in config/media-library.php [3]. For servers using legacy Apache AddHandler, remove or disable that configuration as a partial workaround. No other workaround is available for the incomplete blocklist [4].

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
608ea03703d3

Harden filename validation against malicious extensions (#3939)

https://github.com/spatie/laravel-medialibraryFreek Van der HertenMay 28, 2026via nvd-ref
7 files changed · +203 9
  • config/media-library.php+23 0 modified
    @@ -15,6 +15,7 @@
     use Spatie\MediaLibrary\Conversions\ImageGenerators\Webp;
     use Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob;
     use Spatie\MediaLibrary\Downloaders\DefaultDownloader;
    +use Spatie\MediaLibrary\MediaCollections\FileAdder;
     use Spatie\MediaLibrary\MediaCollections\Models\Media;
     use Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver;
     use Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob;
    @@ -40,6 +41,28 @@
          */
         'max_file_size' => 1024 * 1024 * 10, // 10MB
     
    +    /*
    +     * Uploads whose file name contains any of these extensions will be rejected.
    +     * The check looks at every extension in the file name, so a file named
    +     * `malicious.php.jpg` is blocked as well. Matching is case-insensitive
    +     * and a leading dot is optional.
    +     *
    +     * The default list lives on the `FileAdder` class so the shipped config
    +     * and the in-code fallback (used when the config is cached without the
    +     * key) cannot drift. Override here to extend or shrink it.
    +     */
    +    'disallowed_extensions' => FileAdder::$defaultDisallowedExtensions,
    +
    +    /*
    +     * When this is set to an array of extensions, only uploads whose final
    +     * extension is in the list will be accepted. Matching is case-insensitive
    +     * and a leading dot is optional. The `disallowed_extensions` list above
    +     * is still enforced, so an interior dangerous segment (such as the `php`
    +     * in `shell.php.jpg`) is rejected even if the final extension is allowed.
    +     * Leave `null` to disable allowlisting.
    +     */
    +    'allowed_extensions' => null,
    +
         /*
          * This queue connection will be used to generate derived and responsive images.
          * Leave empty to use the default queue connection.
    
  • docs/api/adding-files.md+2 0 modified
    @@ -49,6 +49,8 @@ This method only accepts URLs that start with `http://` or `https://`
     public function addMediaFromUrl(string $url)
     ```
     
    +**Security note.** `addMediaFromUrl` fetches whatever URL you pass to it from your server. It validates only that the URL starts with `http://` or `https://`, not that the destination is safe to reach. Passing user supplied URLs directly therefore exposes your application to server side request forgery (SSRF), letting an attacker make requests to internal hosts, RFC 1918 ranges, loopback, or cloud metadata endpoints (such as `http://169.254.169.254/`). Only call `addMediaFromUrl` with URLs you control, or that you have validated against an allowlist of hosts.
    +
     ### addMediaFromDisk
     
     ```php
    
  • docs/basic-usage/retrieving-media.md+12 0 modified
    @@ -87,6 +87,18 @@ $yourModel
        ->toMediaCollection();
     ```
     
    +**Security note.** By default, Media Library rejects uploads whose file name contains a potentially executable extension such as `.php` or `.phtml`. The check looks at every extension segment in the name, so `malicious.php.jpg` is blocked too. Passing your own callable to `sanitizingFileName` fully replaces the default sanitizer (including this protection), so make sure your callable does not let dangerous file names through.
    +
    +The blocked extensions can be configured (and an opt-in allowlist enabled) in `config/media-library.php`:
    +
    +```php
    +// Reject these extensions anywhere in the file name.
    +'disallowed_extensions' => ['php', 'phtml', 'phar', 'htaccess', /* ... */],
    +
    +// When set, only accept uploads whose final extension is in this list.
    +'allowed_extensions' => ['jpg', 'jpeg', 'png', 'pdf'],
    +```
    +
     You can also retrieve the size of the file via  `size` and `human_readable_size` :
     
     ```php
    
  • docs/converting-other-file-types/using-image-generators.md+2 0 modified
    @@ -39,6 +39,8 @@ $this->addMediaConversion('thumb')
     
     The only requirement to perform a conversion of a SVG file is [Imagick](http://php.net/manual/en/imagick.setresolution.php).
     
    +**Security note.** SVG files are XML documents. When Imagick renders an SVG it can, depending on your ImageMagick build and delegate configuration, resolve external entities or remote references contained in the file. If you accept SVG uploads from untrusted users, harden your ImageMagick `policy.xml` (for example, by disabling the `SVG`, `URL`, and `HTTPS` coders), keep ImageMagick and its delegates up to date, and consider sanitizing or rejecting SVG uploads at the application layer. See the [ImageMagick security policy documentation](https://imagemagick.org/script/security-policy.php) for details.
    +
     ## Video
     
     The video image generator uses the [PHP-FFMpeg](https://github.com/PHP-FFMpeg/PHP-FFMpeg) package that you can install via Composer:
    
  • src/MediaCollections/Exceptions/FileNameNotAllowed.php+6 2 modified
    @@ -4,8 +4,12 @@
     
     class FileNameNotAllowed extends FileCannotBeAdded
     {
    -    public static function create(string $orignalName, string $sanitizedName): self
    +    public static function create(string $originalName, string $sanitizedName, ?string $extension = null): self
         {
    -        return new static("The file name `{$orignalName}` was sanitized to `{$sanitizedName}`. This sanitized file name is not allowed because it is a PHP file.");
    +        $reason = $extension !== null
    +            ? "The extension `{$extension}` is not allowed because it poses a security risk."
    +            : 'Its extension is not allowed because it poses a security risk.';
    +
    +        return new static("The file name `{$originalName}` was sanitized to `{$sanitizedName}`. {$reason}");
         }
     }
    
  • src/MediaCollections/FileAdder.php+80 7 modified
    @@ -5,7 +5,6 @@
     use Closure;
     use Illuminate\Database\Eloquent\Model;
     use Illuminate\Support\Facades\Storage;
    -use Illuminate\Support\Str;
     use Illuminate\Support\Traits\Macroable;
     use Spatie\MediaLibrary\Conversions\ImageGenerators\Image as ImageGenerator;
     use Spatie\MediaLibrary\HasMedia;
    @@ -467,21 +466,95 @@ protected function ensureDiskExists(string $diskName): void
             }
         }
     
    +    /**
    +     * Default list of executable extensions that are blocked anywhere in an
    +     * uploaded file name. Referenced by `config/media-library.php` so the
    +     * shipped config and the in-code fallback cannot drift.
    +     *
    +     * @var array<int, string>
    +     */
    +    public static array $defaultDisallowedExtensions = [
    +        'php', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8',
    +        'phtml', 'phtm', 'pht', 'phps', 'phar',
    +        'shtml', 'shtm', 'stm',
    +        'htaccess', 'htpasswd',
    +        'cgi', 'pl', 'asp', 'aspx', 'jsp', 'jspx',
    +    ];
    +
         public function defaultSanitizer(string $fileName): string
         {
             $sanitizedFileName = preg_replace('#\p{C}+#u', '', $fileName);
     
             $sanitizedFileName = str_replace(['#', '/', '\\', ' '], '-', $sanitizedFileName);
     
    -        $phpExtensions = [
    -            '.php', '.php3', '.php4', '.php5', '.php7', '.php8', '.phtml', '.phar',
    -        ];
    +        $this->guardAgainstDisallowedFileName($fileName, $sanitizedFileName);
    +
    +        return $sanitizedFileName;
    +    }
    +
    +    protected function guardAgainstDisallowedFileName(string $originalFileName, string $sanitizedFileName): void
    +    {
    +        $extensions = $this->extensionsFromFileName($sanitizedFileName);
    +
    +        $offending = array_intersect($extensions, $this->disallowedExtensions());
     
    -        if (Str::endsWith(strtolower($sanitizedFileName), $phpExtensions)) {
    -            throw FileNameNotAllowed::create($fileName, $sanitizedFileName);
    +        if ($offending !== []) {
    +            throw FileNameNotAllowed::create($originalFileName, $sanitizedFileName, reset($offending));
             }
     
    -        return $sanitizedFileName;
    +        $allowedExtensions = $this->allowedExtensions();
    +
    +        if ($allowedExtensions === []) {
    +            return;
    +        }
    +
    +        $finalExtension = strtolower(pathinfo($sanitizedFileName, PATHINFO_EXTENSION));
    +
    +        if (! in_array($finalExtension, $allowedExtensions, true)) {
    +            throw FileNameNotAllowed::create($originalFileName, $sanitizedFileName, $finalExtension ?: null);
    +        }
    +    }
    +
    +    /**
    +     * Returns every dot-separated segment after the first one, so the
    +     * disallowed-extension check can catch a dangerous extension that is
    +     * not the final one (for example, the `php` segment in `shell.php.jpg`).
    +     *
    +     * @return array<int, string>
    +     */
    +    protected function extensionsFromFileName(string $fileName): array
    +    {
    +        $parts = explode('.', strtolower($fileName));
    +
    +        array_shift($parts);
    +
    +        return $parts;
    +    }
    +
    +    /** @return array<int, string> */
    +    protected function disallowedExtensions(): array
    +    {
    +        $extensions = config('media-library.disallowed_extensions') ?? self::$defaultDisallowedExtensions;
    +
    +        return $this->normalizeExtensions($extensions);
    +    }
    +
    +    /** @return array<int, string> */
    +    protected function allowedExtensions(): array
    +    {
    +        return $this->normalizeExtensions(config('media-library.allowed_extensions') ?? []);
    +    }
    +
    +    /**
    +     * @param  array<int, string>  $extensions
    +     * @return array<int, string>
    +     */
    +    protected function normalizeExtensions(array $extensions): array
    +    {
    +        return array_map(
    +            fn (string $extension) => ltrim(strtolower($extension), '.'),
    +            $extensions,
    +        );
         }
     
         /**
    
  • tests/MediaCollections/FileAdderTest.php+78 0 modified
    @@ -36,3 +36,81 @@
     
         $adder->defaultSanitizer('media-libraryJQwPHp');
     })->throwsNoExceptions();
    +
    +it('blocks a disallowed extension anywhere in the file name', function (string $fileName) {
    +    $adder = app(FileAdder::class);
    +
    +    $adder->defaultSanitizer($fileName);
    +})->throws(FileNameNotAllowed::class)->with([
    +    'shell.php.jpg',
    +    'shell.PHP.jpg',
    +    'shell.php6',
    +    'shell.pht',
    +    'shell.phtml',
    +    'shell.shtml',
    +    'archive.phar',
    +    '.htaccess',
    +    'config.htaccess',
    +]);
    +
    +it('allows files with multiple or non-dangerous extensions', function (string $fileName) {
    +    $adder = app(FileAdder::class);
    +
    +    $adder->defaultSanitizer($fileName);
    +})->throwsNoExceptions()->with([
    +    'archive.tar.gz',
    +    'report.docx',
    +    'video.mp4',
    +    'image.jpeg',
    +    'backup.2026.05.zip',
    +    'document.pdf',
    +]);
    +
    +it('respects a custom disallowed extensions config', function () {
    +    config()->set('media-library.disallowed_extensions', ['exe']);
    +
    +    $adder = app(FileAdder::class);
    +
    +    $adder->defaultSanitizer('installer.exe');
    +})->throws(FileNameNotAllowed::class);
    +
    +it('rejects files outside the allowlist when one is configured', function () {
    +    config()->set('media-library.allowed_extensions', ['jpg', 'png', 'pdf']);
    +
    +    $adder = app(FileAdder::class);
    +
    +    $adder->defaultSanitizer('archive.zip');
    +})->throws(FileNameNotAllowed::class);
    +
    +it('accepts files inside the allowlist when one is configured', function () {
    +    config()->set('media-library.allowed_extensions', ['jpg', 'png', 'pdf']);
    +
    +    $adder = app(FileAdder::class);
    +
    +    expect($adder->defaultSanitizer('photo.jpg'))->toEqual('photo.jpg');
    +});
    +
    +it('still blocks dangerous interior extensions when an allowlist is configured', function () {
    +    config()->set('media-library.allowed_extensions', ['jpg']);
    +
    +    $adder = app(FileAdder::class);
    +
    +    $adder->defaultSanitizer('shell.php.jpg');
    +})->throws(FileNameNotAllowed::class);
    +
    +it('treats allowlist entries case-insensitively', function () {
    +    config()->set('media-library.allowed_extensions', ['JPG', '.PNG']);
    +
    +    $adder = app(FileAdder::class);
    +
    +    expect($adder->defaultSanitizer('photo.jpg'))->toEqual('photo.jpg');
    +    expect($adder->defaultSanitizer('photo.png'))->toEqual('photo.png');
    +});
    +
    +it('rejects files without an extension when an allowlist is configured', function () {
    +    config()->set('media-library.allowed_extensions', ['jpg']);
    +
    +    $adder = app(FileAdder::class);
    +
    +    $adder->defaultSanitizer('Makefile');
    +})->throws(FileNameNotAllowed::class);
    

Vulnerability mechanics

Root cause

"The sanitizer checked only the final filename suffix against a short blocklist, allowing double-extension files and unblocked executable extensions to bypass validation."

Attack vector

An authenticated attacker uploads a file with a double extension (e.g., `shell.php.jpg`) or a previously unblocked extension (e.g., `shell.php6`). The old sanitizer checks only the final suffix against a short blocklist, so the file is accepted. On a server with a legacy Apache `AddHandler` configuration that processes `.php` segments in any position, the inner `.php` stem can be executed as PHP code. The incomplete blocklist bypass (e.g., `.php6`) does not require a special Apache configuration.

Affected code

The vulnerability resides in `FileAdder::defaultSanitizer()` in `src/MediaCollections/FileAdder.php`. The old sanitizer used `Str::endsWith()` to check only the final filename suffix against a short blocklist of PHP extensions, so a file named `shell.php.jpg` passed validation and `pathinfo()` preserved the inner `.php` stem in the saved filename. The blocklist also omitted extensions such as `.php6`, `.shtml`, and `.htaccess`.

What the fix does

The patch replaces the single-suffix `Str::endsWith()` check with `extensionsFromFileName()`, which splits the filename on `.` and returns every segment after the first. `guardAgainstDisallowedFileName()` then intersects those segments against an expanded blocklist that now includes `.php6`, `.pht`, `.phps`, `.phtm`, `.shtml`/`.shtm`/`.stm`, `.htaccess`/`.htpasswd`, `.cgi`/`.pl`, and `.asp`/`.aspx`/`.jsp`/`.jspx`. This catches dangerous extensions anywhere in the filename, not just at the end. The patch also introduces configurable `disallowed_extensions` and `allowed_extensions` keys so applications can tailor the blocklist without overriding the sanitizer.

Preconditions

  • authThe attacker must be able to upload files to the application (authenticated user).
  • configFor the double-extension bypass to achieve code execution, the server must have a legacy Apache AddHandler configuration that interprets .php segments in any filename position.
  • configThe incomplete blocklist bypass (e.g., .php6) does not require any special server configuration.

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.