From 6933ddc5381f75904c7a643e4bb61ff92033dcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 22 May 2026 03:25:16 +0300 Subject: [PATCH] =?UTF-8?q?fix(security):=20SSRF-=D0=B3=D0=B0=D1=80=D0=B4?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20webhook=20target=5Furl=20(=D0=B7=D0=B0?= =?UTF-8?q?=D1=89=D0=B8=D1=82=D0=B0=20=D0=B1=D1=83=D0=B4=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Api/WebhookSettingsController.php | 11 ++++++++ .../Feature/WebhookSettingsControllerTest.php | 28 ++++++++++++------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/app/app/Http/Controllers/Api/WebhookSettingsController.php b/app/app/Http/Controllers/Api/WebhookSettingsController.php index a56bec4c..ebf67620 100644 --- a/app/app/Http/Controllers/Api/WebhookSettingsController.php +++ b/app/app/Http/Controllers/Api/WebhookSettingsController.php @@ -12,6 +12,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Response; /** @@ -54,6 +55,16 @@ class WebhookSettingsController extends Controller 'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'], ]); + // SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную + // сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка + // событий) читает из БД только безопасные адреса. NB: будущая доставка + // обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от + // DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный). + $blockReason = WebhookUrlGuard::blockReason($validated['target_url']); + if ($blockReason !== null) { + throw ValidationException::withMessages(['target_url' => [$blockReason]]); + } + $sub = $this->currentSubscription($request); $plainSecret = null; diff --git a/app/tests/Feature/WebhookSettingsControllerTest.php b/app/tests/Feature/WebhookSettingsControllerTest.php index 234078d6..de9b8b3c 100644 --- a/app/tests/Feature/WebhookSettingsControllerTest.php +++ b/app/tests/Feature/WebhookSettingsControllerTest.php @@ -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');