Skip to content

Commit a68c119

Browse files
jasonvargaclaude
andcommitted
[5.x] Don't expose unviewable collection columns via relationship preload
Relationship preload derived listing columns from the configured collection's blueprint without authorizing it, exposing column metadata via the field-meta endpoint. Columns are now derived only from collections the user can view, falling back to the default columns otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0abc3f2 commit a68c119

2 files changed

Lines changed: 80 additions & 12 deletions

File tree

src/Fieldtypes/Entries.php

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,24 +181,35 @@ private function getViewableCollections(array $collections): SupportCollection
181181

182182
public function getResourceCollection($request, $items)
183183
{
184-
// With no viewable collections we return empty data and, crucially, no columns. The
185-
// columns would otherwise be derived from the first configured collection's blueprint,
186-
// which the user isn't allowed to view.
187-
if ($this->getViewableCollections($this->getConfiguredCollections())->isEmpty()) {
184+
// Derive columns only from a collection the user can view. With none viewable, return
185+
// empty data and no columns rather than leaking the structure of an unviewable blueprint.
186+
if (! $collection = $this->getColumnCollection($request)) {
188187
return JsonResource::collection($items)->additional(['meta' => ['columns' => []]]);
189188
}
190189

191190
return (new EntriesFieldtypeEntries($items, $this))
192-
->blueprint($this->getBlueprint($request))
193-
->columnPreferenceKey("collections.{$this->getFirstCollectionFromRequest($request)->handle()}.columns")
191+
->blueprint($collection->entryBlueprint())
192+
->columnPreferenceKey("collections.{$collection->handle()}.columns")
194193
->additional(['meta' => [
195194
'activeFilterBadges' => $this->activeFilterBadges,
196195
]]);
197196
}
198197

199198
protected function getBlueprint($request = null)
200199
{
201-
return $this->getFirstCollectionFromRequest($request)->entryBlueprint();
200+
return $this->getColumnCollection($request)?->entryBlueprint();
201+
}
202+
203+
protected function getColumnCollection($request = null)
204+
{
205+
$collection = $this->getFirstCollectionFromRequest($request);
206+
207+
// Only derive columns from a collection the user can view. If the first requested or
208+
// configured collection isn't viewable, fall back to the first viewable configured
209+
// collection, or none at all when the user can view none of them.
210+
return User::current()->can('view', $collection)
211+
? $collection
212+
: $this->getViewableCollections($this->getConfiguredCollections())->first();
202213
}
203214

204215
protected function getFirstCollectionFromRequest($request)
@@ -468,18 +479,22 @@ public function toGqlType()
468479

469480
public function getColumns()
470481
{
471-
if (count($this->getConfiguredCollections()) === 1) {
472-
$columns = $this->getBlueprint()->columns();
482+
// Don't derive columns from a blueprint the user can't view; fall back to the
483+
// default columns when none of the configured collections are viewable.
484+
if (! $collection = $this->getColumnCollection()) {
485+
return parent::getColumns();
486+
}
487+
488+
$columns = $collection->entryBlueprint()->columns();
473489

490+
if (count($this->getConfiguredCollections()) === 1) {
474491
$this->addColumn($columns, 'status');
475492

476-
$columns->setPreferred("collections.{$this->getConfiguredCollections()[0]}.columns");
493+
$columns->setPreferred("collections.{$collection->handle()}.columns");
477494

478495
return $columns->rejectUnlisted()->values();
479496
}
480497

481-
$columns = $this->getBlueprint()->columns();
482-
483498
if ($this->canSelectAcrossSites()) {
484499
$this->addColumn($columns, 'site');
485500
}

tests/Feature/Fields/MetaControllerTest.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Illuminate\Support\Facades\Storage;
66
use PHPUnit\Framework\Attributes\Test;
77
use Statamic\Facades\AssetContainer;
8+
use Statamic\Facades\Blueprint;
89
use Statamic\Facades\Collection;
910
use Statamic\Facades\Role;
1011
use Statamic\Facades\User;
@@ -121,6 +122,58 @@ public function a_super_admin_gets_full_data_for_policy_less_types_through_prelo
121122
$this->assertNull($response->json('meta.data.0.invalid'));
122123
}
123124

125+
#[Test]
126+
public function it_does_not_expose_columns_from_an_unviewable_collection_blueprint()
127+
{
128+
Collection::make('secret')->title('Secret')->save();
129+
Blueprint::make('secret')
130+
->setNamespace('collections.secret')
131+
->setContents(['fields' => [
132+
['handle' => 'classified', 'field' => ['type' => 'text']],
133+
]])
134+
->save();
135+
136+
$this->setTestRoles(['test' => ['access cp']]);
137+
$user = User::make()->assignRole('test')->save();
138+
139+
$response = $this->fieldMeta($user, [
140+
'handle' => 'related',
141+
'type' => 'entries',
142+
'collections' => ['secret'],
143+
], [])->assertOk();
144+
145+
$columns = collect($response->json('meta.columns'))->pluck('field')->all();
146+
147+
// Only the default columns, never the unviewable blueprint's fields.
148+
$this->assertNotContains('classified', $columns);
149+
$this->assertEquals(['title'], $columns);
150+
}
151+
152+
#[Test]
153+
public function an_authorized_user_still_gets_the_full_collection_columns_via_preload()
154+
{
155+
Collection::make('news')->title('News')->save();
156+
Blueprint::make('news')
157+
->setNamespace('collections.news')
158+
->setContents(['fields' => [
159+
['handle' => 'intro', 'field' => ['type' => 'text', 'listable' => true]],
160+
]])
161+
->save();
162+
163+
$this->setTestRoles(['test' => ['access cp', 'view news entries']]);
164+
$user = User::make()->assignRole('test')->save();
165+
166+
$response = $this->fieldMeta($user, [
167+
'handle' => 'related',
168+
'type' => 'entries',
169+
'collections' => ['news'],
170+
], [])->assertOk();
171+
172+
$columns = collect($response->json('meta.columns'))->pluck('field')->all();
173+
174+
$this->assertContains('intro', $columns);
175+
}
176+
124177
#[Test]
125178
public function it_gates_assets_through_the_preload_path()
126179
{

0 commit comments

Comments
 (0)