From f6c2bae0336e2ad30ca2dfcf5611fbb88a53b1c7 Mon Sep 17 00:00:00 2001 From: LabSchool-GR Date: Mon, 6 Jul 2026 15:41:20 +0300 Subject: [PATCH] Harden update external links --- app/Http/Controllers/UpdateController.php | 4 +- app/Models/Update.php | 13 +++++ resources/views/dashboard.blade.php | 7 +-- resources/views/updates/index.blade.php | 5 +- .../Feature/AdminModuleAuthorizationTest.php | 50 +++++++++++++++++++ 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/UpdateController.php b/app/Http/Controllers/UpdateController.php index 7a7f248..795292e 100644 --- a/app/Http/Controllers/UpdateController.php +++ b/app/Http/Controllers/UpdateController.php @@ -44,8 +44,8 @@ public function store(Request $request): RedirectResponse $this->authorize('manage-updates'); $request->validate([ - 'description' => 'required|string', - 'link' => 'nullable|url', + 'description' => ['required', 'string', 'max:5000'], + 'link' => ['nullable', 'url:http,https', 'max:255'], ]); Update::create($request->only('description', 'link')); diff --git a/app/Models/Update.php b/app/Models/Update.php index 5b33f8d..df821a7 100644 --- a/app/Models/Update.php +++ b/app/Models/Update.php @@ -23,4 +23,17 @@ class Update extends Model 'description', 'link', ]; + + /** + * Return only browser-safe external links, including for legacy records. + */ + public function safeExternalLink(): ?string + { + $link = trim((string) $this->link); + $scheme = strtolower((string) parse_url($link, PHP_URL_SCHEME)); + + return $link !== '' && in_array($scheme, ['http', 'https'], true) + ? $link + : null; + } } diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 81524f7..517d74a 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -182,6 +182,7 @@ @if ($updates->isNotEmpty())
@foreach ($updates as $update) + @php($safeUpdateLink = $update->safeExternalLink())
@@ -191,8 +192,8 @@

{{ $update->description }}

- @if ($update->link) - + @if ($safeUpdateLink) + {{ __('dashboard.updates_open_link') }} @@ -226,7 +227,7 @@
- + diff --git a/resources/views/updates/index.blade.php b/resources/views/updates/index.blade.php index 5d4ba30..9b53a32 100644 --- a/resources/views/updates/index.blade.php +++ b/resources/views/updates/index.blade.php @@ -44,6 +44,7 @@ @else
@foreach ($updates as $index => $update) + @php($safeUpdateLink = $update->safeExternalLink())
@@ -54,8 +55,8 @@
- @if ($update->link) - + @if ($safeUpdateLink) + {{ __('dashboard.updates_open_link') }} @endif diff --git a/tests/Feature/AdminModuleAuthorizationTest.php b/tests/Feature/AdminModuleAuthorizationTest.php index 637ac19..548ae18 100644 --- a/tests/Feature/AdminModuleAuthorizationTest.php +++ b/tests/Feature/AdminModuleAuthorizationTest.php @@ -182,6 +182,56 @@ ->assertForbidden(); }); +it('accepts only http and https links for updates', function () { + $admin = User::factory()->create([ + 'role' => 'admin', + ]); + + $this->actingAs($admin) + ->post(route('updates.store'), [ + 'description' => 'Unsafe external link', + 'link' => 'file://example.com/release-notes', + ]) + ->assertSessionHasErrors('link'); + + $this->assertDatabaseMissing('updates', [ + 'description' => 'Unsafe external link', + ]); + + $this->actingAs($admin) + ->post(route('updates.store'), [ + 'description' => 'Safe external link', + 'link' => 'https://example.com/release-notes', + ]) + ->assertRedirect(route('updates.index', absolute: false)); + + $this->assertDatabaseHas('updates', [ + 'description' => 'Safe external link', + 'link' => 'https://example.com/release-notes', + ]); +}); + +it('does not render legacy update links with unsafe protocols', function () { + $teacher = User::factory()->create([ + 'role' => 'teacher', + ]); + + Update::create([ + 'description' => 'Legacy unsafe link', + 'link' => 'file://example.com/release-notes', + ]); + + $this->actingAs($teacher) + ->get(route('dashboard')) + ->assertOk() + ->assertDontSee('href="file://example.com/release-notes"', false); + + $this->actingAs($teacher) + ->get(route('updates.index')) + ->assertOk() + ->assertDontSee('href="file://example.com/release-notes"', false); +}); + it('allows admin access to quiz templates management and blocks teachers', function () { $admin = User::factory()->create([ 'role' => 'admin',