fix: restrict deep_link to relative paths and http(s)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 08:25:00 -03:00
parent 027c9f7784
commit 86b02f3d36
4 changed files with 46 additions and 2 deletions

View File

@@ -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) ? 'дня' : 'дней');

View File

@@ -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) {

View File

@@ -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();

View File

@@ -88,4 +88,22 @@ class InboxPageTest extends TestCase
$this->assertStringContainsString('<title>Hado</title>', $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);
}
}