Laravel Framework: Temporary Signed URL Path Confusion
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
A vulnerability in Laravel's local filesystem driver allows temporary signed URLs to be parsed ambiguously, potentially misrouting requests and bypassing expiration enforcement. Under certain conditions, a generated temporary signed URL can be interpreted differently by the server than intended at signing time. This may cause requests to resolve to an unintended resource, and can prevent expiration from being enforced, allowing expired URLs to remain valid indefinitely. ### Impact - Expired temporary URLs may continue to be accepted - Requests may resolve to a different resource than the one that was signed - The upload variant may allow writes to reach an unintended destination
The fix
urlencode paths
src/Illuminate/Filesystem/LocalFilesystemAdapter.php+2 −2
@@ -82,7 +82,7 @@ public function temporaryUrl($path, $expiration, array $options = [])return $url->to($url->temporarySignedRoute('storage.'.$this->disk,$expiration,-['path' => $path],+['path' => rawurlencode($path)],absolute: false));}@@ -115,7 +115,7 @@ public function temporaryUploadUrl($path, $expiration, array $options = [])'url' => $url->to($url->temporarySignedRoute('storage.'.$this->disk.'.upload',$expiration,-['path' => $path, 'upload' => true],+['path' => rawurlencode($path), 'upload' => true],absolute: false)),'headers' => [],
tests/Integration/Filesystem/ReceiveFileTest.php+26 −1
@@ -13,7 +13,10 @@ class ReceiveFileTest extends TestCaseprotected function setUp(): void{$this->beforeApplicationDestroyed(function () {-Storage::delete('receive-file-test.txt');+Storage::delete([+'receive-file-test.txt',+'receive-file-test.txt?pad=x',+]);});parent::setUp();@@ -73,4 +76,26 @@ public function testUploadUrlCannotBeUsedForDownload()$response->assertForbidden();}++public function testItCanReceiveAFileWithUriDelimitersInThePath()+{+$result = Storage::temporaryUploadUrl('receive-file-test.txt?pad=x', Carbon::now()->addMinute());++$response = $this->call('PUT', $result['url'], [], [], [], [], 'Hello Question');++$response->assertNoContent();+Storage::assertExists('receive-file-test.txt?pad=x', 'Hello Question');+Storage::assertMissing('receive-file-test.txt');+}++public function testUriDelimitersInThePathCannotHideAnExpiredUploadUrl()+{+$result = Storage::temporaryUploadUrl('receive-file-test.txt?pad=x', Carbon::now()->subMinute());++$response = $this->call('PUT', $result['url'], [], [], [], [], 'Hello Question');++$response->assertForbidden();+Storage::assertMissing('receive-file-test.txt');+Storage::assertMissing('receive-file-test.txt?pad=x');+}}
tests/Integration/Filesystem/ServeFileTest.php+23 −1
@@ -14,10 +14,14 @@ protected function setUp(): void{$this->afterApplicationCreated(function () {Storage::put('serve-file-test.txt', 'Hello World');+Storage::put('serve-file-test.txt?pad=x', 'Hello Question');});$this->beforeApplicationDestroyed(function () {-Storage::delete('serve-file-test.txt');+Storage::delete([+'serve-file-test.txt',+'serve-file-test.txt?pad=x',+]);});parent::setUp();@@ -51,4 +55,22 @@ public function testItWill403OnWrongSignature()$response->assertForbidden();}++public function testItCanServeAFileWithUriDelimitersInThePath()+{+$url = Storage::temporaryUrl('serve-file-test.txt?pad=x', Carbon::now()->addMinute());++$response = $this->get($url);++$this->assertSame('Hello Question', $response->streamedContent());+}++public function testUriDelimitersInThePathCannotHideAnExpiredUrl()+{+$url = Storage::temporaryUrl('serve-file-test.txt?pad=x', Carbon::now()->subMinute());++$response = $this->get($url);++$response->assertForbidden();+}}
Fix path separator encoding in temporaryUrl on local disk
src/Illuminate/Filesystem/LocalFilesystemAdapter.php+1 −1
@@ -82,7 +82,7 @@ public function temporaryUrl($path, $expiration, array $options = [])return $url->to($url->temporarySignedRoute('storage.'.$this->disk,$expiration,-['path' => rawurlencode($path)],+['path' => strtr(rawurlencode($path), ['%2F' => '/'])],absolute: false));}
tests/Integration/Filesystem/ServeFileTest.php+14 −0
@@ -16,12 +16,14 @@ protected function setUp(): void$this->afterApplicationCreated(function () {Storage::put('serve-file-test.txt', 'Hello World');Storage::put('serve-file-test.txt?pad=x', 'Hello Question');+Storage::put('nested/folder/serve-file-test.txt', 'Hello Nested');});$this->beforeApplicationDestroyed(function () {Storage::delete(['serve-file-test.txt','serve-file-test.txt?pad=x',+'nested/folder/serve-file-test.txt',]);});@@ -67,6 +69,18 @@ public function testItCanServeAFileWithUriDelimitersInThePath()$this->assertSame('Hello Question', $response->streamedContent());}+#[RequiresOperatingSystem('Linux|Darwin')]+public function testTemporaryUrlPreservesPathSeparatorsInNestedPaths()+{+$url = Storage::temporaryUrl('nested/folder/serve-file-test.txt', Carbon::now()->addMinute());++$this->assertStringContainsString('nested/folder/serve-file-test.txt', $url);++$response = $this->get($url);++$this->assertSame('Hello Nested', $response->streamedContent());+}+#[RequiresOperatingSystem('Linux|Darwin')]public function testUriDelimitersInThePathCannotHideAnExpiredUrl(){
fix path seperator being encoded (#60194)
src/Illuminate/Filesystem/LocalFilesystemAdapter.php+1 −1
@@ -111,7 +111,7 @@ public function temporaryUploadUrl($path, $expiration, array $options = [])'url' => $url->to($url->temporarySignedRoute('storage.'.$this->disk.'.upload',$expiration,-['path' => rawurlencode($path), 'upload' => true],+['path' => strtr(rawurlencode($path), ['%2F' => '/']), 'upload' => true],absolute: false)),'headers' => [],
tests/Integration/Filesystem/ReceiveFileTest.php+37 −1
@@ -12,7 +12,11 @@ class ReceiveFileTest extends TestCaseprotected function setUp(): void{$this->beforeApplicationDestroyed(function () {-Storage::delete('receive-file-test.txt');+Storage::delete([+'receive-file-test.txt',+'receive-file-test.txt?pad=x',+'nested/folder/receive-file-test.txt',+]);});parent::setUp();@@ -72,4 +76,36 @@ public function testUploadUrlCannotBeUsedForDownload()$response->assertForbidden();}++#[RequiresOperatingSystem('Linux|Darwin')]+public function testItCanReceiveAFileWithUriDelimitersInThePath()+{+$result = Storage::temporaryUploadUrl('receive-file-test.txt?pad=x', Carbon::now()->addMinute());++$response = $this->call('PUT', $result['url'], [], [], [], [], 'Hello Question');++$response->assertNoContent();+Storage::assertExists('receive-file-test.txt?pad=x', 'Hello Question');+Storage::assertMissing('receive-file-test.txt');+}++#[RequiresOperatingSystem('Linux|Darwin')]+public function testTemporaryUploadUrlPreservesPathSeparatorsInNestedPaths()+{+$result = Storage::temporaryUploadUrl('nested/folder/receive-file-test.txt', Carbon::now()->addMinute());++$this->assertStringContainsString('nested/folder/receive-file-test.txt', $result['url']);+}++#[RequiresOperatingSystem('Linux|Darwin')]+public function testUriDelimitersInThePathCannotHideAnExpiredUploadUrl()+{+$result = Storage::temporaryUploadUrl('receive-file-test.txt?pad=x', Carbon::now()->subMinute());++$response = $this->call('PUT', $result['url'], [], [], [], [], 'Hello Question');++$response->assertForbidden();+Storage::assertMissing('receive-file-test.txt');+Storage::assertMissing('receive-file-test.txt?pad=x');+}}(#60230).../Filesystem/LocalFilesystemAdapter.php | 3 +-.../Integration/Filesystem/ServeFileTest.php | 43 +++++++++++++++++++2 files changed, 44 insertions(+), 2 deletions(-)
src/Illuminate/Filesystem/LocalFilesystemAdapter.php+1 −2
@@ -80,8 +80,7 @@ public function temporaryUrl($path, $expiration, array $options = [])return $url->to($url->temporarySignedRoute('storage.'.$this->disk,$expiration,-['path' => rawurldecode($path)],-absolute: false+['path' => strtr(rawurlencode($path), ['%2F' => '/'])]));}
tests/Integration/Filesystem/ServeFileTest.php+43 −0
@@ -2,9 +2,11 @@namespace Illuminate\Tests\Integration\Filesystem;+use Illuminate\Support\Carbon;use Illuminate\Support\Facades\Storage;use Orchestra\Testbench\Attributes\WithConfig;use Orchestra\Testbench\TestCase;+use PHPUnit\Framework\Attributes\RequiresOperatingSystem;#[WithConfig('filesystems.disks.local.serve', true)]class ServeFileTest extends TestCase@@ -17,6 +19,16 @@ protected function setUp(): void$this->beforeApplicationDestroyed(function () {Storage::delete('serve-file-test.txt');+Storage::put('serve-file-test.txt?pad=x', 'Hello Question');+Storage::put('nested/folder/serve-file-test.txt', 'Hello Nested');+});++$this->beforeApplicationDestroyed(function () {+Storage::delete([+'serve-file-test.txt',+'serve-file-test.txt?pad=x',+'nested/folder/serve-file-test.txt',+]);});parent::setUp();@@ -48,6 +60,37 @@ public function testItWill403OnWrongSignature()$response = $this->get($url);+$response->assertForbidden();+}+#[RequiresOperatingSystem('Linux|Darwin')]+public function testItCanServeAFileWithUriDelimitersInThePath()+{+$url = Storage::temporaryUrl('serve-file-test.txt?pad=x', Carbon::now()->addMinute());++$response = $this->get($url);++$this->assertSame('Hello Question', $response->streamedContent());+}++#[RequiresOperatingSystem('Linux|Darwin')]+public function testTemporaryUrlPreservesPathSeparatorsInNestedPaths()+{+$url = Storage::temporaryUrl('nested/folder/serve-file-test.txt', Carbon::now()->addMinute());++$this->assertStringContainsString('nested/folder/serve-file-test.txt', $url);++$response = $this->get($url);++$this->assertSame('Hello Nested', $response->streamedContent());+}++#[RequiresOperatingSystem('Linux|Darwin')]+public function testUriDelimitersInThePathCannotHideAnExpiredUrl()+{+$url = Storage::temporaryUrl('serve-file-test.txt?pad=x', Carbon::now()->subMinute());++$response = $this->get($url);+$response->assertForbidden();}}tests/Integration/Filesystem/ReceiveFileTest.php | 2 ++1 file changed, 2 insertions(+)
tests/Integration/Filesystem/ReceiveFileTest.php+2 −0
@@ -2,9 +2,11 @@namespace Illuminate\Tests\Integration\Filesystem;+use Illuminate\Support\Carbon;use Illuminate\Support\Facades\Storage;use Orchestra\Testbench\Attributes\WithConfig;use Orchestra\Testbench\TestCase;+use PHPUnit\Framework\Attributes\RequiresOperatingSystem;#[WithConfig('filesystems.disks.local.serve', true)]class ReceiveFileTest extends TestCasesrc/Illuminate/Filesystem/LocalFilesystemAdapter.php | 3 ++-tests/Integration/Filesystem/ServeFileTest.php | 5 +----2 files changed, 3 insertions(+), 5 deletions(-)
src/Illuminate/Filesystem/LocalFilesystemAdapter.php+2 −1
@@ -80,7 +80,8 @@ public function temporaryUrl($path, $expiration, array $options = [])return $url->to($url->temporarySignedRoute('storage.'.$this->disk,$expiration,-['path' => strtr(rawurlencode($path), ['%2F' => '/'])]+['path' => strtr(rawurlencode($path), ['%2F' => '/'])],+absolute: false));}
tests/Integration/Filesystem/ServeFileTest.php+1 −4
@@ -15,10 +15,6 @@ protected function setUp(): void{$this->afterApplicationCreated(function () {Storage::put('serve-file-test.txt', 'Hello World');-});--$this->beforeApplicationDestroyed(function () {-Storage::delete('serve-file-test.txt');Storage::put('serve-file-test.txt?pad=x', 'Hello Question');Storage::put('nested/folder/serve-file-test.txt', 'Hello Nested');});@@ -31,6 +27,7 @@ protected function setUp(): void]);});+parent::setUp();}tests/Integration/Filesystem/ServeFileTest.php | 1 -1 file changed, 1 deletion(-)
tests/Integration/Filesystem/ServeFileTest.php+0 −1
@@ -27,7 +27,6 @@ protected function setUp(): void]);});-parent::setUp();}tests/Integration/Filesystem/ServeFileTest.php | 1 +1 file changed, 1 insertion(+)