From 4efc0e804e1cb4ef62de76e614ef86213c3ba0d4 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 1 Jul 2026 11:39:36 +0100 Subject: [PATCH] Scope public API endpoints to caller visibility (GHSA-6ghm-wf22-pvx5) The public JSON API `index`/`show` endpoints never applied the `HasVisibility` scopes that the web UI and RSS feed use, so anonymous callers could read incidents, metrics and component groups marked `authenticated`-only or `hidden`. - Scope Incident, Metric and ComponentGroup index/show to the caller's visibility, returning 404 for out-of-scope records on show. - Scope Component index/show by the visibility of its group (components inherit visibility from their group; ungrouped stay public, matching the status page). Hide disabled components by default with an opt-in `enabled` filter; show 404s disabled components. - Gate the nested IncidentUpdate and MetricPoint controllers on their parent's visibility. - Fix ComponentGroupFactory using the wrong enum for `visible`, which defaulted groups to `authenticated` instead of `guest`. - Add API visibility regression tests across all affected endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) --- database/factories/ComponentGroupFactory.php | 4 +- .../Controllers/Api/ComponentController.php | 29 ++++++-- .../Api/ComponentGroupController.php | 7 +- .../Controllers/Api/IncidentController.php | 7 +- .../Api/IncidentUpdateController.php | 15 ++++ src/Http/Controllers/Api/MetricController.php | 5 +- .../Controllers/Api/MetricPointController.php | 14 ++++ tests/Feature/Api/ComponentGroupTest.php | 44 ++++++++++++ tests/Feature/Api/ComponentTest.php | 70 +++++++++++++++++++ tests/Feature/Api/IncidentTest.php | 53 ++++++++++++++ tests/Feature/Api/IncidentUpdateTest.php | 29 ++++++++ tests/Feature/Api/MetricPointTest.php | 20 ++++++ tests/Feature/Api/MetricTest.php | 44 ++++++++++++ 13 files changed, 324 insertions(+), 17 deletions(-) diff --git a/database/factories/ComponentGroupFactory.php b/database/factories/ComponentGroupFactory.php index ef1a103d..f27d5018 100644 --- a/database/factories/ComponentGroupFactory.php +++ b/database/factories/ComponentGroupFactory.php @@ -2,9 +2,9 @@ namespace Cachet\Database\Factories; -use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceOrderColumnEnum; use Cachet\Enums\ResourceOrderDirectionEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\ComponentGroup; use Illuminate\Database\Eloquent\Factories\Factory; @@ -27,7 +27,7 @@ public function definition(): array 'order' => 0, 'order_column' => ResourceOrderColumnEnum::Manual, 'order_direction' => null, - 'visible' => ComponentGroupVisibilityEnum::expanded->value, + 'visible' => ResourceVisibilityEnum::guest->value, ]; } diff --git a/src/Http/Controllers/Api/ComponentController.php b/src/Http/Controllers/Api/ComponentController.php index bab70666..55a0ab9a 100644 --- a/src/Http/Controllers/Api/ComponentController.php +++ b/src/Http/Controllers/Api/ComponentController.php @@ -11,8 +11,10 @@ use Cachet\Enums\ComponentStatusEnum; use Cachet\Http\Resources\Component as ComponentResource; use Cachet\Models\Component; +use Cachet\Models\ComponentGroup; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Response; use Illuminate\Routing\Controller; use Spatie\QueryBuilder\AllowedFilter; @@ -41,12 +43,12 @@ class ComponentController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index() { - $components = QueryBuilder::for(Component::class) + $components = QueryBuilder::for($this->visibleComponents()) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters([ 'name', AllowedFilter::exact('status'), - AllowedFilter::exact('enabled'), + AllowedFilter::exact('enabled')->default(true), ]) ->allowedSorts(['name', 'order', 'id']) ->simplePaginate(request('per_page', 15)); @@ -54,6 +56,24 @@ public function index() return ComponentResource::collection($components); } + /** + * Base query scoping components to those visible to the current caller. + * + * Components have no visibility of their own; they inherit it from their + * group. Ungrouped components are always public, matching the status page. + * + * @return Builder + */ + protected function visibleComponents(): Builder + { + $visibleGroups = ComponentGroup::query()->visible(auth()->check())->select('id'); + + return Component::query()->where(function ($query) use ($visibleGroups): void { + $query->whereNull('component_group_id') + ->orWhereIn('component_group_id', $visibleGroups); + }); + } + /** * Create Component */ @@ -73,10 +93,9 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC */ public function show(Component $component) { - - $componentQuery = QueryBuilder::for(Component::class) + $componentQuery = QueryBuilder::for($this->visibleComponents()->enabled()) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->find($component->id); + ->findOrFail($component->id); return ComponentResource::make($componentQuery) ->response() diff --git a/src/Http/Controllers/Api/ComponentGroupController.php b/src/Http/Controllers/Api/ComponentGroupController.php index 45724f9e..25c05406 100644 --- a/src/Http/Controllers/Api/ComponentGroupController.php +++ b/src/Http/Controllers/Api/ComponentGroupController.php @@ -28,7 +28,7 @@ class ComponentGroupController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index() { - $componentGroups = QueryBuilder::for(ComponentGroup::class) + $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) ->allowedIncludes(['components']) ->allowedSorts(['name', 'id']) ->simplePaginate(request('per_page', 15)); @@ -53,10 +53,9 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou */ public function show(ComponentGroup $componentGroup) { - - $componentQuery = QueryBuilder::for(ComponentGroup::class) + $componentQuery = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) ->allowedIncludes(['components']) - ->find($componentGroup->id); + ->findOrFail($componentGroup->id); return ComponentGroupResource::make($componentQuery) ->response() diff --git a/src/Http/Controllers/Api/IncidentController.php b/src/Http/Controllers/Api/IncidentController.php index 6879f40b..8832930e 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -40,7 +40,7 @@ class IncidentController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) { - $incidents = QueryBuilder::for(Incident::query()) + $incidents = QueryBuilder::for(Incident::query()->visible(auth()->check())) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters([ 'name', @@ -73,10 +73,9 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc */ public function show(Incident $incident) { - - $incidentQuery = QueryBuilder::for(Incident::class) + $incidentQuery = QueryBuilder::for(Incident::query()->visible(auth()->check())) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->find($incident->id); + ->findOrFail($incident->id); return IncidentResource::make($incidentQuery) ->response() diff --git a/src/Http/Controllers/Api/IncidentUpdateController.php b/src/Http/Controllers/Api/IncidentUpdateController.php index 0f93be96..dc8a9d90 100644 --- a/src/Http/Controllers/Api/IncidentUpdateController.php +++ b/src/Http/Controllers/Api/IncidentUpdateController.php @@ -31,6 +31,8 @@ class IncidentUpdateController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Incident $incident) { + $this->ensureIncidentVisible($incident); + $query = Update::query() ->where('updateable_id', $incident->id) ->where('updateable_type', 'incident'); @@ -61,6 +63,8 @@ public function store(CreateIncidentUpdateRequestData $data, Incident $incident, */ public function show(Incident $incident, Update $update) { + $this->ensureIncidentVisible($incident); + $updateQuery = QueryBuilder::for(Update::class) ->allowedIncludes([ AllowedInclude::relationship('incident', 'updateable'), @@ -72,6 +76,17 @@ public function show(Incident $incident, Update $update) ->setStatusCode(Response::HTTP_OK); } + /** + * Abort with a 404 when the parent incident is not visible to the caller. + */ + protected function ensureIncidentVisible(Incident $incident): void + { + abort_unless( + Incident::query()->visible(auth()->check())->whereKey($incident->getKey())->exists(), + Response::HTTP_NOT_FOUND, + ); + } + /** * Update Incident Update */ diff --git a/src/Http/Controllers/Api/MetricController.php b/src/Http/Controllers/Api/MetricController.php index bd1f6325..49ac566f 100644 --- a/src/Http/Controllers/Api/MetricController.php +++ b/src/Http/Controllers/Api/MetricController.php @@ -33,6 +33,7 @@ class MetricController extends Controller public function index() { $query = Metric::query() + ->visible(auth()->check()) ->when(! request('sort'), function (Builder $builder) { $builder->orderByDesc('created_at'); }); @@ -63,9 +64,9 @@ public function store(CreateMetricRequestData $data, CreateMetric $createMetricA */ public function show(Metric $metric) { - $metricQuery = QueryBuilder::for(Metric::class) + $metricQuery = QueryBuilder::for(Metric::query()->visible(auth()->check())) ->allowedIncludes(['points']) - ->find($metric->id); + ->findOrFail($metric->id); return MetricResource::make($metricQuery) ->response() diff --git a/src/Http/Controllers/Api/MetricPointController.php b/src/Http/Controllers/Api/MetricPointController.php index 047aa36e..7fdf5a03 100644 --- a/src/Http/Controllers/Api/MetricPointController.php +++ b/src/Http/Controllers/Api/MetricPointController.php @@ -27,6 +27,8 @@ class MetricPointController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Metric $metric) { + $this->ensureMetricVisible($metric); + $query = MetricPoint::query() ->where('metric_id', $metric->id); @@ -57,6 +59,7 @@ public function store(CreateMetricPointRequestData $data, Metric $metric, Create */ public function show(Metric $metric, MetricPoint $metricPoint) { + $this->ensureMetricVisible($metric); $metricPointQuery = QueryBuilder::for(MetricPoint::class) ->allowedIncludes(['metric']) @@ -67,6 +70,17 @@ public function show(Metric $metric, MetricPoint $metricPoint) ->setStatusCode(Response::HTTP_OK); } + /** + * Abort with a 404 when the parent metric is not visible to the caller. + */ + protected function ensureMetricVisible(Metric $metric): void + { + abort_unless( + Metric::query()->visible(auth()->check())->whereKey($metric->getKey())->exists(), + Response::HTTP_NOT_FOUND, + ); + } + /** * Delete Metric Point */ diff --git a/tests/Feature/Api/ComponentGroupTest.php b/tests/Feature/Api/ComponentGroupTest.php index 28cbe643..98ab5a45 100644 --- a/tests/Feature/Api/ComponentGroupTest.php +++ b/tests/Feature/Api/ComponentGroupTest.php @@ -3,6 +3,7 @@ use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceOrderColumnEnum; use Cachet\Enums\ResourceOrderDirectionEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; use Laravel\Sanctum\Sanctum; @@ -433,3 +434,46 @@ 'name' => 'Renamed Group', ]); }); + +it('does not list component groups hidden from guests', function () { + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/component-groups'); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); +}); + +it('lists authenticated component groups to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/component-groups'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a hidden component group to guests', function () { + $componentGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/component-groups/'.$componentGroup->id); + + $response->assertNotFound(); +}); + +it('shows an authenticated component group to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $componentGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/component-groups/'.$componentGroup->id); + + $response->assertOk(); + $response->assertJsonPath('data.attributes.id', $componentGroup->id); +}); diff --git a/tests/Feature/Api/ComponentTest.php b/tests/Feature/Api/ComponentTest.php index 0daad0bd..d5418dab 100644 --- a/tests/Feature/Api/ComponentTest.php +++ b/tests/Feature/Api/ComponentTest.php @@ -1,6 +1,7 @@ $component->id, ]); }); + +it('does not list components belonging to groups hidden from guests', function () { + $guestGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + $authGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + Component::factory()->create(['component_group_id' => $guestGroup->id]); + Component::factory()->create(['component_group_id' => $authGroup->id]); + Component::factory()->create(['component_group_id' => $hiddenGroup->id]); + Component::factory()->create(['component_group_id' => null]); + + $response = getJson('/status/api/components?per_page=50'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('lists components in authenticated groups to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + $authGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + Component::factory()->create(['component_group_id' => $authGroup->id]); + Component::factory()->create(['component_group_id' => $hiddenGroup->id]); + Component::factory()->create(['component_group_id' => null]); + + $response = getJson('/status/api/components?per_page=50'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a component in a hidden group to guests', function () { + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $component = Component::factory()->create(['component_group_id' => $hiddenGroup->id]); + + $response = getJson('/status/api/components/'.$component->id); + + $response->assertNotFound(); +}); + +it('does not list disabled components by default', function () { + Component::factory(3)->enabled()->create(); + Component::factory(2)->disabled()->create(); + + $response = getJson('/status/api/components?per_page=50'); + + $response->assertOk(); + $response->assertJsonCount(3, 'data'); +}); + +it('lists disabled components when explicitly filtered', function () { + Component::factory(3)->enabled()->create(); + Component::factory(2)->disabled()->create(); + + $response = getJson('/status/api/components?per_page=50&filter[enabled]=false'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a disabled component by default', function () { + $component = Component::factory()->disabled()->create(); + + $response = getJson('/status/api/components/'.$component->id); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/IncidentTest.php b/tests/Feature/Api/IncidentTest.php index 411d6389..239b487d 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -2,6 +2,7 @@ use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; use Cachet\Models\Incident; @@ -440,3 +441,55 @@ $response->assertNoContent(); }); + +it('does not list incidents hidden from guests', function () { + Incident::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/incidents'); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.attributes.visible', ResourceVisibilityEnum::guest->value); +}); + +it('lists authenticated incidents to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + Incident::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/incidents'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a hidden incident to guests', function () { + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/incidents/'.$incident->id); + + $response->assertNotFound(); +}); + +it('does not show an authenticated incident to guests', function () { + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/incidents/'.$incident->id); + + $response->assertNotFound(); +}); + +it('shows an authenticated incident to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/incidents/'.$incident->id); + + $response->assertOk(); + $response->assertJsonPath('data.attributes.id', $incident->id); +}); diff --git a/tests/Feature/Api/IncidentUpdateTest.php b/tests/Feature/Api/IncidentUpdateTest.php index e3f7b79b..0490f27a 100644 --- a/tests/Feature/Api/IncidentUpdateTest.php +++ b/tests/Feature/Api/IncidentUpdateTest.php @@ -1,6 +1,7 @@ $incidentUpdate->updateable_id, ]); }); + +it('does not list updates of an incident hidden from guests', function () { + $incident = Incident::factory()->hasUpdates(2)->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson("/status/api/incidents/{$incident->id}/updates"); + + $response->assertNotFound(); +}); + +it('does not show an update of an incident hidden from guests', function () { + $incident = Incident::factory()->hasUpdates(1)->create(['visible' => ResourceVisibilityEnum::hidden]); + $update = $incident->updates()->first(); + + $response = getJson("/status/api/incidents/{$incident->id}/updates/{$update->id}"); + + $response->assertNotFound(); +}); + +it('lists updates of an authenticated incident to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $incident = Incident::factory()->hasUpdates(2)->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson("/status/api/incidents/{$incident->id}/updates"); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/MetricPointTest.php b/tests/Feature/Api/MetricPointTest.php index b2f8baf0..1f433150 100644 --- a/tests/Feature/Api/MetricPointTest.php +++ b/tests/Feature/Api/MetricPointTest.php @@ -1,5 +1,6 @@ $metricPoint->metric_id, ]); }); + +it('does not list points of a metric hidden from guests', function () { + $metric = Metric::factory()->hasMetricPoints(2)->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics/'.$metric->id.'/points'); + + $response->assertNotFound(); +}); + +it('lists points of an authenticated metric to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $metric = Metric::factory()->hasMetricPoints(2)->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/metrics/'.$metric->id.'/points'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/MetricTest.php b/tests/Feature/Api/MetricTest.php index 758d974f..4e94a5ad 100644 --- a/tests/Feature/Api/MetricTest.php +++ b/tests/Feature/Api/MetricTest.php @@ -1,6 +1,7 @@ $metric->id, ]); }); + +it('does not list metrics hidden from guests', function () { + Metric::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics'); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); +}); + +it('lists authenticated metrics to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + Metric::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a hidden metric to guests', function () { + $metric = Metric::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics/'.$metric->id); + + $response->assertNotFound(); +}); + +it('shows an authenticated metric to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $metric = Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/metrics/'.$metric->id); + + $response->assertOk(); + $response->assertJsonPath('data.attributes.id', $metric->id); +});