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 ',