I would not ship a file download until Laravel 13.30 — Storage::path() finally matches get()

I would not ship a file download until Laravel 13.30 — Storage::path() finally matches get()

On 13.29, Storage::get('../../../.env') already threw. Storage::path() of the same string still concatenated past the disk root. 13.30 runs path() through Flysystem's WhitespacePathNormalizer. I would bump composer show laravel/framework before I trust a query-string download again.

· 9 min read #laravel #php #filesystem #storage #security

 Storage::path() stays inside the Laravel disk root after 13.30

Caption: The local disk is storage/app/private (or whatever root you set). .env lives outside that root. get / delete / readStream already refused a walk out. path() was the leftover.

Introduction

I keep a throwaway Laravel 13 app on a VPS for filesystem work. Not a client box. A place I can composer update laravel/framework and see what actually throws.

This week the thing I cared about was not chunkBy() and not php artisan dev. It was the method I reach for when I hand a file to something that is not Flysystem: response()->download(), Imagick, a CLI that wants a native path.

Storage::path() on 13.29 still concatenated. Storage::get() of the same argument already went through Flysystem and refused to leave the disk. I have shipped downloads that look like Storage::path($request->query('path')). On a local disk that string becomes an absolute path. PHP then serves whatever that path points at.

Laravel 13.30.0 shipped 1 Sep 2026 (Packagist 2026-09-01T13:19:00+00:00). 13.30.1 followed the same day. As of 8 Sep 2026, Packagist’s latest 13.x is v13.31.0. The hardening I want is in 13.30.0. I still run composer show laravel/framework before I claim the throw.

This is a lab note from the framework’s own tests and from Laravel News (2 Sep 2026). I did not invent a CVE number. There was not one published for this change at write time.

About the feature

Storage::path($relative) is the method that turns a disk-relative key into something the rest of PHP understands. On the local driver, that is an absolute filesystem path. On S3, the docs still say you get the relative key in the bucket — not a local file.

Until 13.30, Illuminate\Filesystem\FilesystemAdapter::path() handed the string to Flysystem’s PathPrefixer::prefixPath(). Prefixer concatenates. It does not resolve ... It does not ask whether you walked out of root.

Every other call — get(), delete(), readStream(), put() — goes through League\Flysystem\Filesystem, which builds a WhitespacePathNormalizer and throws when the normalized path escapes the disk.

13.30 makes path() do the same first step. The method in v13.30.1 is one line:

public function path($path)
{
    return $this->prefixer->prefixPath(
        (new WhitespacePathNormalizer)->normalizePath($path)
    );
}

normalizePath() turns backslashes into slashes, drops . and empty segments, and pops a segment on ... If .. would walk above the start of the path, Flysystem throws:

League\Flysystem\PathTraversalDetected
Path traversal detected: ../../../.env

That exception class is League\Flysystem\PathTraversalDetected, not an Illuminate wrapper. ReceiveFile in the same namespace already uses the League class and abort(404)s on it. I would catch the same one.

Internal relatives that stay inside the disk still work. The framework test writes foo/bar.txt and then asks for foo/baz/../bar.txt. Both get() and path() resolve to the same file. That is not a traversal. Walking to ../../../.env is.

 Why Storage::get() and Storage::path() disagreed before 13.30

Caption: Before 13.30, get() normalized and path() only prefixed. After PR #61343, path() runs WhitespacePathNormalizer first. The exception is League\Flysystem\PathTraversalDetected.

The 13.x filesystem docs still only show this:

use Illuminate\Support\Facades\Storage;

$path = Storage::path('file.jpg');

There is no new docs chapter. The behavior change is the PR and the tests, not a heading on laravel.com.

Why I picked it

Sunday’s queue pointed at Laravel News from 2 Sep 2026: Storage::path() now goes through the same normalizer as every other filesystem call. The week field on that item is 2026-W36. I am writing on 9 Sep 2026 (W37). No newer Wednesday pick replaced it, so this is the lowest-priority undone feature.

I picked it because I still write downloads against a local disk. Private invoices, exports, a PDF I just rendered. Storage::download() exists. I still use path() when the next consumer is not Laravel’s download helper.

On 13.29, composer show laravel/framework printing 13.29.0 means path() can still walk out. I would not argue with a changelog. I would run the two calls side by side in tinker on that app.

Where it can be used

This is not “any Laravel app.” It is the places I turn a user-controlled string into a native path:

  • response()->download(Storage::path(...)) and response()->file(...)
  • streaming a local file into Imagick, FFmpeg, or a CLI that wants --input /absolute/path
  • a signed download action that still takes a path query or route parameter
  • a scoped disk (prefix in config/filesystems.php) — the same tests reject path('../file.txt') walking out of that prefix
  • a job that builds a zip from keys the request sent

S3 is the wrong panic. On S3, path() is a relative key. The dangerous shape is the local disk plus response()->download(), because PHP then opens whatever absolute path you gave it.

I would also grep storage_path('app/'.$request-> in the same pass. That helper never went through Flysystem. 13.30 does not save a concatenation I wrote myself.

Benefit

get(), delete(), readStream(), and path() now agree about what a path means. I can hand path() to a download and know a .. that escapes the disk throws instead of returning a string that points at .env.

That is defense in depth, not a replacement for authorizing the file. A user can still ask for invoices/someone-elses.pdf if I never check ownership. The throw only stops leaving the disk root.

The package map is short. I do not composer require Flysystem in a normal app. laravel/framework v13.30.1 already requires league/flysystem: ^3.25.1 and league/flysystem-local: ^3.25.1. Those are the packages under this call. I am not inventing Intervention or Scout here.

 Package map under Storage::path() in Laravel 13.30

Caption: FilesystemAdapter::path() lives in laravel/framework. It constructs League\Flysystem\WhitespacePathNormalizer, may throw League\Flysystem\PathTraversalDetected, then prefixes with PathPrefixer. Confirmed in the v13.30.1 composer.json require list.

Practical example from a recent application

Lab app, not a named client. Default local disk rooted at storage/app/private (Laravel 13’s private disk). Experience notes I kept for this item: confirm composer show laravel/framework is 13.30.0+ before claiming the throw; on 13.29 path() still concatenated; the download from ?path= is the lab; docs still only show Storage::path('file.jpg'); code that relied on path() accepting .. will start throwing League\Flysystem\PathTraversalDetected; do not invent a CVE.

composer show laravel/framework | sed -n '1,8p'
# name     : laravel/framework
# versions : * v13.31.0   # 8 Sep 2026 on Packagist. Hardening landed in 13.30.0.

If that first line still says 13.29.x, stop. The rest of this example will not throw.

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\PathTraversalDetected;
use Symfony\Component\HttpFoundation\BinaryFileResponse;

final class InvoiceDownloadController
{
    public function __invoke(Request $request): BinaryFileResponse
    {
        $key = $request->string('path')->toString();

        try {
            $absolute = Storage::disk('local')->path($key);
        } catch (PathTraversalDetected $e) {
            report($e);
            abort(404);
        }

        // Ownership still belongs in the app. The throw only keeps us on the disk.
        abort_unless(Storage::disk('local')->exists($key), 404);

        return response()->download($absolute);
    }
}

Failure I would hit on 13.29. Same controller. Query path=../../../.env. get() already refused. path() returned a native path that pointed at the application .env. response()->download() then did what PHP does with an absolute file: it sent it.

use Illuminate\Support\Facades\Storage;

// Documented disagreement (Laravel News, 2 Sep 2026; framework tests).
Storage::get('../../../.env');   // already PathTraversalDetected on 13.29
Storage::path('../../../.env');  // 13.29: absolute path past the disk root
                                 // 13.30+: League\Flysystem\PathTraversalDetected

The framework tests also cover ..\..\..\.env, /../../../.env, .., and foo/../../../.env. Windows separators do not save anyone — the normalizer rewrites \ to / first.

Fix. Bump laravel/framework to 13.30.0 or newer. Catch League\Flysystem\PathTraversalDetected and abort(404) the way ReceiveFile already does. Prefer a stored key I issued (invoices/2026-09-09-abc.pdf) over a raw query path. If I must accept a name, basename() plus a disk I control is still the app check. The framework throw is the backstop I was missing.

If I had code that wanted path() to accept a walk above the disk, that code is now wrong. I would stop doing that. Put the file on the disk, or open it with a path I constructed from config — not from Storage::path('../../../something').

 Query-string download lab: 13.29 concatenates, 13.30 throws

Caption: response()->download(Storage::path($request->query('path'))) is the shape PR #61343 called out. On 13.30+ the normalizer throws and I abort 404. Official docs still only demonstrate Storage::path('file.jpg').

Conclusion

What I would keep: laravel/framework at 13.30.0+ (today that update lands me on 13.31.0), path() only for keys I already trust or that I wrap in PathTraversalDetected, and a grep for storage_path('app/'.$request that the framework will never fix for me.

What is running in the lab now is a 13.31 app where Storage::path('../../../.env') throws the League exception and a valid invoices/demo.pdf still resolves inside storage/app/private. Next I would grep the same app for response()->file( and for any path() call that still interpolates a request value without the catch.

Did you hit the same wall?

I got stuck on Storage::path() still concatenating on 13.29 while get() already refused ../../../.env. Did you hit the same thing — a download that started throwing PathTraversalDetected after composer update, a scoped disk that now rejects ../file.txt, or a storage_path() concatenation the framework never sees? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.