Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix unrestricted file upload in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Validate with max:512, mimes:png,jpg,jpeg (the type guessed from the contents) and extensions:png,jpg,jpeg (the client's name), require getClientMimeType() to equal getMimeType(), store() on a private disk so hashName() picks the name, and serve with Storage::download() plus X-Content-Type-Options: nosniff. Never move() uploads into public/ under the client's name or extension.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (validation rules mimes, extensions and max; UploadedFile::store; Storage)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 55 KB
On this page

Steps

  1. Find every action that reads $request->file() and where the file goes: move() or storeAs() with getClientOriginalName() or getClientOriginalExtension(), the public disk or public/, or a check on getClientMimeType() alone.
  2. Validate the file: required, file, max:<kilobytes>, mimes:<extensions> (checks the type guessed from the contents with ext-fileinfo) and extensions:<extensions> (checks the client's extension, Laravel 11+). Map the failures to 413 for max and 415 for the type rules if your API needs those statuses; Laravel answers 422 by default.
  3. Require the declared type to agree: $file->getClientMimeType() === $file->getMimeType().
  4. Store with $file->store('', 'private-disk'): hashName() generates 40 random characters plus the extension guessed from the contents, on a disk whose root is outside public/.
  5. Serve through a route that matches only generated names and returns Storage::disk(...)->download() with a fixed Content-Type and X-Content-Type-Options: nosniff, or serve user files from a separate origin.
  6. Add regression tests with this card's payloads: an HTML page and an SVG with script, HTML named avatar.png, avatar.png.html, a PNG that is also a page, a file one byte over the limit, and two users uploading the same name.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/Uploads/VulnerableAvatarUploader.phpPHP
public function store(Request $request): JsonResponse
{
    $file = $request->file('avatar');
    if ($file === null) {
        return response()->json(['error' => 'attach the image in the "avatar" field'], 400);
    }
    // The client's name, in the folder the web server serves. move() replaces an existing file.
    $name = $file->getClientOriginalName();
    $file->move(config('fixcard.web_root').'/uploads', $name);

    return response()->json(['url' => '/uploads/'.rawurlencode($name)], 201);
}

With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).

The fix: validate and store the upload

Fixedapp/Uploads/FixedAvatarUploader.phpPHP
public function store(Request $request): JsonResponse
{
    $validator = Validator::make($request->all(), [
        'avatar' => ['required', 'file', 'max:512', 'mimes:png,jpg,jpeg', 'extensions:png,jpg,jpeg'],
    ]);
    if ($validator->fails()) {
        return Refusal::from($validator);
    }
    $file = $request->file('avatar');
    // The type the client declared must be the type the contents have.
    if ($file->getClientMimeType() !== $file->getMimeType()) {
        return response()->json(['error' => 'avatars must be PNG or JPEG images'], 415);
    }
    // A name the server makes up, on a disk outside the web root.
    $name = $file->store('', 'avatars');

    return response()->json(['url' => '/avatars/'.$name], 201);
}

With IMPL=fixed, 12 of 12 tests pass.

The fix: serve it as a download

Fixedapp/Http/Controllers/AvatarController.phpPHP
// The fixed implementation's serving route. The route only matches names hashName() generates;
// the file comes from the private disk as a download, with a fixed type and nosniff.
public function serve(string $name): Response
{
    abort_unless(Storage::disk('avatars')->exists($name), 404);

    return Storage::disk('avatars')->download($name, $name, [
        'Content-Type' => str_ends_with($name, '.png') ? 'image/png' : 'image/jpeg',
        'X-Content-Type-Options' => 'nosniff',
    ]);
}

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixapp/Uploads/WrongAvatarUploader.phpPHP
public function store(Request $request): JsonResponse
{
    $validator = Validator::make($request->all(), [
        'avatar' => ['required', 'file', 'max:512', 'mimetypes:image/png,image/jpeg'],
    ]);
    if ($validator->fails()) {
        return Refusal::from($validator);
    }
    $file = $request->file('avatar');
    // A random name, but with the client's extension, which decides how the web server serves it.
    $name = Str::random(40).'.'.strtolower($file->getClientOriginalExtension());
    $file->move(config('fixcard.web_root').'/uploads', $name);

    return response()->json(['url' => '/uploads/'.$name], 201);
}

With IMPL=wrong, 2 of 12 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Feature/AvatarsTest.phpPHP
// Exploit: what an attacker sends instead of a picture.

public function test_exploit_html_page_with_a_script_is_refused(): void
{
    [$status, $url] = $this->upload('profile.html', 'text/html', $this->bytes('html'));
    $this->assertRefused($status, $url, 415);
}

public function test_exploit_svg_with_a_script_is_refused(): void
{
    [$status, $url] = $this->upload('avatar.svg', 'image/svg+xml', $this->bytes('svg'));
    $this->assertRefused($status, $url, 415);
}

public function test_exploit_html_named_png_and_declared_image_png_is_refused(): void
{
    [$status, $url] = $this->upload('avatar.png', 'image/png', $this->bytes('html'));
    $this->assertRefused($status, $url, 415);
}

public function test_exploit_double_extension_is_refused_even_when_the_bytes_start_like_a_png(): void
{
    [$status, $url] = $this->upload('avatar.png.html', 'image/png', $this->bytes('polyglot'));
    $this->assertRefused($status, $url, 415);
}

public function test_exploit_png_that_is_also_an_html_page_is_served_only_as_an_image_download(): void
{
    [$status, $url] = $this->upload('avatar.png', 'image/png', $this->bytes('polyglot'));
    $this->assertSame(201, $status);
    $served = $this->get($url);
    $this->assertSame('image/png', $this->mediaType($served));
    $this->assertSame('nosniff', $served->headers->get('X-Content-Type-Options'));
    $this->assertStringStartsWith('attachment', (string) $served->headers->get('Content-Disposition'));
}

public function test_exploit_dot_dot_slash_in_the_file_name_cannot_put_the_file_outside_the_upload_folder(): void
{
    $this->upload('../escaped.png', 'image/png', $this->bytes('png'));
    $outside = array_filter($this->filesAdded(), fn (string $name) => ! str_starts_with($name, 'public/uploads/') && ! str_starts_with($name, 'storage/'));
    $this->assertSame([], array_values($outside));
}

public function test_exploit_file_one_byte_over_the_limit_is_refused_with_413(): void
{
    [$status, $url] = $this->upload('avatar.png', 'image/png', $this->pad($this->bytes('png'), $this->fixture['limitBytes'] + 1));
    $this->assertRefused($status, $url, 413);
}

public function test_exploit_upload_cannot_replace_another_users_file_by_reusing_its_name(): void
{
    [$alice, $aliceUrl] = $this->upload('avatar.png', 'image/png', $this->bytes('png'));
    [$mallory] = $this->upload('avatar.png', 'image/png', $this->bytes('otherPng'));
    $this->assertSame(201, $alice);
    $this->assertSame(201, $mallory);
    $this->assertSame($this->bytes('png'), $this->body($this->get($aliceUrl)), "Alice's URL must still serve her own image");
}

Pitfalls

  • getClientMimeType() and getClientOriginalExtension() are the client's words. mimes and mimetypes read the contents instead, which is why the wrong implementation refuses an HTML page named avatar.png, but it then stores the file under the client's extension in public/, so a valid PNG named avatar.png.html is served as a page.
  • mimes alone accepts the polyglot named avatar.png.html, because its contents start like a PNG; pair it with extensions, which checks the name, and generate the stored name.
  • Files in public/ are served by the web server, not Laravel: it picks the Content-Type from the extension, serves inline and sends no X-Content-Type-Options. On a server that runs .php by extension, an upload there is a web shell; Laravel's mimes, mimetypes and extensions rules refuse a .php client extension unless you list php, but that is not a reason to store uploads in public/.
  • move() replaces an existing file, so the client's name lets one user overwrite another's upload. store() and hashName() pick a fresh random name.
  • max is in kilobytes for files (the size is getSize() / 1024). PHP's upload_max_filesize (2M) and post_max_size (8M) defaults are server-wide limits, not the feature's; keep the rule.
  • A signature check proves how a file starts, not that it is harmless: the polyglot test is a valid PNG that is also a working page. The serving headers keep it from rendering; re-encode images with a maintained library (Intervention Image or Imagick, under resource limits) to strip trailing data.
  • Laravel skips CSRF checks while APP_ENV is testing, as in this card. A real upload form behind session authentication needs the CSRF token, and a private file needs an authorization check on the serving route.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/php-laravel does this for you, in Docker if you do not have PHP).
  2. Against your own endpoint, upload an HTML file named avatar.png with Content-Type image/png, an .svg with a <script> and a PNG named avatar.png.html; all three must be refused with nothing stored. Open the URL of an accepted avatar and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
  3. No scanner here notices a missing type check or the serving headers; the regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix unrestricted file upload in PHP and Laravel
ToolRuleWhat it found here
Semgrepr/php (every PHP rule in the Semgrep registry)No findings on any implementation (47 rules on 11 files). No PHP rule in the registry models $request->file(), the declared Content-Type, a file signature or a missing size cap, so the vulnerable, wrong and fixed uploaders look the same.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --metrics=off --config r/php app routes config
Psalmtaint analysis (not executed this round)Psalm taint analysis with the Laravel pluginPsalm would not report the upload: an UploadedFile reaching move() or store() is not a TaintedFile sink it models, and it has no rule for the declared type, the extension or the serving headers.Checked: Not executed in this round; the other Laravel cards run Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6 under --taint-analysis

No rule checked here notices a missing type or size check or the serving headers. CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript. The regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (validation rules mimes, extensions and max; UploadedFile::store; Storage)
laravel/framework13.34.0
symfony/http-foundation and symfony/mime (UploadedFile, MIME guessing via ext-fileinfo)as locked by laravel/framework 13.34.0
phpunit/phpunit13.4.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/php-laravelfolder.

Shell
composer install --no-interaction --no-progress
Shell
# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xml

Without PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'composer install --no-interaction --no-progress'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=fixed \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=vulnerable \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=wrong \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs file-upload/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 55 KB

Verified

Machine
Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
Toolchain
  • php 8.5.11 (CLI, with ext-fileinfo)
  • laravel/framework 13.34.0
  • phpunit/phpunit 13.4.0
  • composer 2.10.3
  • semgrep 1.180.0
  • psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
Matrix
fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 2 of 12 tests fail (2 exploit tests)
Test results
fixed 12/12 pass; vulnerable 7/12 fail (7 exploit tests: the HTML page, the SVG, HTML named avatar.png, avatar.png.html, the polyglot's serving headers, the over-limit file and the reused name overwriting another user's file); wrong 2/12 fail (2 exploit tests: avatar.png.html stored as <random>.html and served as text/html, and the polyglot's serving headers; mimetypes reads the contents, so the HTML named avatar.png is refused). The ../ test passes in every mode: Symfony's UploadedFile keeps only the last path segment of the client's name (File::getName), as PHP itself does for real uploads. Each test run in a container with --network=none after composer install.
Dependency freshness
Same lock as the other Laravel cards: laravel/framework 13.34.0 (2026-09-29) with laravel/prompts held at 0.3.24 and phpunit/phpunit 13.4.0 (2026-10-02), because the 2026-10-06 releases were under seven days old and are not security releases. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP File Upload Cheat Sheet
  2. OWASP ASVS 5.0.0, V5 File Handling
  3. CWE-434: Unrestricted Upload of File with Dangerous Type
  4. Laravel 13: Validation, the mimes, mimetypes, extensions and max rules
  5. Laravel 13: File storage, storing uploaded files and hashName()
  6. Laravel 13: File downloads (Storage::download)
  7. Laravel 13.34.0 source: ValidatesAttributes (validateMimes, validateExtensions, shouldBlockPhpUpload)
  8. PHP manual: Handling file uploads (upload_max_filesize, post_max_size)
  9. MDN: X-Content-Type-Options

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.