From e2482c1ebd2f3f6297478b5b30b5367f6dfa4225 Mon Sep 17 00:00:00 2001 From: Micah Henshaw <31399816+micahhenshaw@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:07:17 +1100 Subject: [PATCH 01/37] [5.x] Removed a comment from the js code output of the StaticCacher (#14233) Co-authored-by: Micah Henshaw --- src/StaticCaching/Cachers/FileCacher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php index 50170b5983f..d8791897b76 100644 --- a/src/StaticCaching/Cachers/FileCacher.php +++ b/src/StaticCaching/Cachers/FileCacher.php @@ -278,7 +278,7 @@ function replaceElement(el, html) { }) .then((response) => response.json()) .then((data) => { - map = createMap(); // Recreate map in case the DOM changed. + map = createMap(); const regions = data.regions; for (var key in regions) { From a9f513bf1cef6d70944990bdc5eadefacc018f17 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Thu, 12 Mar 2026 15:50:13 +0100 Subject: [PATCH 02/37] [5.x] Fix ensure field has config (#14195) Co-authored-by: Jesse Leite --- src/Fields/Blueprint.php | 18 +++++++++++++++++ tests/Fields/BlueprintTest.php | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/Fields/Blueprint.php b/src/Fields/Blueprint.php index c1e3b14c217..616f2e4fec0 100644 --- a/src/Fields/Blueprint.php +++ b/src/Fields/Blueprint.php @@ -645,6 +645,11 @@ protected function ensureFieldInTabHasConfig($handle, $tab, $config) return $this; } + // If field is deferred as an ensured field, we'll need to update it instead + if (! isset($fields[$handle]) && isset($this->ensuredFields[$handle])) { + return $this->ensureEnsuredFieldHasConfig($handle, $config); + } + $fieldKey = $fields[$handle]['fieldIndex']; $sectionKey = $fields[$handle]['sectionIndex']; @@ -664,6 +669,19 @@ protected function ensureFieldInTabHasConfig($handle, $tab, $config) return $this->resetBlueprintCache()->resetFieldsCache(); } + private function ensureEnsuredFieldHasConfig($handle, $config) + { + if (! isset($this->ensuredFields[$handle])) { + return $this; + } + + $existingConfig = Arr::get($this->ensuredFields[$handle], 'config', []); + + $this->ensuredFields[$handle]['config'] = array_merge($existingConfig, $config); + + return $this->resetBlueprintCache()->resetFieldsCache(); + } + public function validateUniqueHandles() { $fields = $this->fieldsCache ?? new Fields($this->tabs()->map->fields()->flatMap->items()); diff --git a/tests/Fields/BlueprintTest.php b/tests/Fields/BlueprintTest.php index 2be10d841da..5ef21d4f39c 100644 --- a/tests/Fields/BlueprintTest.php +++ b/tests/Fields/BlueprintTest.php @@ -898,6 +898,41 @@ public function it_ensures_a_field_has_config() // todo: duplicate or tweak above test but make the target field not in the first section. + #[Test] + public function it_can_ensure_an_deferred_ensured_field_has_specific_config() + { + $blueprint = (new Blueprint)->setContents(['tabs' => [ + 'tab_one' => [ + 'sections' => [ + [ + 'fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ], + ], + ], + ], + ]]); + + // Let's say somewhere else in the code ensures an `author` field + $blueprint->ensureField('author', ['type' => 'text', 'do_not_touch_other_config' => true, 'foo' => 'bar']); + + // Then later, we try to ensure that `author` field has config, we should be able to successfully modify that deferred field + $fields = $blueprint + ->ensureFieldHasConfig('author', ['foo' => 'baz', 'visibility' => 'read_only']) + ->fields(); + + $this->assertEquals(['type' => 'text'], $fields->get('title')->config()); + + $expectedConfig = [ + 'type' => 'text', + 'do_not_touch_other_config' => true, + 'foo' => 'baz', + 'visibility' => 'read_only', + ]; + + $this->assertEquals($expectedConfig, $fields->get('author')->config()); + } + #[Test] public function it_merges_previously_undefined_keys_into_the_config_when_ensuring_a_field_exists_and_it_already_exists() { From 7c820bb9e61b9a305586b724ab4fa2322e4eacab Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 13 Mar 2026 16:30:03 -0400 Subject: [PATCH 03/37] [5.x] Relationship endpoint authorization (#14254) --- src/Fieldtypes/Entries.php | 31 +++++- src/Fieldtypes/Terms.php | 26 +++++ .../Fieldtypes/RelationshipFieldtypeTest.php | 96 ++++++++++++++++++- 3 files changed, 150 insertions(+), 3 deletions(-) diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 7631364c301..293c2b4c588 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -7,6 +7,7 @@ use Statamic\Contracts\Entries\Entry as EntryContract; use Statamic\CP\Column; use Statamic\CP\Columns; +use Statamic\Exceptions\AuthorizationException; use Statamic\Exceptions\CollectionNotFoundException; use Statamic\Facades\Blink; use Statamic\Facades\Collection; @@ -132,12 +133,16 @@ protected function configFieldItems(): array public function getIndexItems($request) { + $configuredCollections = $this->getConfiguredCollections(); + $requestedCollections = $this->getRequestedCollections($request, $configuredCollections); + $this->authorizeCollectionAccess($requestedCollections); + $query = $this->getIndexQuery($request); $filters = $request->filters; if (! isset($filters['collection'])) { - $query->whereIn('collection', $this->getConfiguredCollections()); + $query->whereIn('collection', $configuredCollections); } if ($blueprints = $this->config('blueprints')) { @@ -157,6 +162,30 @@ public function getIndexItems($request) return $paginate ? $results->setCollection($items) : $items; } + private function getRequestedCollections($request, $configuredCollections) + { + $filteredCollections = collect($request->input('filters.collection.collections', [])) + ->filter() + ->values() + ->all(); + + return empty($filteredCollections) ? $configuredCollections : $filteredCollections; + } + + private function authorizeCollectionAccess($collections) + { + $user = User::current(); + + collect($collections)->each(function ($collectionHandle) use ($user) { + $collection = Collection::findByHandle($collectionHandle); + + throw_if( + ! $collection || ! $user->can('view', $collection), + new AuthorizationException + ); + }); + } + public function getResourceCollection($request, $items) { return (new EntriesFieldtypeEntries($items, $this)) diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index ce404f113b3..c7b46cf028d 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -7,6 +7,7 @@ use Statamic\Contracts\Entries\Entry; use Statamic\Contracts\Taxonomies\Term as TermContract; use Statamic\CP\Column; +use Statamic\Exceptions\AuthorizationException; use Statamic\Exceptions\TaxonomyNotFoundException; use Statamic\Exceptions\TermsFieldtypeBothOptionsUsedException; use Statamic\Exceptions\TermsFieldtypeTaxonomyOptionUsed; @@ -257,6 +258,10 @@ public function getIndexItems($request) return collect(); } + $this->authorizeTaxonomyAccess( + $this->getRequestedTaxonomies($request, $this->getConfiguredTaxonomies()) + ); + $query = $this->getIndexQuery($request); if ($sort = $this->getSortColumn($request)) { @@ -266,6 +271,27 @@ public function getIndexItems($request) return $request->boolean('paginate', true) ? $query->paginate() : $query->get(); } + private function getRequestedTaxonomies($request, $configuredTaxonomies) + { + $requestedTaxonomies = collect($request->taxonomies)->filter()->values()->all(); + + return empty($requestedTaxonomies) ? $configuredTaxonomies : $requestedTaxonomies; + } + + private function authorizeTaxonomyAccess($taxonomies) + { + $user = User::current(); + + collect($taxonomies)->each(function ($taxonomyHandle) use ($user) { + $taxonomy = Taxonomy::findByHandle($taxonomyHandle); + + throw_if( + ! $taxonomy || ! $user->can('view', $taxonomy), + new AuthorizationException + ); + }); + } + public function getResourceCollection($request, $items) { return (new TermsResource($items, $this)) diff --git a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php index 30cf1ed8795..3d9504855c7 100644 --- a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php +++ b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php @@ -5,6 +5,8 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Collection; use Statamic\Facades\Entry; +use Statamic\Facades\Taxonomy; +use Statamic\Facades\Term; use Statamic\Facades\User; use Statamic\Query\Scopes\Scope; use Tests\FakesRoles; @@ -35,7 +37,7 @@ public function it_filters_entries_by_query_scopes() Entry::make()->collection('test')->slug('cherry')->data(['title' => 'Cherry'])->save(); Entry::make()->collection('test')->slug('banana')->data(['title' => 'Banana'])->save(); - $this->setTestRoles(['test' => ['access cp']]); + $this->setTestRoles(['test' => ['access cp', 'view test entries']]); $user = User::make()->assignRole('test')->save(); $config = base64_encode(json_encode([ @@ -46,7 +48,7 @@ public function it_filters_entries_by_query_scopes() $response = $this ->actingAs($user) - ->get("/cp/fieldtypes/relationship?config={$config}&collections[0]=test") + ->get("/cp/fieldtypes/relationship?config={$config}") ->assertOk(); $titles = collect($response->json('data'))->pluck('title')->all(); @@ -57,6 +59,96 @@ public function it_filters_entries_by_query_scopes() $this->assertNotContains('Apple', $titles); $this->assertNotContains('Banana', $titles); } + + #[Test] + public function it_denies_access_to_entries_when_theres_a_collection_the_user_cannot_view() + { + Collection::make('secret')->save(); + Entry::make()->collection('secret')->slug('secret-one')->data(['title' => 'Secret One'])->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode([ + 'type' => 'entries', + 'collections' => ['secret'], + ])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertForbidden(); + } + + #[Test] + public function it_forbids_access_to_entries_when_filters_target_a_collection_the_user_cannot_view() + { + Collection::make('secret')->save(); + Entry::make()->collection('test')->slug('apple')->data(['title' => 'Apple'])->save(); + Entry::make()->collection('secret')->slug('secret-one')->data(['title' => 'Secret One'])->save(); + + $this->setTestRoles([ + 'test' => ['access cp', 'view test entries'], + ]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode([ + 'type' => 'entries', + 'collections' => ['test'], + ])); + $filters = base64_encode(json_encode([ + 'collection' => ['collections' => ['secret']], + ])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}&filters={$filters}") + ->assertForbidden(); + } + + #[Test] + public function it_forbids_access_to_terms_when_config_contains_a_taxonomy_the_user_cannot_view() + { + Taxonomy::make('secret')->save(); + Term::make('internal')->taxonomy('secret')->data([])->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode([ + 'type' => 'terms', + 'taxonomies' => ['secret'], + ])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}&taxonomies[0]=secret") + ->assertForbidden(); + } + + #[Test] + public function it_forbids_access_to_terms_when_requested_taxonomy_is_forbidden() + { + Taxonomy::make('topics')->save(); + Taxonomy::make('secret')->save(); + Term::make('public')->taxonomy('topics')->data([])->save(); + Term::make('internal')->taxonomy('secret')->data([])->save(); + + $this->setTestRoles([ + 'test' => ['access cp', 'view topics terms'], + ]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode([ + 'type' => 'terms', + 'taxonomies' => ['topics'], + ])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}&taxonomies[0]=secret") + ->assertForbidden(); + } } class StartsWithC extends Scope From 5b0c01ca14768bcc45f1a1e06d8abe22de66f14e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 13 Mar 2026 16:54:22 -0400 Subject: [PATCH 04/37] changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4872ac82173..6ca22b534ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Release Notes +## 5.73.13 (2026-03-13) + +### What's fixed +- Relationship endpoint authorization [#14254](https://github.com/statamic/cms/issues/14254) by @jasonvarga +- Fix ensure field has config [#14195](https://github.com/statamic/cms/issues/14195) by @marcorieser +- Removed a comment from the js code output of the StaticCacher [#14233](https://github.com/statamic/cms/issues/14233) by @micahhenshaw +- Acquire stache-warming lock in Duplicates::find [#14176](https://github.com/statamic/cms/issues/14176) by @mmodler + + + ## 5.73.12 (2026-03-04) ### What's fixed From ecc1caa015a6c963a752b165418aca2c2f203043 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 16 Mar 2026 18:16:45 -0400 Subject: [PATCH 05/37] [5.x] Sanitize SVGs on asset reupload (#14270) Co-authored-by: Duncan McClean --- src/Assets/Asset.php | 12 +++++++ tests/Assets/AssetTest.php | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index de31d71fed5..9f017a6ad33 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -8,6 +8,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use League\Flysystem\PathTraversalDetected; +use Rhukster\DomSanitizer\DOMSanitizer; use Statamic\Assets\AssetUploader as Uploader; use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract; @@ -945,6 +946,17 @@ public function reupload(ReplacementFile $file) $file->writeTo($this->disk()->filesystem(), $this->path()); + if ($this->isSvg() && config('statamic.assets.svg_sanitization_on_upload', true)) { + $contents = $this->disk()->get($this->path()); + + $this->disk()->put( + $this->path(), + (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($contents, [ + 'remove-xml-tags' => ! Str::startsWith($contents, 'clearCaches(); $this->writeMeta($this->generateMeta()); diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index 69646d24022..a2fc3d7ea9b 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -2180,6 +2180,76 @@ public function cannot_reupload_a_file_with_a_different_extension() Event::assertNotDispatched(AssetSaved::class); } + #[Test] + public function it_sanitizes_svgs_on_reupload() + { + Event::fake(); + + // Create and upload an initial clean SVG + $asset = (new Asset)->container($this->container)->path('path/to/asset.svg')->syncOriginal(); + Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container); + $asset->upload(UploadedFile::fake()->createWithContent('asset.svg', '')); + Storage::disk('test')->assertExists('path/to/asset.svg'); + + // Place a malicious SVG in the local disk for reupload + $uploadDisk = Storage::fake('local'); + $maliciousSvg = ''; + $uploadDisk->put('path/to/malicious.svg', $maliciousSvg); + $uploadDisk->assertExists('path/to/malicious.svg'); + + $file = new ReplacementFile('path/to/malicious.svg'); + + $return = $asset->reupload($file); + + $this->assertEquals($asset, $return); + Storage::disk('test')->assertExists('path/to/asset.svg'); + + // Ensure the inline scripts were stripped out + $this->assertStringNotContainsString('contents()); + $this->assertStringNotContainsString('Bad stuff could go in here.', $asset->contents()); + $this->assertStringNotContainsString('', $asset->contents()); + + Event::assertDispatched(AssetReuploaded::class, function ($event) use ($asset) { + return $event->asset->id() === $asset->id(); + }); + } + + #[Test] + public function it_does_not_sanitize_svgs_on_reupload_when_behaviour_is_disabled() + { + Event::fake(); + + config()->set('statamic.assets.svg_sanitization_on_upload', false); + + // Create and upload an initial clean SVG + $asset = (new Asset)->container($this->container)->path('path/to/asset.svg')->syncOriginal(); + Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container); + $asset->upload(UploadedFile::fake()->createWithContent('asset.svg', '')); + Storage::disk('test')->assertExists('path/to/asset.svg'); + + // Place a malicious SVG in the local disk for reupload + $uploadDisk = Storage::fake('local'); + $maliciousSvg = ''; + $uploadDisk->put('path/to/malicious.svg', $maliciousSvg); + $uploadDisk->assertExists('path/to/malicious.svg'); + + $file = new ReplacementFile('path/to/malicious.svg'); + + $return = $asset->reupload($file); + + $this->assertEquals($asset, $return); + Storage::disk('test')->assertExists('path/to/asset.svg'); + + // Ensure the inline scripts were NOT stripped out when disabled + $this->assertStringContainsString('contents()); + $this->assertStringContainsString('Bad stuff could go in here.', $asset->contents()); + $this->assertStringContainsString('', $asset->contents()); + + Event::assertDispatched(AssetReuploaded::class, function ($event) use ($asset) { + return $event->asset->id() === $asset->id(); + }); + } + #[Test] public function it_doesnt_lowercase_uploaded_filenames_when_configured() { From f243f13d36bd51e81f511e8f19b21b3df6260f75 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 17 Mar 2026 18:04:48 +0100 Subject: [PATCH 06/37] [5.x] Prevent path traversal in file dictionary (#14272) --- src/Dictionaries/File.php | 9 ++++++++- tests/Dictionaries/FileTest.php | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Dictionaries/File.php b/src/Dictionaries/File.php index 62765f74b95..942e46db479 100644 --- a/src/Dictionaries/File.php +++ b/src/Dictionaries/File.php @@ -2,6 +2,7 @@ namespace Statamic\Dictionaries; +use League\Flysystem\PathTraversalDetected; use Statamic\Facades\Antlers; use Statamic\Facades\YAML; @@ -55,7 +56,13 @@ protected function getItemLabel(array $item): string protected function getItems(): array { - $path = resource_path('dictionaries').'/'.$this->config['filename']; + $filename = $this->config['filename']; + + if (str_contains($filename, '..')) { + throw PathTraversalDetected::forPath($filename); + } + + $path = resource_path('dictionaries/'.$filename); if (! file_exists($path)) { throw new \Exception('Dictionary file ['.$path.'] does not exist.'); diff --git a/tests/Dictionaries/FileTest.php b/tests/Dictionaries/FileTest.php index cf5f45b8a4b..ad60a35f38b 100644 --- a/tests/Dictionaries/FileTest.php +++ b/tests/Dictionaries/FileTest.php @@ -2,6 +2,7 @@ namespace Tests\Dictionaries; +use League\Flysystem\PathTraversalDetected; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Dictionaries\File; @@ -180,4 +181,15 @@ public function it_gets_array_from_value() 'emoji' => '🍌', ], $item->data()); } + + #[Test] + public function path_traversal_not_allowed() + { + $this->expectException(PathTraversalDetected::class); + $this->expectExceptionMessage('Path traversal detected: ../secret.json'); + + (new File) + ->setConfig(['filename' => '../secret.json']) + ->options(); + } } From 92cb9e200a0d1d855c90cb221f1845dd2fb7a350 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 17 Mar 2026 18:52:09 +0100 Subject: [PATCH 07/37] [5.x] Prevent term creation via fieldtype without permission (#14274) Co-authored-by: Jason Varga --- src/Fieldtypes/Terms.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index c7b46cf028d..3408672388c 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -220,8 +220,13 @@ public function process($data) $id = $this->createTermFromString($id, $taxonomy); } + if (! $id) { + return null; + } + return explode('::', $id, 2)[1]; }) + ->filter() ->unique() ->values() ->all(); @@ -485,9 +490,15 @@ protected function createTermFromString($string, $taxonomy) $slug = Str::slug($string, '-', $lang); if (! $term = Facades\Term::find("{$taxonomy}::{$slug}")) { + $taxonomy = Facades\Taxonomy::findByHandle($taxonomy); + + if (User::current()->cant('create', [TermContract::class, $taxonomy])) { + return null; + } + $term = Facades\Term::make() ->slug($slug) - ->taxonomy(Facades\Taxonomy::findByHandle($taxonomy)) + ->taxonomy($taxonomy) ->set('title', $string); $term->save(); From 6dd9fb677bf0c4ac6103a131e0cab0336f1266be Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 17 Mar 2026 14:02:47 -0400 Subject: [PATCH 08/37] changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca22b534ad..5c215c1284a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Release Notes +## 5.73.14 (2026-03-17) + +### What's fixed +- Prevent term creation via fieldtype without permission [#14274](https://github.com/statamic/cms/issues/14274) by @duncanmcclean +- Prevent path traversal in file dictionary [#14272](https://github.com/statamic/cms/issues/14272) by @duncanmcclean +- Sanitize SVGs on asset reupload [#14270](https://github.com/statamic/cms/issues/14270) by @jasonvarga + + + ## 5.73.13 (2026-03-13) ### What's fixed From 27105a1739bac1e3ce876fdedd63b152846ad7b9 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 18 Mar 2026 17:20:46 +0100 Subject: [PATCH 09/37] [5.x] Add additional `URL::isExternalToApplication()` tests (#14288) Co-authored-by: Jason Varga --- tests/Facades/Concerns/ProvidesExternalUrls.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index 867aaf15407..731b22d6379 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -67,6 +67,21 @@ public static function externalUrlProvider() ['http://subdomain.this-site.com.au/some-slug', true], ['http://subdomain.this-site.com.au/some-slug?foo', true], ['http://subdomain.this-site.com.au/some-slug#anchor', true], + + // Credential injection + ['http://this-site.com@evil.com', true], + ['http://this-site.com@evil.com/', true], + ['http://this-site.com@evil.com/path', true], + ['http://this-site.com@evil.com/path?query', true], + ['http://this-site.com:password@evil.com', true], + ['http://user:pass@evil.com', true], + ['http://absolute-url-resolved-from-request.com@evil.com', true], + ['http://absolute-url-resolved-from-request.com@evil.com/path', true], + ['http://subdomain.this-site.com@evil.com', true], + ['http://subdomain.this-site.com@evil.com/path', true], + ['http://this-site.com:8000@evil.com', true], + ['http://this-site.com:8000@evil.com/path', true], + ['http://this-site.com:8000@webhook.site/token', true], ]; } } From beb2adc7e75b55bb354803aea7244e9d6d39e556 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 17:51:49 -0400 Subject: [PATCH 10/37] [5.x] Harden password reset (#14296) --- src/Auth/UserTags.php | 15 +- .../Controllers/ForgotPasswordController.php | 35 +++- tests/Auth/ForgotPasswordTest.php | 140 ++++++++++++++-- .../Facades/Concerns/ProvidesExternalUrls.php | 152 ++++++++++-------- tests/Tags/User/ForgotPasswordFormTest.php | 32 +++- 5 files changed, 284 insertions(+), 90 deletions(-) diff --git a/src/Auth/UserTags.php b/src/Auth/UserTags.php index 61f34cd36b0..f6f600a0442 100644 --- a/src/Auth/UserTags.php +++ b/src/Auth/UserTags.php @@ -352,7 +352,7 @@ public function forgotPasswordForm() $params['error_redirect'] = $this->parseRedirect($errorRedirect); } - if ($resetUrl = $this->params->get('reset_url')) { + if ($resetUrl = $this->getPasswordResetUrl($this->params->get('reset_url'))) { $params['reset_url'] = $resetUrl; } @@ -374,6 +374,19 @@ public function forgotPasswordForm() return $html; } + private function getPasswordResetUrl(?string $url = null): ?string + { + if (! $url) { + return null; + } + + if (! preg_match('#^https?://#', $url) && ! str_starts_with($url, '/')) { + $url = '/'.$url; + } + + return encrypt($url); + } + /** * Output a reset password form. * diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php index 325cc795d5e..7bfbd320adb 100644 --- a/src/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -2,11 +2,11 @@ namespace Statamic\Http\Controllers; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Password; use Statamic\Auth\Passwords\PasswordReset; use Statamic\Auth\SendsPasswordResetEmails; -use Statamic\Exceptions\ValidationException; use Statamic\Facades\URL; use Statamic\Http\Middleware\RedirectIfAuthenticated; @@ -30,17 +30,40 @@ public function showLinkRequestForm() public function sendResetLinkEmail(Request $request) { - if ($url = $request->_reset_url) { - throw_if(URL::isExternalToApplication($url), ValidationException::withMessages([ - '_reset_url' => trans('validation.url', ['attribute' => '_reset_url']), - ])); - + if ($url = $this->getResetFormUrl($request)) { PasswordReset::resetFormUrl(URL::makeAbsolute($url)); } return $this->traitSendResetLinkEmail($request); } + private function getResetFormUrl(Request $request): ?string + { + if (! $url = $request->_reset_url) { + return null; + } + + if (strlen($url) > 2048) { + return null; + } + + try { + $url = decrypt($url); + } catch (DecryptException $e) { + if (! str_starts_with($url, '/') || str_starts_with($url, '//')) { + return null; + } + + if (preg_match('/[\x00-\x1F\x7F]/', $url)) { + return null; + } + + return $url; + } + + return URL::isExternalToApplication($url) ? null : $url; + } + public function broker() { $broker = config('statamic.users.passwords.'.PasswordReset::BROKER_RESETS); diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php index 38d35f835d3..94c50b79ce1 100644 --- a/tests/Auth/ForgotPasswordTest.php +++ b/tests/Auth/ForgotPasswordTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Password; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Auth\Passwords\PasswordReset; use Statamic\Facades\User; use Tests\Facades\Concerns\ProvidesExternalUrls; use Tests\PreventSavingStacheItemsToDisk; @@ -22,35 +23,146 @@ protected function resolveApplicationConfiguration($app) $app['config']->set('app.url', 'http://absolute-url-resolved-from-request.com'); } - #[Test] - #[DataProvider('externalUrlProvider')] - public function it_validates_reset_url_when_sending_reset_link_email($url, $isExternal) + public function setUp(): void { + parent::setUp(); + + PasswordReset::resetFormUrl(null); + $this->setSites([ 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], 'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'], 'c' => ['name' => 'C', 'locale' => 'fr_FR', 'url' => '/fr/'], ]); + } + #[Test] + public function it_accepts_encrypted_reset_url_when_sending_reset_link_email() + { $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); - User::make() - ->email('san@holo.com') - ->password('chewy') - ->save(); + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => encrypt('http://this-site.com/some-path'), + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://this-site.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets')); + } - $response = $this->post('/!/auth/password/email', [ + #[Test] + public function it_accepts_unencrypted_relative_reset_url_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => '/some-path', + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + #[DataProvider('externalResetUrlProvider')] + public function it_rejects_unencrypted_external_reset_url_when_sending_reset_link_email($url) + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ 'email' => 'san@holo.com', '_reset_url' => $url, - ]); + ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url. + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + public function it_rejects_unencrypted_absolute_internal_reset_url_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => 'http://this-site.com/some-path', + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + public function it_rejects_unencrypted_relative_reset_url_with_control_characters_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => "/some-path\r\nLocation: https://evil.com", + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + public function it_rejects_reset_url_longer_than_2048_characters_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => '/'.str_repeat('a', 2048), + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } - if ($isExternal) { - $response->assertSessionHasErrors(['_reset_url']); + #[Test] + public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email() + { + // Unencrypted string that doesn't look like a URL is probably a tampered encrypted string. + // It might be a relative url without a leading slash, but we won't treat it as that. - return; - } + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); - $response->assertSessionHasNoErrors(); + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => 'not-an-encrypted-string', + ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url. + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + #[DataProvider('externalResetUrlProvider')] + public function it_rejects_encrypted_external_reset_url_when_sending_reset_link_email($url) + { + // It's weird to point to an external URL, even if you encrypt it yourself. + // This is an additional safeguard. + + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => encrypt($url), + ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url. + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + public static function externalResetUrlProvider() + { + $keyFn = function ($key) { + return is_null($key) ? 'null' : $key; + }; + + return collect(static::externalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url]])->all(); + } + + private function createUser(): void + { + User::make() + ->email('san@holo.com') + ->password('chewy') + ->save(); } #[Test] diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index 731b22d6379..633a7c4c495 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -4,84 +4,102 @@ trait ProvidesExternalUrls { - public static function externalUrlProvider() + private static function internalUrls() { return [ - ['http://this-site.com', false], - ['http://this-site.com?foo', false], - ['http://this-site.com#anchor', false], - ['http://this-site.com/', false], - ['http://this-site.com/?foo', false], - ['http://this-site.com/#anchor', false], + 'http://this-site.com', + 'http://this-site.com?foo', + 'http://this-site.com#anchor', + 'http://this-site.com/', + 'http://this-site.com/?foo', + 'http://this-site.com/#anchor', - ['http://that-site.com', true], - ['http://that-site.com/', true], - ['http://that-site.com/?foo', true], - ['http://that-site.com/#anchor', true], - ['http://that-site.com/some-slug', true], - ['http://that-site.com/some-slug?foo', true], - ['http://that-site.com/some-slug#anchor', true], + 'http://subdomain.this-site.com', + 'http://subdomain.this-site.com/', + 'http://subdomain.this-site.com/?foo', + 'http://subdomain.this-site.com/#anchor', + 'http://subdomain.this-site.com/some-slug', + 'http://subdomain.this-site.com/some-slug?foo', + 'http://subdomain.this-site.com/some-slug#anchor', - ['http://subdomain.this-site.com', false], - ['http://subdomain.this-site.com/', false], - ['http://subdomain.this-site.com/?foo', false], - ['http://subdomain.this-site.com/#anchor', false], - ['http://subdomain.this-site.com/some-slug', false], - ['http://subdomain.this-site.com/some-slug?foo', false], - ['http://subdomain.this-site.com/some-slug#anchor', false], + 'http://absolute-url-resolved-from-request.com', + 'http://absolute-url-resolved-from-request.com/', + 'http://absolute-url-resolved-from-request.com/?foo', + 'http://absolute-url-resolved-from-request.com/?anchor', + 'http://absolute-url-resolved-from-request.com/some-slug', + 'http://absolute-url-resolved-from-request.com/some-slug?foo', + 'http://absolute-url-resolved-from-request.com/some-slug#anchor', - ['http://absolute-url-resolved-from-request.com', false], - ['http://absolute-url-resolved-from-request.com/', false], - ['http://absolute-url-resolved-from-request.com/?foo', false], - ['http://absolute-url-resolved-from-request.com/?anchor', false], - ['http://absolute-url-resolved-from-request.com/some-slug', false], - ['http://absolute-url-resolved-from-request.com/some-slug?foo', false], - ['http://absolute-url-resolved-from-request.com/some-slug#anchor', false], - ['/', false], - ['/?foo', false], - ['/#anchor', false], - ['/some-slug', false], - ['?foo', false], - ['#anchor', false], - ['', false], - [null, false], + '/', + '/?foo', + '/#anchor', + '/some-slug', + '?foo', + '#anchor', + '', + null, + ]; + } + + private static function externalUrls() + { + return [ + 'http://that-site.com', + 'http://that-site.com/', + 'http://that-site.com/?foo', + 'http://that-site.com/#anchor', + 'http://that-site.com/some-slug', + 'http://that-site.com/some-slug?foo', + 'http://that-site.com/some-slug#anchor', // Protocol-relative URLs are external - ['//evil.com', true], - ['//evil.com/', true], - ['//evil.com/path', true], - ['//this-site.com', true], + '//evil.com', + '//evil.com/', + '//evil.com/path', + '//this-site.com', // External domain that starts with a valid domain. - ['http://this-site.com.au', true], - ['http://this-site.com.au/', true], - ['http://this-site.com.au/?foo', true], - ['http://this-site.com.au/#anchor', true], - ['http://this-site.com.au/some-slug', true], - ['http://this-site.com.au/some-slug?foo', true], - ['http://this-site.com.au/some-slug#anchor', true], - ['http://subdomain.this-site.com.au', true], - ['http://subdomain.this-site.com.au/', true], - ['http://subdomain.this-site.com.au/?foo', true], - ['http://subdomain.this-site.com.au/#anchor', true], - ['http://subdomain.this-site.com.au/some-slug', true], - ['http://subdomain.this-site.com.au/some-slug?foo', true], - ['http://subdomain.this-site.com.au/some-slug#anchor', true], + 'http://this-site.com.au', + 'http://this-site.com.au/', + 'http://this-site.com.au/?foo', + 'http://this-site.com.au/#anchor', + 'http://this-site.com.au/some-slug', + 'http://this-site.com.au/some-slug?foo', + 'http://this-site.com.au/some-slug#anchor', + 'http://subdomain.this-site.com.au', + 'http://subdomain.this-site.com.au/', + 'http://subdomain.this-site.com.au/?foo', + 'http://subdomain.this-site.com.au/#anchor', + 'http://subdomain.this-site.com.au/some-slug', + 'http://subdomain.this-site.com.au/some-slug?foo', + 'http://subdomain.this-site.com.au/some-slug#anchor', // Credential injection - ['http://this-site.com@evil.com', true], - ['http://this-site.com@evil.com/', true], - ['http://this-site.com@evil.com/path', true], - ['http://this-site.com@evil.com/path?query', true], - ['http://this-site.com:password@evil.com', true], - ['http://user:pass@evil.com', true], - ['http://absolute-url-resolved-from-request.com@evil.com', true], - ['http://absolute-url-resolved-from-request.com@evil.com/path', true], - ['http://subdomain.this-site.com@evil.com', true], - ['http://subdomain.this-site.com@evil.com/path', true], - ['http://this-site.com:8000@evil.com', true], - ['http://this-site.com:8000@evil.com/path', true], - ['http://this-site.com:8000@webhook.site/token', true], + 'http://this-site.com@evil.com', + 'http://this-site.com@evil.com/', + 'http://this-site.com@evil.com/path', + 'http://this-site.com@evil.com/path?query', + 'http://this-site.com:password@evil.com', + 'http://user:pass@evil.com', + 'http://absolute-url-resolved-from-request.com@evil.com', + 'http://absolute-url-resolved-from-request.com@evil.com/path', + 'http://subdomain.this-site.com@evil.com', + 'http://subdomain.this-site.com@evil.com/path', + 'http://this-site.com:8000@evil.com', + 'http://this-site.com:8000@evil.com/path', + 'http://this-site.com:8000@webhook.site/token', + ]; + } + + public static function externalUrlProvider() + { + $keyFn = function ($key) { + return is_null($key) ? 'null' : $key; + }; + + return [ + ...collect(static::internalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url, false]])->all(), + ...collect(static::externalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url, true]])->all(), ]; } } diff --git a/tests/Tags/User/ForgotPasswordFormTest.php b/tests/Tags/User/ForgotPasswordFormTest.php index 04cc9eaf92f..f2c7e1f282c 100644 --- a/tests/Tags/User/ForgotPasswordFormTest.php +++ b/tests/Tags/User/ForgotPasswordFormTest.php @@ -3,6 +3,7 @@ namespace Tests\Tags\User; use Illuminate\Support\Facades\Password; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Parse; use Statamic\Facades\User; @@ -32,12 +33,39 @@ public function it_renders_form() #[Test] public function it_renders_form_with_params() { - $output = $this->tag('{{ user:forgot_password_form redirect="/submitted" error_redirect="/errors" reset_url="/resetting" class="form" id="form" }}{{ /user:forgot_password_form }}'); + $output = $this->tag('{{ user:forgot_password_form redirect="/submitted" error_redirect="/errors" class="form" id="form" }}{{ /user:forgot_password_form }}'); $this->assertStringStartsWith('
', $output); $this->assertStringContainsString('', $output); $this->assertStringContainsString('', $output); - $this->assertStringContainsString('', $output); + } + + #[Test] + #[DataProvider('resetUrlProvider')] + public function it_renders_reset_url($resetUrl, $expectedUrl) + { + $output = $this->tag('{{ user:forgot_password_form reset_url="'.$resetUrl.'" }}{{ /user:forgot_password_form }}'); + + $this->assertMatchesRegularExpression('//', $output); + preg_match('//', $output, $matches); + $this->assertEquals($expectedUrl, decrypt($matches[1])); + } + + public static function resetUrlProvider() + { + return [ + '/custom' => ['/custom', '/custom'], + 'custom' => ['custom', '/custom'], + 'absolute' => ['https://example.com/custom', 'https://example.com/custom'], + ]; + } + + #[Test] + public function it_renders_null_reset_url() + { + $output = $this->tag('{{ user:forgot_password_form :reset_url="null" }}{{ /user:forgot_password_form }}'); + + $this->assertStringNotContainsString('_reset_url', $output); } #[Test] From ea0fa7fc3a0d6032d43b1d992b860667d678e13b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 18:21:06 -0400 Subject: [PATCH 11/37] changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c215c1284a..a0fe028c26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Release Notes +## 5.73.15 (2026-03-18) + +### What's fixed +- Harden password reset [#14296](https://github.com/statamic/cms/issues/14296) by @jasonvarga +- Add additional `URL::isExternalToApplication()` tests [#14288](https://github.com/statamic/cms/issues/14288) by @duncanmcclean + + + ## 5.73.14 (2026-03-17) ### What's fixed From a0aea71a20f7af9e123cc98627c9dbcc24dfdd48 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 20 Mar 2026 01:04:37 +0100 Subject: [PATCH 12/37] [5.x] Fix PHP sanitization edge cases (#14300) Co-authored-by: Jason Varga --- .../Language/Utilities/StringUtilities.php | 13 ++++--------- tests/Antlers/Runtime/PhpEnabledTest.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/View/Antlers/Language/Utilities/StringUtilities.php b/src/View/Antlers/Language/Utilities/StringUtilities.php index 997b17182ac..f114e64344e 100644 --- a/src/View/Antlers/Language/Utilities/StringUtilities.php +++ b/src/View/Antlers/Language/Utilities/StringUtilities.php @@ -79,15 +79,10 @@ public static function containsSymbolicCharacters($text) */ public static function sanitizePhp($text) { - $text = str_replace('assertSame('<?php echo "test"; ?>', StringUtilities::sanitizePhp('')); + $this->assertSame('<?PHP echo "test"; ?>', StringUtilities::sanitizePhp('')); + $this->assertSame('<?Php echo "test"; ?>', StringUtilities::sanitizePhp('')); + $this->assertSame('<?pHp echo "test"; ?>', StringUtilities::sanitizePhp('')); + } + + public function test_sanitize_php_handles_short_tags() + { + $this->assertSame('<?= $var ?>', StringUtilities::sanitizePhp('')); + $this->assertSame('<?="test"?>', StringUtilities::sanitizePhp('')); + $this->assertSame("<? echo 'test' ?>", StringUtilities::sanitizePhp("")); + } } From 90a6e0b21621e94b593e634bf7acd94dc54b4ba0 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 20 Mar 2026 12:27:28 -0400 Subject: [PATCH 13/37] [5.x] Fix live preview token scope (#14304) --- .../Protectors/Password/PasswordProtector.php | 2 +- src/GraphQL/Queries/EntryQuery.php | 4 ++ src/Http/Controllers/API/ApiController.php | 2 +- src/Http/Responses/DataResponse.php | 9 +++- src/Providers/AppServiceProvider.php | 12 ++++++ src/Tokens/FileTokenRepository.php | 10 ++++- tests/API/APITest.php | 15 +++++++ tests/Auth/Protect/PasswordProtectionTest.php | 43 +++++++++++++++++++ tests/Feature/GraphQL/EntryTest.php | 37 ++++++++++++++++ tests/FrontendTest.php | 15 +++++++ tests/Tokens/TokenRepositoryTest.php | 30 +++++++++---- 11 files changed, 166 insertions(+), 13 deletions(-) diff --git a/src/Auth/Protect/Protectors/Password/PasswordProtector.php b/src/Auth/Protect/Protectors/Password/PasswordProtector.php index e137cf4db32..9c9ec87ea84 100644 --- a/src/Auth/Protect/Protectors/Password/PasswordProtector.php +++ b/src/Auth/Protect/Protectors/Password/PasswordProtector.php @@ -20,7 +20,7 @@ public function protect() throw new ForbiddenHttpException(); } - if (request()->isLivePreview()) { + if (request()->isLivePreviewOf($this->data)) { return; } diff --git a/src/GraphQL/Queries/EntryQuery.php b/src/GraphQL/Queries/EntryQuery.php index bb6f2d0577b..c1938723344 100644 --- a/src/GraphQL/Queries/EntryQuery.php +++ b/src/GraphQL/Queries/EntryQuery.php @@ -75,6 +75,10 @@ public function resolve($root, $args) $entry = $query->limit(1)->get()->first(); + if ($entry && $entry->status() !== 'published' && request()->isLivePreview() && ! request()->isLivePreviewOf($entry)) { + return null; + } + // The `AuthorizeSubResources` middleware will authorize when using `collection` arg, // but this is still required when the user queries entry using other args. if ($entry && ! in_array($collection = $entry->collection()->handle(), $this->allowedSubResources())) { diff --git a/src/Http/Controllers/API/ApiController.php b/src/Http/Controllers/API/ApiController.php index dfd61b05fab..b4d85a0963d 100644 --- a/src/Http/Controllers/API/ApiController.php +++ b/src/Http/Controllers/API/ApiController.php @@ -27,7 +27,7 @@ class ApiController extends Controller */ protected function abortIfUnpublished($item) { - if (request()->isLivePreview()) { + if (request()->isLivePreviewOf($item)) { return; } diff --git a/src/Http/Responses/DataResponse.php b/src/Http/Responses/DataResponse.php index 83e1e8bc39d..7ae6885df3e 100644 --- a/src/Http/Responses/DataResponse.php +++ b/src/Http/Responses/DataResponse.php @@ -112,7 +112,7 @@ protected function handleDraft() return $this; } - throw_unless($this->request->isLivePreview(), new NotFoundHttpException); + throw_unless($this->isLivePreviewing(), new NotFoundHttpException); $this->headers['X-Statamic-Draft'] = true; @@ -129,13 +129,18 @@ protected function handlePrivateEntries() return $this; } - throw_unless($this->request->isLivePreview(), new NotFoundHttpException); + throw_unless($this->isLivePreviewing(), new NotFoundHttpException); $this->headers['X-Statamic-Private'] = true; return $this; } + private function isLivePreviewing() + { + return $this->request->isLivePreviewOf($this->data); + } + protected function view() { return app(View::class) diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index a543a475cd1..2069cff5ecc 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -96,6 +96,18 @@ public function boot() return optional($this->statamicToken())->handler() === LivePreview::class; }); + Request::macro('isLivePreviewOf', function ($item) { + $token = $this->statamicToken(); + + if (! $token || $token->handler() !== LivePreview::class) { + return false; + } + + $previewItem = \Facades\Statamic\CP\LivePreview::item($token); + + return $item && $previewItem && method_exists($item, 'reference') && $previewItem->reference() === $item->reference(); + }); + TrimStrings::skipWhen(function (Request $request) { $route = config('statamic.cp.route'); diff --git a/src/Tokens/FileTokenRepository.php b/src/Tokens/FileTokenRepository.php index 94a9caee9d9..044112fba8f 100644 --- a/src/Tokens/FileTokenRepository.php +++ b/src/Tokens/FileTokenRepository.php @@ -22,7 +22,15 @@ public function find(string $token): ?TokenContract return null; } - return $this->makeFromPath($path); + $token = $this->makeFromPath($path); + + if ($token->hasExpired()) { + $this->delete($token); + + return null; + } + + return $token; } public function save(TokenContract $token): bool diff --git a/tests/API/APITest.php b/tests/API/APITest.php index d9cb6813f24..64737f01e4a 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -501,6 +501,21 @@ public function non_live_preview_tokens_doesnt_bypass_entry_status_check() ]); } + #[Test] + public function live_preview_token_for_different_entry_doesnt_bypass_status_check() + { + Facades\Config::set('statamic.api.resources.collections', true); + Facades\Collection::make('pages')->save(); + tap(Facades\Entry::make()->collection('pages')->id('dance')->published(false)->set('title', 'Dance')->slug('dance'))->save(); + $otherEntry = tap(Facades\Entry::make()->collection('pages')->id('sing')->published(true)->set('title', 'Sing')->slug('sing'))->save(); + + LivePreview::tokenize('test-token', $otherEntry); + + $this->get('/api/collections/pages/entries/dance?token=test-token')->assertJson([ + 'message' => 'Not found.', + ]); + } + #[Test] public function it_replaces_terms_using_live_preview_token() { diff --git a/tests/Auth/Protect/PasswordProtectionTest.php b/tests/Auth/Protect/PasswordProtectionTest.php index 9d998e05cdd..1efda28c8cf 100644 --- a/tests/Auth/Protect/PasswordProtectionTest.php +++ b/tests/Auth/Protect/PasswordProtectionTest.php @@ -3,8 +3,11 @@ namespace Tests\Auth\Protect; use Facades\Statamic\Auth\Protect\Protectors\Password\Token; +use Facades\Statamic\CP\LivePreview; +use Facades\Tests\Factories\EntryFactory; use Illuminate\Support\Facades\Route; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Entry; class PasswordProtectionTest extends PageProtectionTestCase { @@ -124,4 +127,44 @@ public function custom_password_form_url_is_unprotected() ->assertOk() ->assertSee('Password form template'); } + + #[Test] + public function live_preview_token_bypasses_password_protection() + { + config(['statamic.protect.schemes.password-scheme' => [ + 'driver' => 'password', + 'allowed' => ['test'], + ]]); + + $this->createPage('test', ['data' => ['protect' => 'password-scheme']]); + + $entry = Entry::find('test'); + + LivePreview::tokenize('test-token', $entry); + + $this + ->get('/test?token=test-token') + ->assertOk(); + } + + #[Test] + public function live_preview_token_for_different_entry_doesnt_bypass_password_protection() + { + config(['statamic.protect.schemes.password-scheme' => [ + 'driver' => 'password', + 'allowed' => ['test'], + ]]); + + $this->createPage('test', ['data' => ['protect' => 'password-scheme']]); + + $other = EntryFactory::slug('other')->id('other')->collection('pages')->create(); + + LivePreview::tokenize('test-token', $other); + + Token::shouldReceive('generate')->andReturn('pw-token'); + + $this + ->get('/test?token=test-token') + ->assertRedirect(); + } } diff --git a/tests/Feature/GraphQL/EntryTest.php b/tests/Feature/GraphQL/EntryTest.php index e54dacdb72d..98787b29cb5 100644 --- a/tests/Feature/GraphQL/EntryTest.php +++ b/tests/Feature/GraphQL/EntryTest.php @@ -796,4 +796,41 @@ public function it_only_shows_unpublished_entries_with_token() 'title' => 'That was so rad!', ]]]); } + + #[Test] + public function it_does_not_show_unpublished_entries_with_token_for_different_entry() + { + FilterAuthorizer::shouldReceive('allowedForSubResources') + ->andReturn(['published', 'status']); + + EntryFactory::collection('blog') + ->id('6') + ->slug('that-was-so-rad') + ->data(['title' => 'That was so rad!']) + ->published(false) + ->create(); + + $other = EntryFactory::collection('blog') + ->id('7') + ->slug('other') + ->data(['title' => 'Other']) + ->create(); + + LivePreview::tokenize('test-token', $other); + + $query = <<<'GQL' +{ + entry(id: "6") { + id + title + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql?token=test-token', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => ['entry' => null]]); + } } diff --git a/tests/FrontendTest.php b/tests/FrontendTest.php index 15d2229e344..fb5184b5b45 100644 --- a/tests/FrontendTest.php +++ b/tests/FrontendTest.php @@ -254,6 +254,21 @@ public function drafts_are_visible_if_using_live_preview() $this->assertEquals('Testing 123', $response->content()); } + #[Test] + public function drafts_are_not_visible_if_using_live_preview_token_for_different_entry() + { + $this->withStandardFakeErrorViews(); + + $page = tap($this->createPage('about')->published(false)->set('content', 'Testing 123'))->save(); + $other = $this->createPage('other'); + + LivePreview::tokenize('test-token', $other); + + $this + ->get('/about?token=test-token') + ->assertStatus(404); + } + #[Test] public function drafts_dont_get_statically_cached() { diff --git a/tests/Tokens/TokenRepositoryTest.php b/tests/Tokens/TokenRepositoryTest.php index 1ad49558458..7ad1172bae5 100644 --- a/tests/Tokens/TokenRepositoryTest.php +++ b/tests/Tokens/TokenRepositoryTest.php @@ -87,6 +87,8 @@ public function it_deletes_a_token() #[Test] public function it_finds_a_token() { + Carbon::setTestNow(Carbon::create(2020, 1, 1, 0, 0, 0)); + $contents = <<assertTrue($token->expiry()->eq(Carbon::create(2020, 1, 1, 3, 35))); } + #[Test] + public function it_returns_null_and_deletes_expired_token_on_find() + { + Carbon::setTestNow(Carbon::create(2020, 1, 1, 3, 0, 0)); + + $this->tokens->make('expired-token', 'test')->expireAt(Carbon::now()->subMinute())->save(); + + $this->assertFileExists(storage_path('statamic/tokens/expired-token.yaml')); + $this->assertNull($this->tokens->find('expired-token')); + $this->assertFileDoesNotExist(storage_path('statamic/tokens/expired-token.yaml')); + } + #[Test] public function attempting_to_find_a_non_existent_token_returns_null() { @@ -125,16 +139,16 @@ public function it_deletes_expired_tokens() $this->tokens->make('c', 'test')->expireAt(Carbon::now()->subHour())->save(); $this->tokens->make('d', 'test')->expireAt(Carbon::now()->addMinute())->save(); - $this->assertNotNull($this->tokens->find('a')); - $this->assertNotNull($this->tokens->find('b')); - $this->assertNotNull($this->tokens->find('c')); - $this->assertNotNull($this->tokens->find('d')); + $this->assertFileExists(storage_path('statamic/tokens/a.yaml')); + $this->assertFileExists(storage_path('statamic/tokens/b.yaml')); + $this->assertFileExists(storage_path('statamic/tokens/c.yaml')); + $this->assertFileExists(storage_path('statamic/tokens/d.yaml')); $this->tokens->collectGarbage(); - $this->assertNotNull($this->tokens->find('a')); - $this->assertNull($this->tokens->find('b')); - $this->assertNull($this->tokens->find('c')); - $this->assertNotNull($this->tokens->find('d')); + $this->assertFileExists(storage_path('statamic/tokens/a.yaml')); + $this->assertFileDoesNotExist(storage_path('statamic/tokens/b.yaml')); + $this->assertFileDoesNotExist(storage_path('statamic/tokens/c.yaml')); + $this->assertFileExists(storage_path('statamic/tokens/d.yaml')); } } From 5fe9103a2bba123d3d9df8f3e18bc1b747425819 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 20 Mar 2026 14:22:26 -0400 Subject: [PATCH 14/37] [5.x] Handle more cases in external url detection (#14312) Co-authored-by: Duncan McClean --- src/Facades/Endpoint/URL.php | 54 ++++++++++++++----- .../Facades/Concerns/ProvidesExternalUrls.php | 46 ++++++++++++++++ tests/Facades/UrlTest.php | 12 +++++ 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 34d0c7c3edb..a3958b3a169 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -247,25 +247,53 @@ public function isExternal($url) /** * Check whether a URL is external to whole Statamic application. + * Ambiguous URLs are considered external. */ public function isExternalToApplication(?string $url): bool { - if (Str::startsWith($url, '//')) { + if ($url === null || $url === '') { + return false; + } + + $urlLower = strtolower($url); + + if (Str::startsWith($urlLower, '//')) { return true; } - $urlDomain = parse_url($url, PHP_URL_HOST); - $currentRequestDomain = parse_url(url()->to('/'), PHP_URL_HOST); - - return $urlDomain - ? Site::all() - ->map(fn ($site) => parse_url($site->absoluteUrl(), PHP_URL_HOST)) - ->push($currentRequestDomain) - ->filter(fn ($siteDomain) => ! is_null($siteDomain)) - ->unique() - ->filter(fn ($siteDomain) => $siteDomain === $urlDomain) - ->isEmpty() - : false; + if (Str::startsWith($urlLower, ['/', '#', '?'])) { + return false; + } + + if (! Str::startsWith($urlLower, ['http://', 'https://'])) { + return true; + } + + // Normalize backslashes to forward slashes. + // Browsers treat \ as / for special schemes (http/https), which can + // cause parse_url() to extract a different host than the browser uses. + $url = str_replace('\\', '/', $url); + $url = preg_replace('/%5c/i', '/', $url); + + // If we can't extract a host from an absolute http(s) URL, treat it as external. + // Non-http(s) and relative URLs are handled by the guards above. + if (! $urlDomain = parse_url($url, PHP_URL_HOST)) { + return true; + } + + $sites = Site::all(); + $siteDomains = $sites->map(fn ($site) => parse_url($site->absoluteUrl(), PHP_URL_HOST)); + + if ($sites->contains(fn ($site) => Str::startsWith((string) $site->url(), '/'))) { + $currentRequestDomain = parse_url(url()->to('/'), PHP_URL_HOST); + $siteDomains->push($currentRequestDomain); + } + + return $siteDomains + ->filter(fn ($siteDomain) => ! is_null($siteDomain)) + ->unique() + ->filter(fn ($siteDomain) => $siteDomain === $urlDomain) + ->isEmpty(); } public function clearExternalUrlCache() diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index 633a7c4c495..4c0745c24d0 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -88,6 +88,52 @@ private static function externalUrls() 'http://this-site.com:8000@evil.com', 'http://this-site.com:8000@evil.com/path', 'http://this-site.com:8000@webhook.site/token', + + // Backslash bypass + 'http://evil.com\@this-site.com', + 'http://evil.com\@this-site.com/', + 'http://evil.com\@this-site.com/path', + 'http://evil.com\@subdomain.this-site.com', + 'http://evil.com\@absolute-url-resolved-from-request.com', + 'https://evil.com\@this-site.com', + 'http://evil.com\\@this-site.com', + 'http://evil.com\\\@this-site.com', + + // Percent-encoded backslash bypass + 'http://evil.com%5c@this-site.com', + 'http://evil.com%5c@this-site.com/', + 'http://evil.com%5c@this-site.com/path', + 'http://evil.com%5c@subdomain.this-site.com', + 'http://evil.com%5c@absolute-url-resolved-from-request.com', + 'https://evil.com%5C@this-site.com', + + // Absolute-looking URL with no host (parse_url() returns false) + 'http:///path', + + // Percent-encoded whitespace bypass + '%20http://evil.com', + '%09http://evil.com', + '%0ahttp://evil.com', + + // Dangerous URL schemes + 'javascript:alert(1)', + 'javascript:alert(document.cookie)', + 'javascript://this-site.com/%0aalert(1)', + 'JAVASCRIPT:alert(1)', + 'data:text/html,', + 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==', + 'DATA:text/html,test', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + + // Whitespace bypass + ' http://this-site.com', + ' http://evil.com', + ' http://evil.com', + "\thttp://evil.com", + "\nhttp://evil.com", + "\rhttp://evil.com", + "\r\nhttp://evil.com", ]; } diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 58201545c5f..93354c678a7 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -94,6 +94,18 @@ public function it_determines_if_external_url_to_application($url, $expected) $this->assertEquals($expected, URL::isExternalToApplication($url)); } + #[Test] + public function it_does_not_trust_current_request_domain_when_no_sites_are_relative() + { + $this->setSites([ + 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + 'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'], + ]); + + $this->assertTrue(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/')); + $this->assertFalse(URL::isExternalToApplication('http://this-site.com/')); + } + #[Test] #[DataProvider('ancestorProvider')] public function it_checks_whether_a_url_is_an_ancestor_of_another($child, $parent, $isAncestor) From 828bbf9e5da901989c9484dd0b6b0b383fd6c2ef Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sun, 22 Mar 2026 10:47:40 -0400 Subject: [PATCH 15/37] [5.x] Allow external redirects from Form::getSubmissionRedirect (#14318) --- src/Http/Controllers/FormController.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php index 5913d199906..9de616b57e9 100644 --- a/src/Http/Controllers/FormController.php +++ b/src/Http/Controllers/FormController.php @@ -154,9 +154,7 @@ private function formSuccess($params, $submission, $silentFailure = false) ]); } - $response = $redirect && ! \Statamic\Facades\URL::isExternalToApplication($redirect) - ? redirect($redirect) - : back(); + $response = $redirect ? redirect($redirect) : back(); if (! \Statamic\Facades\URL::isExternal($redirect)) { session()->flash("form.{$submission->form()->handle()}.success", __('Submission successful.')); @@ -169,8 +167,14 @@ private function formSuccess($params, $submission, $silentFailure = false) private function formSuccessRedirect($params, $submission) { - if (! $redirect = Form::getSubmissionRedirect($submission)) { - $redirect = Arr::get($params, '_redirect'); + if ($redirect = Form::getSubmissionRedirect($submission)) { + return $redirect; + } + + $redirect = Arr::get($params, '_redirect'); + + if ($redirect && \Statamic\Facades\URL::isExternalToApplication($redirect)) { + return null; } return $redirect; From b193c40fe6f9c23430be7f2ecdfb1b827c814c7b Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 23 Mar 2026 18:20:46 +0100 Subject: [PATCH 16/37] [5.x] Relationship fieldtype authorization tweaks (#14307) Co-authored-by: Jason Varga --- src/Fieldtypes/Entries.php | 37 +++++------ src/Fieldtypes/Terms.php | 46 +++++++------ src/Query/Scopes/Filters/Collection.php | 25 ++++++- .../Fieldtypes/RelationshipFieldtypeTest.php | 66 +++++++++++++++---- 4 files changed, 113 insertions(+), 61 deletions(-) diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 293c2b4c588..c2196dcf480 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -134,15 +134,23 @@ protected function configFieldItems(): array public function getIndexItems($request) { $configuredCollections = $this->getConfiguredCollections(); - $requestedCollections = $this->getRequestedCollections($request, $configuredCollections); - $this->authorizeCollectionAccess($requestedCollections); + $this->authorizeCollectionAccess($configuredCollections); $query = $this->getIndexQuery($request); $filters = $request->filters; if (! isset($filters['collection'])) { - $query->whereIn('collection', $configuredCollections); + $user = User::current(); + + $query->whereIn( + 'collection', + collect($configuredCollections) + ->map(fn (string $collectionHandle) => Collection::findByHandle($collectionHandle)) + ->filter(fn ($collection) => $collection && $user->can('view', $collection)) + ->map->handle() + ->all() + ); } if ($blueprints = $this->config('blueprints')) { @@ -162,28 +170,15 @@ public function getIndexItems($request) return $paginate ? $results->setCollection($items) : $items; } - private function getRequestedCollections($request, $configuredCollections) - { - $filteredCollections = collect($request->input('filters.collection.collections', [])) - ->filter() - ->values() - ->all(); - - return empty($filteredCollections) ? $configuredCollections : $filteredCollections; - } - - private function authorizeCollectionAccess($collections) + private function authorizeCollectionAccess(array $collections): void { $user = User::current(); - collect($collections)->each(function ($collectionHandle) use ($user) { - $collection = Collection::findByHandle($collectionHandle); + $authorizedCollections = collect($collections) + ->map(fn (string $collectionHandle) => Collection::findByHandle($collectionHandle)) + ->filter(fn ($collection) => $collection && $user->can('view', $collection)); - throw_if( - ! $collection || ! $user->can('view', $collection), - new AuthorizationException - ); - }); + throw_if($authorizedCollections->isEmpty(), new AuthorizationException); } public function getResourceCollection($request, $items) diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index 3408672388c..424675af64d 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -263,9 +263,7 @@ public function getIndexItems($request) return collect(); } - $this->authorizeTaxonomyAccess( - $this->getRequestedTaxonomies($request, $this->getConfiguredTaxonomies()) - ); + $this->authorizeTaxonomyAccess($this->getConfiguredTaxonomies()); $query = $this->getIndexQuery($request); @@ -276,25 +274,16 @@ public function getIndexItems($request) return $request->boolean('paginate', true) ? $query->paginate() : $query->get(); } - private function getRequestedTaxonomies($request, $configuredTaxonomies) - { - $requestedTaxonomies = collect($request->taxonomies)->filter()->values()->all(); - - return empty($requestedTaxonomies) ? $configuredTaxonomies : $requestedTaxonomies; - } - - private function authorizeTaxonomyAccess($taxonomies) + private function authorizeTaxonomyAccess(array $taxonomies): void { $user = User::current(); - collect($taxonomies)->each(function ($taxonomyHandle) use ($user) { - $taxonomy = Taxonomy::findByHandle($taxonomyHandle); + $authorizedTaxonomies = collect($taxonomies) + ->map(fn (string $taxonomyHandle) => Taxonomy::findByHandle($taxonomyHandle)) + ->filter() + ->filter(fn ($taxonomy) => $user->can('view', $taxonomy)); - throw_if( - ! $taxonomy || ! $user->can('view', $taxonomy), - new AuthorizationException - ); - }); + throw_if($authorizedTaxonomies->isEmpty(), new AuthorizationException); } public function getResourceCollection($request, $items) @@ -311,9 +300,13 @@ protected function getBlueprint($request) protected function getFirstTaxonomyFromRequest($request) { - return $request->taxonomies - ? Facades\Taxonomy::findByHandle($request->taxonomies[0]) - : Facades\Taxonomy::all()->first(); + $taxonomies = $this->getConfiguredTaxonomies(); + + $taxonomy = Taxonomy::findByHandle($taxonomyHandle = Arr::first($taxonomies)); + + throw_if(! $taxonomy, new TaxonomyNotFoundException($taxonomyHandle)); + + return $taxonomy; } public function getSortColumn($request) @@ -434,10 +427,15 @@ protected function getColumns() protected function getIndexQuery($request) { $query = Term::query(); + $user = User::current(); - if ($taxonomies = $request->taxonomies) { - $query->whereIn('taxonomy', $taxonomies); - } + $taxonomies = collect($this->getConfiguredTaxonomies()) + ->map(fn (string $taxonomyHandle) => Taxonomy::findByHandle($taxonomyHandle)) + ->filter(fn ($taxonomy) => $taxonomy && $user->can('view', $taxonomy)) + ->map->handle() + ->all(); + + $query->whereIn('taxonomy', $taxonomies); if ($search = $request->search) { $query->where('title', 'like', '%'.$search.'%'); diff --git a/src/Query/Scopes/Filters/Collection.php b/src/Query/Scopes/Filters/Collection.php index ddfa4cf9b04..169b1a58c75 100644 --- a/src/Query/Scopes/Filters/Collection.php +++ b/src/Query/Scopes/Filters/Collection.php @@ -2,7 +2,9 @@ namespace Statamic\Query\Scopes\Filters; +use Statamic\Exceptions\AuthorizationException; use Statamic\Facades; +use Statamic\Facades\User; use Statamic\Query\Scopes\Filter; class Collection extends Filter @@ -29,6 +31,8 @@ public function fieldItems() public function apply($query, $values) { + $this->authorizeCollectionAccess($values['collections']); + $query->whereIn('collection', $values['collections']); } @@ -44,8 +48,25 @@ public function visibleTo($key) protected function options() { - return collect($this->context['collections'])->mapWithKeys(function ($collection) { - return [$collection => Facades\Collection::findByHandle($collection)->title()]; + $user = User::current(); + + return collect($this->context['collections']) + ->map(fn ($collection) => Facades\Collection::findByHandle($collection)) + ->filter(fn ($collection) => $collection && $user->can('view', $collection)) + ->mapWithKeys(fn ($collection) => [$collection->handle() => $collection->title()]); + } + + private function authorizeCollectionAccess(array $collections): void + { + $user = User::current(); + + collect($collections)->each(function (string $collectionHandle) use ($user) { + $collection = Facades\Collection::findByHandle($collectionHandle); + + throw_if( + ! $collection || ! $user->can('view', $collection), + new AuthorizationException + ); }); } } diff --git a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php index 3d9504855c7..3bb3c04d1a4 100644 --- a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php +++ b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php @@ -61,8 +61,40 @@ public function it_filters_entries_by_query_scopes() } #[Test] - public function it_denies_access_to_entries_when_theres_a_collection_the_user_cannot_view() + public function it_limits_access_to_entries_from_collections_the_user_can_view() { + Collection::make('pages')->save(); + Entry::make()->collection('pages')->slug('home')->data(['title' => 'Home'])->save(); + + Collection::make('secret')->save(); + Entry::make()->collection('secret')->slug('secret-one')->data(['title' => 'Secret One'])->save(); + + $this->setTestRoles(['test' => ['access cp', 'view pages entries']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode([ + 'type' => 'entries', + 'collections' => ['pages', 'secret'], + ])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJson([ + 'data' => [ + ['slug' => 'home'], + ], + ]); + } + + #[Test] + public function it_denies_access_to_entries_when_user_cannot_view_any_of_the_collections() + { + Collection::make('pages')->save(); + Entry::make()->collection('pages')->slug('home')->data(['title' => 'Home'])->save(); + Collection::make('secret')->save(); Entry::make()->collection('secret')->slug('secret-one')->data(['title' => 'Secret One'])->save(); @@ -71,7 +103,7 @@ public function it_denies_access_to_entries_when_theres_a_collection_the_user_ca $config = base64_encode(json_encode([ 'type' => 'entries', - 'collections' => ['secret'], + 'collections' => ['pages', 'secret'], ])); $this @@ -81,7 +113,7 @@ public function it_denies_access_to_entries_when_theres_a_collection_the_user_ca } #[Test] - public function it_forbids_access_to_entries_when_filters_target_a_collection_the_user_cannot_view() + public function it_forbids_access_to_entries_when_filters_target_collections_the_user_cannot_view() { Collection::make('secret')->save(); Entry::make()->collection('test')->slug('apple')->data(['title' => 'Apple'])->save(); @@ -107,46 +139,52 @@ public function it_forbids_access_to_entries_when_filters_target_a_collection_th } #[Test] - public function it_forbids_access_to_terms_when_config_contains_a_taxonomy_the_user_cannot_view() + public function it_limits_access_to_terms_from_taxonomies_the_user_can_view() { + Taxonomy::make('topics')->save(); Taxonomy::make('secret')->save(); + Term::make('public')->taxonomy('topics')->data([])->save(); Term::make('internal')->taxonomy('secret')->data([])->save(); - $this->setTestRoles(['test' => ['access cp']]); + $this->setTestRoles(['test' => ['access cp', 'view topics terms']]); $user = User::make()->assignRole('test')->save(); $config = base64_encode(json_encode([ 'type' => 'terms', - 'taxonomies' => ['secret'], + 'taxonomies' => ['topics', 'secret'], ])); $this ->actingAs($user) - ->getJson("/cp/fieldtypes/relationship?config={$config}&taxonomies[0]=secret") - ->assertForbidden(); + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJson([ + 'data' => [ + ['slug' => 'public'], + ], + ]); } #[Test] - public function it_forbids_access_to_terms_when_requested_taxonomy_is_forbidden() + public function it_forbids_access_to_terms_when_the_user_cannot_view_any_of_the_taxonomies() { Taxonomy::make('topics')->save(); Taxonomy::make('secret')->save(); Term::make('public')->taxonomy('topics')->data([])->save(); Term::make('internal')->taxonomy('secret')->data([])->save(); - $this->setTestRoles([ - 'test' => ['access cp', 'view topics terms'], - ]); + $this->setTestRoles(['test' => ['access cp']]); $user = User::make()->assignRole('test')->save(); $config = base64_encode(json_encode([ 'type' => 'terms', - 'taxonomies' => ['topics'], + 'taxonomies' => ['topics', 'secret'], ])); $this ->actingAs($user) - ->getJson("/cp/fieldtypes/relationship?config={$config}&taxonomies[0]=secret") + ->getJson("/cp/fieldtypes/relationship?config={$config}") ->assertForbidden(); } } From 3eaa80c9296201943536f8bb732d4bd7cbd84a62 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 23 Mar 2026 17:57:32 -0400 Subject: [PATCH 17/37] [5.x] Add CSP header to svg route (#14325) --- src/Http/Controllers/CP/Assets/SvgController.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/CP/Assets/SvgController.php b/src/Http/Controllers/CP/Assets/SvgController.php index f1a714bee63..5c307d48c8c 100644 --- a/src/Http/Controllers/CP/Assets/SvgController.php +++ b/src/Http/Controllers/CP/Assets/SvgController.php @@ -21,7 +21,9 @@ public function show($asset) $this->authorize('view', $asset); - return response($contents)->header('Content-Type', 'image/svg+xml'); + return response($contents) + ->header('Content-Type', 'image/svg+xml') + ->header('Content-Security-Policy', "script-src 'none'"); } /** From 33e0ceb98496f517e6abaeef54f6c903446aa7ba Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 23 Mar 2026 23:11:21 +0100 Subject: [PATCH 18/37] [5.x] Add authorization to revision routes (#14301) Co-authored-by: Jason Varga --- resources/js/components/terms/PublishForm.vue | 39 ----- routes/cp.php | 8 - .../Collections/EntryRevisionsController.php | 6 + .../RestoreTermRevisionController.php | 26 --- .../CP/Taxonomies/TermRevisionsController.php | 118 ------------- tests/Feature/Entries/EntryRevisionsTest.php | 158 +++++++++++++++++- 6 files changed, 163 insertions(+), 192 deletions(-) delete mode 100644 src/Http/Controllers/CP/Taxonomies/RestoreTermRevisionController.php delete mode 100644 src/Http/Controllers/CP/Taxonomies/TermRevisionsController.php diff --git a/resources/js/components/terms/PublishForm.vue b/resources/js/components/terms/PublishForm.vue index 9adebbd8013..81208100f4b 100644 --- a/resources/js/components/terms/PublishForm.vue +++ b/resources/js/components/terms/PublishForm.vue @@ -131,45 +131,6 @@ - -