diff --git a/core/app/Http/Controllers/InboxController.php b/core/app/Http/Controllers/InboxController.php index 0b12e8f..8ce6b8e 100644 --- a/core/app/Http/Controllers/InboxController.php +++ b/core/app/Http/Controllers/InboxController.php @@ -91,7 +91,7 @@ class InboxController extends Controller /** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */ public static function safeLink(?string $link): ?string { - return $link !== null && preg_match('#^(/|https?://)#i', $link) === 1 ? $link : null; + return $link !== null && preg_match('#^(/(?!/)|https?://)#i', $link) === 1 ? $link : null; } public static function relative(int $diff): string diff --git a/core/app/Http/Requests/UpsertEventsRequest.php b/core/app/Http/Requests/UpsertEventsRequest.php index 3f3eb5a..eb23703 100644 --- a/core/app/Http/Requests/UpsertEventsRequest.php +++ b/core/app/Http/Requests/UpsertEventsRequest.php @@ -27,7 +27,7 @@ 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', 'max:2048', 'regex:#^(/|https?://)#'], + 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?!/)|https?://)#'], 'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'], ]; } diff --git a/core/tests/Feature/ClientApiTest.php b/core/tests/Feature/ClientApiTest.php index fa09ed2..6702540 100644 --- a/core/tests/Feature/ClientApiTest.php +++ b/core/tests/Feature/ClientApiTest.php @@ -89,7 +89,11 @@ class ClientApiTest extends TestCase $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']]); + $protocolRelative = $this->item(['payload' => ['title' => 'x', 'deep_link' => '//evil.com/x']]); + $this->putJson('/api/events', ['events' => [$protocolRelative]], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']); + + $ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42?x=1']]); $this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk(); $ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]); diff --git a/core/tests/Feature/InboxPageTest.php b/core/tests/Feature/InboxPageTest.php index 76f32ad..7f58c84 100644 --- a/core/tests/Feature/InboxPageTest.php +++ b/core/tests/Feature/InboxPageTest.php @@ -100,10 +100,18 @@ class InboxPageTest extends TestCase 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Опасное событие', 'deep_link' => 'javascript:alert(1)'], 'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), ]); + Event::create([ + 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'b', + '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' => '//evil.com/x'], + '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->assertStringNotContainsString('href="//evil', $html); $this->assertStringContainsString('Опасное событие', $html); + $this->assertStringContainsString('Протокольная ссылка', $html); } }