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