Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix path traversal 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

Resolve both paths and compare whole components: $base = realpath($root), $target = realpath($base.'/'.$name), and serve response()->file($target) only if $target starts with $base.DIRECTORY_SEPARATOR. realpath() collapses ../ and follows symlinks, and returns false for missing files. Storage's path checks stop ../ but not symlinks; never strip "../" yourself.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (Storage)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 51 KB
On this page

Steps

  1. Find every file read, download or response whose path includes request data: response()->file(), response()->download(), Storage::disk()->get(), download(), response(), path(), file_get_contents(), fopen() and readfile(), and uploaded file names.
  2. Resolve the base directory and the requested path with realpath(); it returns the canonical absolute path, with ../ collapsed and symlinks followed, or false when the file does not exist.
  3. Serve the file only if the resolved target starts with the resolved base plus a directory separator, and answer the same 404 for refused and missing paths so the response does not reveal what exists outside the folder.
  4. Reject an empty path and a NUL byte with 400 before touching the file system: realpath() and file_get_contents() throw ValueError on a NUL byte, while is_file() just returns false.
  5. Serve with response()->file($target, ['Content-Type' => ...]) or response()->download($target, $name) and an explicit content type.
  6. Add regression tests with this card's payloads: ../, %2e%2e%2f, an absolute path, ..\, the files-secret sibling, a symlink created in the test's temporary folder, and ....//.
  7. When users only pick from known files, store them under generated names on a disk and map an ID to the stored name instead of accepting a path at all.

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/Files/VulnerableFileServer.phpPHP
public function serve(string $requested): ?Response
{
    $path = config('filesystems.disks.files.root').'/'.$requested;

    return is_file($path) ? response()->file($path, ['Content-Type' => 'application/octet-stream']) : null;
}

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

The fix

Fixedapp/Files/FixedFileServer.phpPHP
public function serve(string $requested): ?Response
{
    // realpath() returns an absolute path with ../ collapsed and every symlink followed, or
    // false when nothing is there. Resolve the base the same way: on macOS, for example, /var
    // is a link to /private/var.
    $base = realpath(config('filesystems.disks.files.root'));
    $target = realpath($base.DIRECTORY_SEPARATOR.$requested);
    // Compare whole path components: /srv/files-secret starts with /srv/files but is not inside it.
    if ($base === false || $target === false || ! str_starts_with($target, $base.DIRECTORY_SEPARATOR) || ! is_file($target)) {
        return null;
    }

    return response()->file($target, ['Content-Type' => 'application/octet-stream']);
}

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixapp/Files/WrongFileServer.phpPHP
public function serve(string $requested): ?Response
{
    $disk = Storage::disk('files');
    try {
        return $disk->fileExists($requested) ? $disk->response($requested) : null;
    } catch (PathTraversalDetected) { // ../ above the disk's root
        return null;
    }
}

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

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/FilesTest.phpPHP
// Exploit: each payload aims at a real file outside files/, so a leak would come back as a 200.

public function test_exploit_dot_dot_slash_cannot_climb_out_of_the_base_directory(): void
{
    $this->assertNotServed($this->fetch('../secret.txt'));
}

public function test_exploit_percent_encoded_dot_dot_slash_is_decoded_by_laravel_and_still_refused(): void
{
    $this->assertNotServed($this->getJson('/files?path=%2e%2e%2fsecret.txt'));
}

public function test_exploit_absolute_path_cannot_replace_the_base_directory(): void
{
    $this->assertNotServed($this->fetch("$this->tree/secret.txt"));
}

public function test_exploit_backslashes_cannot_climb_out_a_separator_on_windows(): void
{
    $this->assertNotServed($this->fetch('..\\secret.txt'));
}

public function test_exploit_sibling_folder_that_shares_the_prefix_is_outside(): void
{
    // files-secret starts with "files" but is not inside files/.
    $this->assertNotServed($this->fetch('../files-secret/api-keys.txt'));
}

public function test_exploit_symlink_inside_the_base_cannot_lead_outside_it(): void
{
    symlink("$this->tree/files-secret", $this->baseDir().'/shared');
    $this->assertNotServed($this->fetch('shared/api-keys.txt'));
}

public function test_exploit_four_dots_do_not_turn_back_into_dot_dot_slash(): void
{
    // A single-pass filter that deletes "../" turns "....//" into "../".
    $this->assertNotServed($this->fetch('....//secret.txt'));
}

Pitfalls

  • Laravel's Storage facade runs every path through Flysystem's path normalizer: it throws League\Flysystem\PathTraversalDetected for ../ that climbs above the disk's root, turns backslashes into slashes, and reads /etc/passwd as etc/passwd under the root. It never resolves symlinks, so a link inside the root that points outside it is served. That is this card's wrong implementation; this card checked it on Laravel 13.34.0 with Flysystem 3.36.0.
  • PathTraversalDetected is not caught by Laravel: uncaught, it becomes a 500 and a log entry, which tells a prober the path was refused. Catch it and answer 404.
  • The local disk's links option (skip or the default, which refuses) applies when Flysystem lists a directory; reading a file through a symlinked folder still follows the link.
  • str_starts_with($target, $base) without the trailing separator accepts /srv/files-secret when the base is /srv/files.
  • Laravel decodes the query string before your controller sees it: ?path=%2e%2e%2fsecret.txt arrives as ../secret.txt.
  • On macOS, temporary folders live under /var, a symlink to /private/var. Resolve the base as well as the target, or every legitimate file will be refused.
  • Checking and then opening leaves a window in which someone who can write inside the base can swap in a symlink. Keep the served tree read-only for untrusted users.

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 path-traversal/php-laravel does this for you in the pinned composer image, so it needs Docker).
  2. Against your own endpoint, request ../, %2e%2e%2f, an absolute path and a symlink inside the served folder that points outside it; each must get the same 404 as a missing file.
  3. grep for response()->file(, ->download(, Storage:: and file_get_contents( with request data; Semgrep's tainted-filename rule only follows $_GET-style sources, and Psalm flags every file sink, the fixed one included.

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 path traversal in PHP and Laravel
ToolRuleWhat it found here
Semgrepphp.lang.security.injection.tainted-filename.tainted-filenameNo findings. Its sources are $_GET, $_POST, $_COOKIE, $_REQUEST and $_SERVER, so a path read with $request->query() is not tracked, and response()->file() and Storage are not among its sinks. A probe that passes $_GET['path'] to readfile() was flagged; it also lists realpath() as a sanitizer.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/<id> app routes
Semgrepr/php (every PHP rule in the Semgrep registry, 47 rules)No findings on any implementation.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes config bootstrap
PsalmTaintedFilePsalm taint analysis with the Laravel pluginOne TaintedFile issue in each implementation, at the call that serves the file: response()->file($path) in VulnerableFileServer.php, $disk->response($requested) in WrongFileServer.php and response()->file($target) in FixedFileServer.php. The last one is a false positive: Psalm does not treat the realpath() containment check as a sanitizer, so expect to suppress it with a comment that names the check.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/

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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (Storage)
laravel/framework13.34.0
league/flysystem3.36.0
league/flysystem-local3.35.3
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 path-traversal/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 path-traversal/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, 51 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)
  • 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: 4 of 12 tests fail (4 exploit tests); wrong: 1 of 12 tests fail (1 exploit test)
Test results
fixed 12/12 pass; vulnerable 4/12 fail (4 exploit tests); wrong 1/12 fail (1 exploit test), each test run in a container with --network=none after composer install
Dependency freshness
Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. 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: Path Traversal
  2. Laravel 13.x: File responses
  3. Laravel 13.x: File downloads
  4. Laravel 13.x: Filesystem, the local driver
  5. PHP manual: realpath()
  6. Flysystem: WhitespacePathNormalizer (source)
  7. PortSwigger Web Security Academy: Path traversal
  8. CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

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.