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:
@@ -70,7 +70,7 @@ class InboxController extends Controller
|
|||||||
'state' => $e->state->value,
|
'state' => $e->state->value,
|
||||||
'title' => $e->payload['title'],
|
'title' => $e->payload['title'],
|
||||||
'subtitle' => $e->payload['subtitle'] ?? null,
|
'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'] ?? 'Сделано',
|
'done_label' => $e->payload['done_label'] ?? 'Сделано',
|
||||||
'can_remind' => $e->state !== EventState::Scheduled,
|
'can_remind' => $e->state !== EventState::Scheduled,
|
||||||
'source' => self::sourceLabel($e->source->name),
|
'source' => self::sourceLabel($e->source->name),
|
||||||
@@ -88,6 +88,12 @@ class InboxController extends Controller
|
|||||||
return $name === 'manual' ? 'вручную' : $name;
|
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
|
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) ? 'дня' : 'дней');
|
$days = fn (int $n) => $n % 10 === 1 && $n % 100 !== 11 ? 'день' : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 'дня' : 'дней');
|
||||||
|
|||||||
@@ -27,11 +27,18 @@ class UpsertEventsRequest extends FormRequest
|
|||||||
'events.*.payload' => ['required', 'array'],
|
'events.*.payload' => ['required', 'array'],
|
||||||
'events.*.payload.title' => ['required', 'string'],
|
'events.*.payload.title' => ['required', 'string'],
|
||||||
'events.*.payload.subtitle' => ['sometimes', 'nullable', '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'],
|
'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
|
public function after(): array
|
||||||
{
|
{
|
||||||
return [function (Validator $v) {
|
return [function (Validator $v) {
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ class ClientApiTest extends TestCase
|
|||||||
->assertStatus(422)->assertJsonValidationErrors(['events']);
|
->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
|
public function test_topic_defaults_to_empty_string(): void
|
||||||
{
|
{
|
||||||
$item = $this->item();
|
$item = $this->item();
|
||||||
|
|||||||
@@ -88,4 +88,22 @@ class InboxPageTest extends TestCase
|
|||||||
$this->assertStringContainsString('<title>Hado</title>', $html);
|
$this->assertStringContainsString('<title>Hado</title>', $html);
|
||||||
$this->assertStringNotContainsString('class="counter"', $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user