ARTICLE

Storage::path() Could Walk Out of the Disk: The Path Traversal Laravel 13.30 Closes

Laravelsecurityprivacy

Up to Laravel 13.29, Storage::get('../../../.env') threw an exception while Storage::path('../../../.env') happily returned the absolute path of your .env. Same facade, same disk, same argument, opposite answers. Laravel 13.30, released on September 1, brings the two calls in line. It is worth understanding how the gap existed, where the pattern actually bites, and why the right security fix here is a design habit rather than a flag.

Why two methods on the same facade disagreed

Almost everything you ask of Storage goes through Flysystem: get(), put(), delete(), readStream(). Flysystem normalizes the path before using it and, if the normalized result escapes the disk root, throws PathTraversalDetected. That is why, in years of Laravel applications, nobody worried much about a .. passed to Storage::get(): the framework blocked it for you.

Storage::path() was the exception. It does not read the file; it only returns the native path string, and to do so it went straight to PathPrefixer::prefixPath(), which concatenates the disk prefix with whatever you hand it. No normalization, no check. On the default local disk, path('../../../.env') resolved out of storage/app all the way to the project root. Scoped disks had the same hole: path('../file.txt') walked straight out of the configured prefix as if it were not there.

KIKOmanasijev's PR, merged on August 27 and shipped in 13.30.0, runs path() through the same WhitespacePathNormalizer that Flysystem builds for every other call, with the same defaults. The result is that path() now returns exactly the string the driver computes internally, so the two can no longer disagree about what a path means.

Where the pattern bites: attachment downloads

The code that makes this asymmetry dangerous is trivial, which is exactly why the story interests me. Every line-of-business app has a spot where the user downloads a document: an invoice, an attachment, a report export. The lazy version of that route looks like this:

Route::get('/download', function (Request $request) {
    return response()->download(
        Storage::path($request->query('path'))
    );
});

With ?path=../../../.env, until two weeks ago, that route served your database credentials to anyone with an account. And Storage::path() is precisely the method you reach for when you don't want Flysystem to read the file: when you hand it to response()->download(), to an external process like ImageMagick or LibreOffice for a conversion, to a PDF library that wants a native path. In other words, exactly the cases where the file travels from the disk to the outside world, often after a detour through a queue where nobody checks anymore who asked for what.

After eleven years of building Laravel business applications I have seen enough download routes to say that the "path comes from the query string" variant is not rare. It is almost always born in good faith: the front end already has the path because it received it in a JSON response, and passing it back looks like the simplest thing. It is also the wrong thing, and 13.30 does not change that verdict.

The framework's fix is not your fix

Upgrade to 13.30: it is free and it closes the path() hole. But normalization stops the .., not access to a file inside the disk that does not belong to the person asking. With ?path=invoices/another-customer/2026-03.pdf the path is perfectly legitimate for Flysystem and perfectly illegitimate for you. No normalizer can know that.

The rule I have applied for years is that the path on disk is never an input. It is a column in a table, and the user passes you the row's identifier:

Route::get('/attachments/{attachment}', function (Attachment $attachment) {
    Gate::authorize('view', $attachment);

    return Storage::disk('attachments')->download($attachment->path, $attachment->original_name);
});

Three things happen in three lines. Route model binding turns the input into an existing row or a 404. The policy answers the question the filesystem cannot ask: is this person allowed to see this file? And download() goes through Flysystem, so even if someone one day wrote a row with a strange path, the traversal would still be rejected. The 13.30 fix becomes a third layer of defense, not the only one.

If you truly need path() — for an external process, say — the same discipline applies: the path comes from the model, never from the request, and it is still worth checking that realpath() of the result starts with realpath() of the disk root. It is one extra line that, on Laravel < 13.30, is the only thing between you and your .env.

Fewer places a byte can live, fewer paths to defend

This bug reminded me of a choice I made in Miraviso, my SaaS for hair salons. The haircut preview is generated on a server in the EU, with consent, and is never written to disk: it is born in memory, returned to the salon's tablet, and gone. That decision was not made with path traversal in mind; it came from privacy — a client's face should not outlive the request that generated it. But it has a side effect I appreciate more today: a file that does not exist has no path, and a path that does not exist can be neither traversed nor guessed. The same goes for the sensitive notes, which the server stores as sealed envelopes it cannot open: even an improper download would return something unreadable.

That model does not apply to everything. A business app has to keep invoices, and invoices have to be downloadable. But the question to ask is the one I asked for Miraviso: does this file need to exist on disk, and for how long? Every file you decide not to write is a download route you do not have to protect.

What to do today

Upgrade to 13.30 and grep your code for Storage::path( and ->path(: for every hit, trace the argument back to its origin. If at any point you reach $request, that is the route to rewrite with a binding and a policy, whatever framework version you run. It is a less entertaining audit than a minesweeper that never makes you guess, but the rule there is the same: a cell only opens once you can prove it is safe.

← All articles