Skip to content

Commit 856f17a

Browse files
jbrooksukclaude
andauthored
Scope public API endpoints to caller visibility (GHSA-6ghm-wf22-pvx5) (#367)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3415990 commit 856f17a

16 files changed

Lines changed: 624 additions & 43 deletions

database/factories/ComponentGroupFactory.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
namespace Cachet\Database\Factories;
44

5-
use Cachet\Enums\ComponentGroupVisibilityEnum;
65
use Cachet\Enums\ResourceOrderColumnEnum;
76
use Cachet\Enums\ResourceOrderDirectionEnum;
7+
use Cachet\Enums\ResourceVisibilityEnum;
88
use Cachet\Models\ComponentGroup;
99
use Illuminate\Database\Eloquent\Factories\Factory;
1010

@@ -27,7 +27,7 @@ public function definition(): array
2727
'order' => 0,
2828
'order_column' => ResourceOrderColumnEnum::Manual,
2929
'order_direction' => null,
30-
'visible' => ComponentGroupVisibilityEnum::expanded->value,
30+
'visible' => ResourceVisibilityEnum::guest->value,
3131
];
3232
}
3333

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cachet\Concerns;
6+
7+
trait ChecksApiAuthentication
8+
{
9+
/**
10+
* Determine whether the API caller is authenticated.
11+
*
12+
* The API's read routes carry no auth middleware, so the application's
13+
* default guard never sees bearer tokens. The Sanctum guard resolves
14+
* both API tokens and first-party sessions.
15+
*/
16+
protected function isAuthenticated(): bool
17+
{
18+
return auth('sanctum')->check();
19+
}
20+
}

src/Http/Controllers/Api/ComponentController.php

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,35 @@
55
use Cachet\Actions\Component\CreateComponent;
66
use Cachet\Actions\Component\DeleteComponent;
77
use Cachet\Actions\Component\UpdateComponent;
8+
use Cachet\Concerns\ChecksApiAuthentication;
89
use Cachet\Concerns\GuardsApiAbilities;
910
use Cachet\Data\Requests\Component\CreateComponentRequestData;
1011
use Cachet\Data\Requests\Component\UpdateComponentRequestData;
1112
use Cachet\Enums\ComponentStatusEnum;
1213
use Cachet\Filters\MetaFilter;
1314
use Cachet\Http\Resources\Component as ComponentResource;
1415
use Cachet\Models\Component;
16+
use Cachet\Models\ComponentGroup;
17+
use Cachet\Models\Incident;
1518
use Dedoc\Scramble\Attributes\Group;
1619
use Dedoc\Scramble\Attributes\QueryParameter;
20+
use Illuminate\Database\Eloquent\Builder;
21+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
1722
use Illuminate\Http\Request;
1823
use Illuminate\Http\Response;
1924
use Illuminate\Routing\Controller;
25+
use Illuminate\Support\Collection;
2026
use Illuminate\Support\Number;
2127
use Spatie\QueryBuilder\AllowedFilter;
28+
use Spatie\QueryBuilder\AllowedInclude;
2229
use Spatie\QueryBuilder\QueryBuilder;
2330

2431
#[Group('Components', weight: 1)]
2532
class ComponentController extends Controller
2633
{
34+
use ChecksApiAuthentication;
2735
use GuardsApiAbilities;
2836

29-
/**
30-
* The list of allowed includes.
31-
*/
32-
public const ALLOWED_INCLUDES = [
33-
'group',
34-
'incidents',
35-
'meta',
36-
];
37-
3837
/**
3938
* List Components
4039
*/
@@ -47,12 +46,12 @@ class ComponentController extends Controller
4746
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
4847
public function index(Request $request)
4948
{
50-
$components = QueryBuilder::for(Component::class)
51-
->allowedIncludes(self::ALLOWED_INCLUDES)
49+
$components = QueryBuilder::for($this->visibleComponents())
50+
->allowedIncludes($this->allowedIncludes())
5251
->allowedFilters([
5352
'name',
5453
AllowedFilter::exact('status'),
55-
AllowedFilter::exact('enabled'),
54+
AllowedFilter::exact('enabled')->default(true),
5655
AllowedFilter::custom('meta', new MetaFilter),
5756
])
5857
->allowedSorts(['name', 'order', 'id'])
@@ -61,6 +60,44 @@ public function index(Request $request)
6160
return ComponentResource::collection($components);
6261
}
6362

63+
/**
64+
* The list of allowed includes, scoped to the current caller.
65+
*
66+
* @return array<int, string|Collection<int, AllowedInclude>>
67+
*/
68+
protected function allowedIncludes(): array
69+
{
70+
return [
71+
'group',
72+
AllowedInclude::callback('incidents', function (BelongsToMany $query): void {
73+
/** @var BelongsToMany<Incident, Component> $query */
74+
$query->visible($this->isAuthenticated());
75+
}),
76+
'meta',
77+
];
78+
}
79+
80+
/**
81+
* Base query scoping components to those visible to the current caller.
82+
*
83+
* Components have no visibility of their own; they inherit it from their
84+
* group. Ungrouped components are always public and disabled components
85+
* are hidden from guests, matching the status page.
86+
*
87+
* @return Builder<Component>
88+
*/
89+
protected function visibleComponents(): Builder
90+
{
91+
$visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id');
92+
93+
return Component::query()
94+
->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled())
95+
->where(function ($query) use ($visibleGroups): void {
96+
$query->whereNull('component_group_id')
97+
->orWhereIn('component_group_id', $visibleGroups);
98+
});
99+
}
100+
64101
/**
65102
* Create Component
66103
*/
@@ -81,10 +118,9 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC
81118
#[QueryParameter('include', 'Include related data (group, incidents, meta).', example: 'meta')]
82119
public function show(Component $component)
83120
{
84-
85-
$componentQuery = QueryBuilder::for(Component::class)
86-
->allowedIncludes(self::ALLOWED_INCLUDES)
87-
->find($component->id);
121+
$componentQuery = QueryBuilder::for($this->visibleComponents())
122+
->allowedIncludes($this->allowedIncludes())
123+
->findOrFail($component->id);
88124

89125
return ComponentResource::make($componentQuery)
90126
->response()

src/Http/Controllers/Api/ComponentGroupController.php

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,52 @@
55
use Cachet\Actions\ComponentGroup\CreateComponentGroup;
66
use Cachet\Actions\ComponentGroup\DeleteComponentGroup;
77
use Cachet\Actions\ComponentGroup\UpdateComponentGroup;
8+
use Cachet\Concerns\ChecksApiAuthentication;
89
use Cachet\Concerns\GuardsApiAbilities;
910
use Cachet\Data\Requests\ComponentGroup\CreateComponentGroupRequestData;
1011
use Cachet\Data\Requests\ComponentGroup\UpdateComponentGroupRequestData;
1112
use Cachet\Filters\MetaFilter;
1213
use Cachet\Http\Resources\ComponentGroup as ComponentGroupResource;
14+
use Cachet\Models\Component;
1315
use Cachet\Models\ComponentGroup;
1416
use Dedoc\Scramble\Attributes\Group;
1517
use Dedoc\Scramble\Attributes\QueryParameter;
18+
use Illuminate\Database\Eloquent\Relations\HasMany;
1619
use Illuminate\Http\Request;
1720
use Illuminate\Http\Response;
1821
use Illuminate\Routing\Controller;
22+
use Illuminate\Support\Collection;
1923
use Illuminate\Support\Number;
2024
use Spatie\QueryBuilder\AllowedFilter;
25+
use Spatie\QueryBuilder\AllowedInclude;
2126
use Spatie\QueryBuilder\QueryBuilder;
2227

2328
#[Group('Component Groups', weight: 2)]
2429
class ComponentGroupController extends Controller
2530
{
31+
use ChecksApiAuthentication;
2632
use GuardsApiAbilities;
2733

34+
/**
35+
* The list of allowed includes, scoped to the current caller.
36+
*
37+
* Disabled components are hidden from guests, matching the status page.
38+
*
39+
* @return array<int, string|Collection<int, AllowedInclude>>
40+
*/
41+
protected function allowedIncludes(): array
42+
{
43+
return [
44+
AllowedInclude::callback('components', function (HasMany $query): void {
45+
/** @var HasMany<Component, ComponentGroup> $query */
46+
if (! $this->isAuthenticated()) {
47+
$query->enabled();
48+
}
49+
}),
50+
'meta',
51+
];
52+
}
53+
2854
/**
2955
* List Component Groups
3056
*/
@@ -34,8 +60,8 @@ class ComponentGroupController extends Controller
3460
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
3561
public function index(Request $request)
3662
{
37-
$componentGroups = QueryBuilder::for(ComponentGroup::class)
38-
->allowedIncludes(['components', 'meta'])
63+
$componentGroups = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated()))
64+
->allowedIncludes($this->allowedIncludes())
3965
->allowedFilters([
4066
AllowedFilter::custom('meta', new MetaFilter),
4167
])
@@ -63,10 +89,9 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou
6389
#[QueryParameter('include', 'Include related data (components, meta).', example: 'meta')]
6490
public function show(ComponentGroup $componentGroup)
6591
{
66-
67-
$componentQuery = QueryBuilder::for(ComponentGroup::class)
68-
->allowedIncludes(['components', 'meta'])
69-
->find($componentGroup->id);
92+
$componentQuery = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated()))
93+
->allowedIncludes($this->allowedIncludes())
94+
->findOrFail($componentGroup->id);
7095

7196
return ComponentGroupResource::make($componentQuery)
7297
->response()

src/Http/Controllers/Api/IncidentController.php

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,54 @@
55
use Cachet\Actions\Incident\CreateIncident;
66
use Cachet\Actions\Incident\DeleteIncident;
77
use Cachet\Actions\Incident\UpdateIncident;
8+
use Cachet\Concerns\ChecksApiAuthentication;
89
use Cachet\Concerns\GuardsApiAbilities;
910
use Cachet\Data\Requests\Incident\CreateIncidentRequestData;
1011
use Cachet\Data\Requests\Incident\UpdateIncidentRequestData;
1112
use Cachet\Filters\MetaFilter;
1213
use Cachet\Http\Resources\Incident as IncidentResource;
14+
use Cachet\Models\Component;
15+
use Cachet\Models\ComponentGroup;
1316
use Cachet\Models\Incident;
1417
use Dedoc\Scramble\Attributes\Group;
1518
use Dedoc\Scramble\Attributes\QueryParameter;
19+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1620
use Illuminate\Http\Request;
1721
use Illuminate\Http\Response;
1822
use Illuminate\Routing\Controller;
23+
use Illuminate\Support\Collection;
1924
use Illuminate\Support\Number;
2025
use Spatie\QueryBuilder\AllowedFilter;
26+
use Spatie\QueryBuilder\AllowedInclude;
2127
use Spatie\QueryBuilder\QueryBuilder;
2228

2329
#[Group('Incidents', weight: 3)]
2430
class IncidentController extends Controller
2531
{
32+
use ChecksApiAuthentication;
2633
use GuardsApiAbilities;
2734

2835
/**
29-
* The list of allowed includes.
36+
* The list of allowed includes, scoped to the current caller.
37+
*
38+
* Component groups hidden from the caller are excluded from the nested
39+
* include so a visible incident cannot reveal them.
40+
*
41+
* @return array<int, string|Collection<int, AllowedInclude>>
3042
*/
31-
public const ALLOWED_INCLUDES = [
32-
'components',
33-
'components.group',
34-
'updates',
35-
'user',
36-
'meta',
37-
];
43+
protected function allowedIncludes(): array
44+
{
45+
return [
46+
'components',
47+
AllowedInclude::callback('components.group', function (BelongsTo $query): void {
48+
/** @var BelongsTo<ComponentGroup, Component> $query */
49+
$query->visible($this->isAuthenticated());
50+
}),
51+
'updates',
52+
'user',
53+
'meta',
54+
];
55+
}
3856

3957
/**
4058
* List Incidents
@@ -45,8 +63,8 @@ class IncidentController extends Controller
4563
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
4664
public function index(Request $request)
4765
{
48-
$incidents = QueryBuilder::for(Incident::query()->with('updates'))
49-
->allowedIncludes(self::ALLOWED_INCLUDES)
66+
$incidents = QueryBuilder::for(Incident::query()->with('updates')->visible($this->isAuthenticated()))
67+
->allowedIncludes($this->allowedIncludes())
5068
->allowedFilters([
5169
'name',
5270
AllowedFilter::exact('status'),
@@ -80,10 +98,9 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc
8098
#[QueryParameter('include', 'Include related data (components, components.group, updates, user, meta).', example: 'meta')]
8199
public function show(Incident $incident)
82100
{
83-
84-
$incidentQuery = QueryBuilder::for(Incident::class)
85-
->allowedIncludes(self::ALLOWED_INCLUDES)
86-
->find($incident->id);
101+
$incidentQuery = QueryBuilder::for(Incident::query()->visible($this->isAuthenticated()))
102+
->allowedIncludes($this->allowedIncludes())
103+
->findOrFail($incident->id);
87104

88105
return IncidentResource::make($incidentQuery)
89106
->response()

src/Http/Controllers/Api/IncidentUpdateController.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Cachet\Actions\Update\CreateUpdate;
66
use Cachet\Actions\Update\DeleteUpdate;
77
use Cachet\Actions\Update\EditUpdate;
8+
use Cachet\Concerns\ChecksApiAuthentication;
89
use Cachet\Concerns\GuardsApiAbilities;
910
use Cachet\Data\Requests\IncidentUpdate\CreateIncidentUpdateRequestData;
1011
use Cachet\Data\Requests\IncidentUpdate\EditIncidentUpdateRequestData;
@@ -24,6 +25,7 @@
2425
#[Group('Incident Updates', weight: 4)]
2526
class IncidentUpdateController extends Controller
2627
{
28+
use ChecksApiAuthentication;
2729
use GuardsApiAbilities;
2830

2931
/**
@@ -33,6 +35,8 @@ class IncidentUpdateController extends Controller
3335
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
3436
public function index(Request $request, Incident $incident)
3537
{
38+
$this->ensureIncidentVisible($incident);
39+
3640
$query = Update::query()
3741
->where('updateable_id', $incident->id)
3842
->where('updateable_type', 'incident');
@@ -65,6 +69,8 @@ public function store(CreateIncidentUpdateRequestData $data, Incident $incident,
6569
*/
6670
public function show(Incident $incident, Update $update)
6771
{
72+
$this->ensureIncidentVisible($incident);
73+
6874
$updateQuery = QueryBuilder::for(Update::class)
6975
->allowedIncludes([
7076
AllowedInclude::relationship('incident', 'updateable'),
@@ -76,6 +82,17 @@ public function show(Incident $incident, Update $update)
7682
->setStatusCode(Response::HTTP_OK);
7783
}
7884

85+
/**
86+
* Abort with a 404 when the parent incident is not visible to the caller.
87+
*/
88+
protected function ensureIncidentVisible(Incident $incident): void
89+
{
90+
abort_unless(
91+
Incident::query()->visible($this->isAuthenticated())->whereKey($incident->getKey())->exists(),
92+
Response::HTTP_NOT_FOUND,
93+
);
94+
}
95+
7996
/**
8097
* Update Incident Update
8198
*/

0 commit comments

Comments
 (0)