fix(security): SSRF-гард на сохранении webhook target_url (защита будущей доставки)
- update(): WebhookUrlGuard блокирует сохранение private/reserved/loopback IP → 422 validation error на target_url; небезопасные адреса не попадают в БД, любой будущий потребитель (test() + outbound-доставка) читает только безопасные - NB: будущая outbound-доставка обязана ВДОБАВОК звать guard перед отправкой (DNS-rebinding); outbound-pipeline пока не построен (комментарий в update()) - тесты: +PUT private-IP→422 не сохраняет; webhook target_url → публичные IP-литералы (убрал DNS-резолюцию example.ru-хостов, webhook-suite 93s→5s) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -27,13 +27,13 @@ test('GET webhook-settings возвращает подписку тенанта'
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/tenants/me/webhook-settings');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
|
||||
expect($response->json('data'))->not->toHaveKey('secret_hash');
|
||||
});
|
||||
@@ -55,11 +55,11 @@ test('GET webhook-settings изолирован по тенанту', function (
|
||||
|
||||
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data.secret'))->toStartWith('whsec_');
|
||||
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
|
||||
|
||||
@@ -72,15 +72,15 @@ test('PUT webhook-settings обновляет URL существующей по
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://old.example.ru/hook',
|
||||
'target_url' => 'https://8.8.8.8/hook',
|
||||
]);
|
||||
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://new.example.ru/hook',
|
||||
'target_url' => 'https://1.1.1.1/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://1.1.1.1/hook');
|
||||
expect($response->json('data'))->not->toHaveKey('secret');
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
@@ -91,12 +91,20 @@ test('PUT webhook-settings: 422 при не-https URL', function () {
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
});
|
||||
|
||||
test('PUT webhook-settings: 422 для приватного/служебного IP в target_url (SSRF), не сохраняет', function () {
|
||||
$this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://169.254.169.254/hook',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('POST webhooks/test отправляет запрос и возвращает результат', function () {
|
||||
Http::fake(['*' => Http::response(['ok' => true], 200)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
@@ -104,7 +112,7 @@ test('POST webhooks/test отправляет запрос и возвращае
|
||||
$response->assertOk();
|
||||
expect($response->json('ok'))->toBeTrue();
|
||||
expect($response->json('status'))->toBe(200);
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://93.184.216.34/hook');
|
||||
});
|
||||
|
||||
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
|
||||
@@ -112,7 +120,7 @@ test('POST webhooks/test возвращает ok=false при ошибке endpo
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
|
||||
Reference in New Issue
Block a user