From 68330c3a590b13c68f7390bacb1ac5068e771eec Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 21 May 2026 10:41:57 +0100 Subject: [PATCH 01/10] Add failing test --- tests/Tokens/TokenRepositoryTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Tokens/TokenRepositoryTest.php b/tests/Tokens/TokenRepositoryTest.php index 7ad1172bae5..421afe6f68a 100644 --- a/tests/Tokens/TokenRepositoryTest.php +++ b/tests/Tokens/TokenRepositoryTest.php @@ -129,6 +129,25 @@ public function attempting_to_find_a_non_existent_token_returns_null() $this->assertNull($this->tokens->find('missing-token')); } + #[Test] + public function it_prevents_path_traversal_in_find() + { + File::put(storage_path('statamic/evil.yaml'), "handler: 'Handler'\nexpires_at: 9999999999\ndata: []"); + + $this->assertNull($this->tokens->find('../evil')); + } + + #[Test] + public function it_generates_token_when_path_traversal_attempted_in_make() + { + Generator::shouldReceive('generate')->times(4)->andReturn('generated-token'); + + $this->assertEquals('generated-token', $this->tokens->make('../../../etc/passwd', 'Handler')->token()); + $this->assertEquals('generated-token', $this->tokens->make('..\\..\\..\\windows', 'Handler')->token()); + $this->assertEquals('generated-token', $this->tokens->make('foo/bar', 'Handler')->token()); + $this->assertEquals('generated-token', $this->tokens->make('foo\\bar', 'Handler')->token()); + } + #[Test] public function it_deletes_expired_tokens() { From a5ad9f03bfd19282cbaa2b9cd3560347c655b7f0 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 21 May 2026 11:07:36 +0100 Subject: [PATCH 02/10] Tweak tests --- tests/Tokens/TokenRepositoryTest.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/Tokens/TokenRepositoryTest.php b/tests/Tokens/TokenRepositoryTest.php index 421afe6f68a..28737c1472f 100644 --- a/tests/Tokens/TokenRepositoryTest.php +++ b/tests/Tokens/TokenRepositoryTest.php @@ -138,14 +138,11 @@ public function it_prevents_path_traversal_in_find() } #[Test] - public function it_generates_token_when_path_traversal_attempted_in_make() + public function it_prevents_path_traversal_in_make() { - Generator::shouldReceive('generate')->times(4)->andReturn('generated-token'); + Generator::shouldReceive('generate')->once()->andReturn('generated-token'); - $this->assertEquals('generated-token', $this->tokens->make('../../../etc/passwd', 'Handler')->token()); - $this->assertEquals('generated-token', $this->tokens->make('..\\..\\..\\windows', 'Handler')->token()); - $this->assertEquals('generated-token', $this->tokens->make('foo/bar', 'Handler')->token()); - $this->assertEquals('generated-token', $this->tokens->make('foo\\bar', 'Handler')->token()); + $this->assertEquals('generated-token', $this->tokens->make('../evil', 'Handler')->token()); } #[Test] From 0e9bddfeaa9e5923ea12d00001efd664e5e8e3de Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 21 May 2026 11:07:57 +0100 Subject: [PATCH 03/10] Prevent path traversal in token repository --- src/Tokens/FileTokenRepository.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Tokens/FileTokenRepository.php b/src/Tokens/FileTokenRepository.php index 044112fba8f..bb8ea42943f 100644 --- a/src/Tokens/FileTokenRepository.php +++ b/src/Tokens/FileTokenRepository.php @@ -11,11 +11,19 @@ class FileTokenRepository extends TokenRepository { public function make(?string $token, string $handler, array $data = []): TokenContract { + if ($token && (str_contains($token, '/') || str_contains($token, '\\'))) { + $token = null; + } + return app()->makeWith(TokenContract::class, compact('token', 'handler', 'data')); } public function find(string $token): ?TokenContract { + if (str_contains($token, '/') || str_contains($token, '\\')) { + return null; + } + $path = storage_path('statamic/tokens/'.$token.'.yaml'); if (! File::exists($path)) { From 4f4351e001a4069f1d60b22ec94485e314e5f05d Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 21 May 2026 11:37:28 +0100 Subject: [PATCH 04/10] Handle race condition in `LivePreview::tokenize()` --- src/CP/LivePreview.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CP/LivePreview.php b/src/CP/LivePreview.php index 21c0705c239..bed95d42a34 100644 --- a/src/CP/LivePreview.php +++ b/src/CP/LivePreview.php @@ -11,7 +11,7 @@ class LivePreview { public function tokenize($token, $item): TokenContract { - $token = tap(Token::make($token, Handler::class))->save(); + $token = Token::find($token) ?? tap(Token::make($token, Handler::class))->save(); Cache::put('statamic.live-preview.'.$token->token(), $item, now()->addHour()); From 151ac7ec88ff7ddf7424e5bd3ae0a70a6d5dd3a4 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 21 May 2026 14:44:20 +0100 Subject: [PATCH 05/10] Skip token lookup when token is empty Avoids calling Token::find() with an empty or null token value, which would otherwise trigger the path traversal protection and throw an exception. --- src/CP/LivePreview.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/CP/LivePreview.php b/src/CP/LivePreview.php index bed95d42a34..ba3f3fa4387 100644 --- a/src/CP/LivePreview.php +++ b/src/CP/LivePreview.php @@ -11,7 +11,8 @@ class LivePreview { public function tokenize($token, $item): TokenContract { - $token = Token::find($token) ?? tap(Token::make($token, Handler::class))->save(); + $token = ($token ? Token::find($token) : null) + ?? tap(Token::make($token, Handler::class))->save(); Cache::put('statamic.live-preview.'.$token->token(), $item, now()->addHour()); From 8d0a82c69f44509209c1a9c455d351d0c2716334 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 21 May 2026 14:56:38 +0100 Subject: [PATCH 06/10] Use unique token name in test --- tests/API/APITest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/API/APITest.php b/tests/API/APITest.php index 64737f01e4a..e8d7e803e79 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -494,9 +494,9 @@ public function non_live_preview_tokens_doesnt_bypass_entry_status_check() 'message' => 'Not found.', ]); - Token::make('test-token', FakeTokenHandler::class)->save(); + Token::make('fake-token', FakeTokenHandler::class)->save(); - $this->get('/api/collections/pages/entries/dance?token=test-token')->assertJson([ + $this->get('/api/collections/pages/entries/dance?token=fake-token')->assertJson([ 'message' => 'Not found.', ]); } From efe7f59f1f846fd12a87fbc8825b92ac68bfef19 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 21 May 2026 14:16:43 -0400 Subject: [PATCH 07/10] keep the race condition fix out of this pr --- src/CP/LivePreview.php | 3 +-- tests/API/APITest.php | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/CP/LivePreview.php b/src/CP/LivePreview.php index ba3f3fa4387..21c0705c239 100644 --- a/src/CP/LivePreview.php +++ b/src/CP/LivePreview.php @@ -11,8 +11,7 @@ class LivePreview { public function tokenize($token, $item): TokenContract { - $token = ($token ? Token::find($token) : null) - ?? tap(Token::make($token, Handler::class))->save(); + $token = tap(Token::make($token, Handler::class))->save(); Cache::put('statamic.live-preview.'.$token->token(), $item, now()->addHour()); diff --git a/tests/API/APITest.php b/tests/API/APITest.php index e8d7e803e79..64737f01e4a 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -494,9 +494,9 @@ public function non_live_preview_tokens_doesnt_bypass_entry_status_check() 'message' => 'Not found.', ]); - Token::make('fake-token', FakeTokenHandler::class)->save(); + Token::make('test-token', FakeTokenHandler::class)->save(); - $this->get('/api/collections/pages/entries/dance?token=fake-token')->assertJson([ + $this->get('/api/collections/pages/entries/dance?token=test-token')->assertJson([ 'message' => 'Not found.', ]); } From e3e770ca477e7136e39a76c2688d7bda708f4f3e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 21 May 2026 14:40:23 -0400 Subject: [PATCH 08/10] Validate token names against an allowlist Reject any token name that isn't strictly [A-Za-z0-9_-] rather than denylisting separators. This covers traversal, absolute paths, and Windows drive/UNC paths in one check, regardless of OS or encoding. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Tokens/FileTokenRepository.php | 9 +++++++-- tests/Tokens/TokenRepositoryTest.php | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Tokens/FileTokenRepository.php b/src/Tokens/FileTokenRepository.php index bb8ea42943f..8abc2e7d462 100644 --- a/src/Tokens/FileTokenRepository.php +++ b/src/Tokens/FileTokenRepository.php @@ -11,7 +11,7 @@ class FileTokenRepository extends TokenRepository { public function make(?string $token, string $handler, array $data = []): TokenContract { - if ($token && (str_contains($token, '/') || str_contains($token, '\\'))) { + if ($token && ! $this->isValidTokenName($token)) { $token = null; } @@ -20,7 +20,7 @@ public function make(?string $token, string $handler, array $data = []): TokenCo public function find(string $token): ?TokenContract { - if (str_contains($token, '/') || str_contains($token, '\\')) { + if (! $this->isValidTokenName($token)) { return null; } @@ -63,6 +63,11 @@ public function collectGarbage(): void ->each->delete(); } + private function isValidTokenName(string $token): bool + { + return (bool) preg_match('/^[A-Za-z0-9_-]+$/', $token); + } + private function makeFromPath(string $path): FileToken { $yaml = YAML::file($path)->parse(); diff --git a/tests/Tokens/TokenRepositoryTest.php b/tests/Tokens/TokenRepositoryTest.php index 28737c1472f..a1b74053ee7 100644 --- a/tests/Tokens/TokenRepositoryTest.php +++ b/tests/Tokens/TokenRepositoryTest.php @@ -5,6 +5,7 @@ use Facades\Statamic\Tokens\Generator; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Tokens\Token; use Statamic\Facades\File; @@ -138,11 +139,25 @@ public function it_prevents_path_traversal_in_find() } #[Test] - public function it_prevents_path_traversal_in_make() + #[DataProvider('invalidTokenNameProvider')] + public function it_rejects_invalid_token_names_in_make($token) { Generator::shouldReceive('generate')->once()->andReturn('generated-token'); - $this->assertEquals('generated-token', $this->tokens->make('../evil', 'Handler')->token()); + $this->assertEquals('generated-token', $this->tokens->make($token, 'Handler')->token()); + } + + public static function invalidTokenNameProvider() + { + return [ + 'parent traversal' => ['../evil'], + 'backslash traversal' => ['..\\evil'], + 'nested traversal' => ['foo/../../evil'], + 'forward slash' => ['foo/evil'], + 'dots only' => ['..'], + 'absolute path' => ['/etc/passwd'], + 'windows drive' => ['C:\\evil'], + ]; } #[Test] From 0b75e1c2156eeb885296f1d6a65b5b8d612e49bc Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 21 May 2026 14:48:59 -0400 Subject: [PATCH 09/10] Throw when making a token with an invalid name Fail fast at construction instead of silently regenerating, so callers don't end up holding a token whose name differs from what they asked for. find() still returns null for invalid names to avoid an error oracle on attacker-supplied input. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Tokens/FileTokenRepository.php | 2 +- tests/Tokens/TokenRepositoryTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tokens/FileTokenRepository.php b/src/Tokens/FileTokenRepository.php index 8abc2e7d462..0730162fe28 100644 --- a/src/Tokens/FileTokenRepository.php +++ b/src/Tokens/FileTokenRepository.php @@ -12,7 +12,7 @@ class FileTokenRepository extends TokenRepository public function make(?string $token, string $handler, array $data = []): TokenContract { if ($token && ! $this->isValidTokenName($token)) { - $token = null; + throw new \InvalidArgumentException("Invalid token name [{$token}]."); } return app()->makeWith(TokenContract::class, compact('token', 'handler', 'data')); diff --git a/tests/Tokens/TokenRepositoryTest.php b/tests/Tokens/TokenRepositoryTest.php index a1b74053ee7..22735c5a75f 100644 --- a/tests/Tokens/TokenRepositoryTest.php +++ b/tests/Tokens/TokenRepositoryTest.php @@ -140,11 +140,11 @@ public function it_prevents_path_traversal_in_find() #[Test] #[DataProvider('invalidTokenNameProvider')] - public function it_rejects_invalid_token_names_in_make($token) + public function it_throws_when_making_a_token_with_an_invalid_name($token) { - Generator::shouldReceive('generate')->once()->andReturn('generated-token'); + $this->expectException(\InvalidArgumentException::class); - $this->assertEquals('generated-token', $this->tokens->make($token, 'Handler')->token()); + $this->tokens->make($token, 'Handler'); } public static function invalidTokenNameProvider() From 19e1eafaceec37e0431e42e21ca5e7a15a5f74ef Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 21 May 2026 14:57:27 -0400 Subject: [PATCH 10/10] Use \z anchor so a trailing newline is rejected PCRE's $ matches before a trailing newline, which let a token name like "abc\n" pass the allowlist. \z anchors strictly to end of string. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Tokens/FileTokenRepository.php | 2 +- tests/Tokens/TokenRepositoryTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tokens/FileTokenRepository.php b/src/Tokens/FileTokenRepository.php index 0730162fe28..7b19622d931 100644 --- a/src/Tokens/FileTokenRepository.php +++ b/src/Tokens/FileTokenRepository.php @@ -65,7 +65,7 @@ public function collectGarbage(): void private function isValidTokenName(string $token): bool { - return (bool) preg_match('/^[A-Za-z0-9_-]+$/', $token); + return (bool) preg_match('/^[A-Za-z0-9_-]+\z/', $token); } private function makeFromPath(string $path): FileToken diff --git a/tests/Tokens/TokenRepositoryTest.php b/tests/Tokens/TokenRepositoryTest.php index 22735c5a75f..e26245b8346 100644 --- a/tests/Tokens/TokenRepositoryTest.php +++ b/tests/Tokens/TokenRepositoryTest.php @@ -157,6 +157,7 @@ public static function invalidTokenNameProvider() 'dots only' => ['..'], 'absolute path' => ['/etc/passwd'], 'windows drive' => ['C:\\evil'], + 'trailing newline' => ["evil\n"], ]; }