From 4489761130d781492c3a89a953e9519715c91d7c Mon Sep 17 00:00:00 2001
From: Jan Kahmen <36455663+kah-ja@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:01:47 +0200
Subject: [PATCH] Restrict component links to http and https URLs
The status page wrote a component link into the anchor exactly as
stored, and the API accepted any string for it, so a value such as
javascript:alert(1) reached the href. Validate the field as
url:http,https on create and update, matching the rule the Filament
form already applies, and render the anchor through a formattedLink
accessor that returns nothing for a link stored earlier with another
scheme.
---
.../views/components/component.blade.php | 4 ++--
.../Component/CreateComponentRequestData.php | 2 +-
.../Component/UpdateComponentRequestData.php | 2 +-
src/Models/Component.php | 12 ++++++++++
tests/Feature/Api/ComponentTest.php | 23 +++++++++++++++++++
tests/Feature/StatusPage/StatusPageTest.php | 23 +++++++++++++++++++
6 files changed, 62 insertions(+), 4 deletions(-)
diff --git a/resources/views/components/component.blade.php b/resources/views/components/component.blade.php
index fa9f0e6b..1c417b10 100644
--- a/resources/views/components/component.blade.php
+++ b/resources/views/components/component.blade.php
@@ -7,8 +7,8 @@
'font-semibold text-zinc-900 dark:text-zinc-100' => ! ($nested ?? false),
'text-zinc-600 dark:text-zinc-300' => $nested ?? false,
])>
- @if($component->link)
- {{ $component->name }}
+ @if($component->formattedLink())
+ {{ $component->name }}
@else
{{ $component->name }}
@endif
diff --git a/src/Data/Requests/Component/CreateComponentRequestData.php b/src/Data/Requests/Component/CreateComponentRequestData.php
index 11417dc1..11d958b3 100644
--- a/src/Data/Requests/Component/CreateComponentRequestData.php
+++ b/src/Data/Requests/Component/CreateComponentRequestData.php
@@ -27,7 +27,7 @@ public static function rules(ValidationContext $context): array
'name' => ['string', 'required', 'max:255'],
'description' => ['string'],
'status' => [Rule::enum(ComponentStatusEnum::class)],
- 'link' => ['string'],
+ 'link' => ['string', 'url:http,https'],
'order' => ['int', 'min:0'],
'enabled' => ['boolean'],
'component_group_id' => ['int', 'min:0', Rule::exists('component_groups', 'id')],
diff --git a/src/Data/Requests/Component/UpdateComponentRequestData.php b/src/Data/Requests/Component/UpdateComponentRequestData.php
index 0de51897..bbd6fc2a 100644
--- a/src/Data/Requests/Component/UpdateComponentRequestData.php
+++ b/src/Data/Requests/Component/UpdateComponentRequestData.php
@@ -27,7 +27,7 @@ public static function rules(ValidationContext $context): array
'name' => ['string', 'max:255'],
'description' => ['string'],
'status' => [Rule::enum(ComponentStatusEnum::class)],
- 'link' => ['string'],
+ 'link' => ['string', 'url:http,https'],
'order' => ['int', 'min:0'],
'component_group_id' => ['int', 'min:0', Rule::exists('component_groups', 'id')],
'enabled' => ['boolean'],
diff --git a/src/Models/Component.php b/src/Models/Component.php
index a8687545..77c2840d 100644
--- a/src/Models/Component.php
+++ b/src/Models/Component.php
@@ -24,6 +24,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection as SupportCollection;
+use Illuminate\Support\Str;
/**
* @property int $id
@@ -97,6 +98,17 @@ public function formattedDescription(): string
return Cachet::markdown($this->description, inline: true);
}
+ /**
+ * Get the link to render on the status page, which is only ever an http
+ * or https URL.
+ */
+ public function formattedLink(): ?string
+ {
+ return Str::isUrl((string) $this->link, ['http', 'https'])
+ ? $this->link
+ : null;
+ }
+
/**
* Get the group the component belongs to.
*/
diff --git a/tests/Feature/Api/ComponentTest.php b/tests/Feature/Api/ComponentTest.php
index 4dfa3a8c..2138c31f 100644
--- a/tests/Feature/Api/ComponentTest.php
+++ b/tests/Feature/Api/ComponentTest.php
@@ -252,6 +252,29 @@
$response->assertJsonFragment(['id' => $component->id]);
});
+it('cannot create a component with a javascript link', function () {
+ Sanctum::actingAs(User::factory()->create(), ['components.manage']);
+
+ $response = postJson('/status/api/components', [
+ 'name' => 'Test',
+ 'link' => 'javascript:alert(1)',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrorFor('link');
+});
+
+it('can create a component with an http link', function () {
+ Sanctum::actingAs(User::factory()->create(), ['components.manage']);
+
+ $response = postJson('/status/api/components', [
+ 'name' => 'Test',
+ 'link' => 'https://status.example.com/api',
+ ]);
+
+ $response->assertCreated();
+});
+
it('cannot create a component when not authenticated', function () {
$response = postJson('/status/api/components', [
'name' => 'Test',
diff --git a/tests/Feature/StatusPage/StatusPageTest.php b/tests/Feature/StatusPage/StatusPageTest.php
index 8c9fe74f..ee027d70 100644
--- a/tests/Feature/StatusPage/StatusPageTest.php
+++ b/tests/Feature/StatusPage/StatusPageTest.php
@@ -93,6 +93,29 @@
->assertDontSee('image/svg+xml');
});
+it('does not link a component to a javascript url', function () {
+ Component::factory()->create([
+ 'name' => 'Scriptable API',
+ 'link' => 'javascript:alert(1)',
+ ]);
+
+ $this->get(route('cachet.status-page'))
+ ->assertOk()
+ ->assertSee('Scriptable API')
+ ->assertDontSee('javascript:alert(1)', escape: false);
+});
+
+it('links a component to an http url', function () {
+ Component::factory()->create([
+ 'name' => 'Linked API',
+ 'link' => 'https://status.example.com/api',
+ ]);
+
+ $this->get(route('cachet.status-page'))
+ ->assertOk()
+ ->assertSee('href="https://status.example.com/api"', escape: false);
+});
+
it('does not render raw html in component descriptions', function () {
Component::factory()->create([
'description' => 'The **primary** API ',