From 86b02f3d36c5ed2796a4b48272d28e1243e18236 Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Fri, 4 Sep 2026 08:25:00 -0300 Subject: [PATCH] fix: restrict deep_link to relative paths and http(s) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- core/app/Http/Controllers/InboxController.php | 8 +++++++- core/app/Http/Requests/UpsertEventsRequest.php | 9 ++++++++- core/tests/Feature/ClientApiTest.php | 13 +++++++++++++ core/tests/Feature/InboxPageTest.php | 18 ++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/core/app/Http/Controllers/InboxController.php b/core/app/Http/Controllers/InboxController.php index 7b4e073..0b12e8f 100644 --- a/core/app/Http/Controllers/InboxController.php +++ b/core/app/Http/Controllers/InboxController.php @@ -70,7 +70,7 @@ class InboxController extends Controller 'state' => $e->state->value, 'title' => $e->payload['title'], 'subtitle' => $e->payload['subtitle'] ?? null, - 'link' => $e->payload['deep_link'] ?? null, + 'link' => self::safeLink($e->payload['deep_link'] ?? null), 'done_label' => $e->payload['done_label'] ?? 'Сделано', 'can_remind' => $e->state !== EventState::Scheduled, 'source' => self::sourceLabel($e->source->name), @@ -88,6 +88,12 @@ class InboxController extends Controller return $name === 'manual' ? 'вручную' : $name; } + /** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */ + public static function safeLink(?string $link): ?string + { + return $link !== null && preg_match('#^(/|https?://)#i', $link) === 1 ? $link : null; + } + public static function relative(int $diff): string { $days = fn (int $n) => $n % 10 === 1 && $n % 100 !== 11 ? 'день' : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 'дня' : 'дней'); diff --git a/core/app/Http/Requests/UpsertEventsRequest.php b/core/app/Http/Requests/UpsertEventsRequest.php index aa093a2..3f3eb5a 100644 --- a/core/app/Http/Requests/UpsertEventsRequest.php +++ b/core/app/Http/Requests/UpsertEventsRequest.php @@ -27,11 +27,18 @@ class UpsertEventsRequest extends FormRequest 'events.*.payload' => ['required', 'array'], 'events.*.payload.title' => ['required', 'string'], 'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'], - 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string'], + 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/|https?://)#'], 'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'], ]; } + public function messages(): array + { + return [ + 'events.*.payload.deep_link.regex' => 'deep_link должен быть относительным путём или http(s)-ссылкой', + ]; + } + public function after(): array { return [function (Validator $v) { diff --git a/core/tests/Feature/ClientApiTest.php b/core/tests/Feature/ClientApiTest.php index d8b2ea8..fa09ed2 100644 --- a/core/tests/Feature/ClientApiTest.php +++ b/core/tests/Feature/ClientApiTest.php @@ -83,6 +83,19 @@ class ClientApiTest extends TestCase ->assertStatus(422)->assertJsonValidationErrors(['events']); } + public function test_deep_link_must_be_relative_or_http(): void + { + $bad = $this->item(['payload' => ['title' => 'x', 'deep_link' => 'javascript:alert(1)']]); + $this->putJson('/api/events', ['events' => [$bad]], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']); + + $ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42']]); + $this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk(); + + $ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]); + $this->putJson('/api/events', ['events' => [$ok2]], $this->auth())->assertOk(); + } + public function test_topic_defaults_to_empty_string(): void { $item = $this->item(); diff --git a/core/tests/Feature/InboxPageTest.php b/core/tests/Feature/InboxPageTest.php index 9016a45..76f32ad 100644 --- a/core/tests/Feature/InboxPageTest.php +++ b/core/tests/Feature/InboxPageTest.php @@ -88,4 +88,22 @@ class InboxPageTest extends TestCase $this->assertStringContainsString('Hado', $html); $this->assertStringNotContainsString('class="counter"', $html); } + + public function test_unsafe_deep_link_is_not_rendered_as_href(): void + { + $this->get('/', ['X-Remote-User' => 'nikita']); + $user = User::sole(); + $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + Event::create([ + 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'a', + 'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04', + 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Опасное событие', 'deep_link' => 'javascript:alert(1)'], + 'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), + ]); + + $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); + + $this->assertStringNotContainsString('href="javascript:', $html); + $this->assertStringContainsString('Опасное событие', $html); + } }