Files
portal/app/tests/Feature/WebhookSsrfGuardTest.php
T
Дмитрий 2a34ee880a fix(security): закрыть открытые эндпоинты + SSRF-гард webhook перед go-live
- /api/dashboard/summary, /api/managers, /api/lead-statuses: были без auth
  (tenant_id параметром) → auth:sanctum (+tenant); tenant_id из authed-user,
  не из параметра — закрывает кросс-tenant утечку KPI/списка пользователей
- ManagerController: явный where(tenant_id) поверх RLS (BYPASSRLS-роли/тесты)
- WebhookUrlGuard + webhooks/test: SSRF-блок private/reserved/loopback IP
  (cloud-metadata 169.254.169.254 и пр.); update()/delivery — follow-up
- TDD: +EndpointAuthHardeningTest(5) +WebhookSsrfGuardTest(10); обновлены
  Dashboard/Lookups/LeadStatuses тесты под auth
- регрессия tests/Feature 960/964 (2 фейла pre-existing: Vite-manifest env +
  RouteSupplierLeadJobBilling idempotency — оба фейлят и на чистом base)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:15:05 +03:00

68 lines
2.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use App\Support\WebhookUrlGuard;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
});
// --- unit: WebhookUrlGuard (IP-литералы, без DNS) ---
test('WebhookUrlGuard блокирует приватные/зарезервированные/loopback IP', function (string $url) {
expect(WebhookUrlGuard::blockReason($url))->not->toBeNull();
})->with([
'https://127.0.0.1/hook', // loopback
'https://10.0.0.1/hook', // private A
'https://172.16.0.1/hook', // private B
'https://192.168.1.1/hook', // private C
'https://169.254.169.254/hook', // link-local / cloud metadata
'https://[::1]/hook', // IPv6 loopback
]);
test('WebhookUrlGuard пропускает публичный IP', function () {
expect(WebhookUrlGuard::blockReason('https://93.184.216.34/hook'))->toBeNull();
});
test('WebhookUrlGuard отклоняет битый URL', function () {
expect(WebhookUrlGuard::blockReason('not-a-url'))->not->toBeNull();
});
// --- endpoint: webhooks/test не должен бить во внутреннюю сеть ---
test('POST webhooks/test блокирует приватный IP target_url (SSRF) и не шлёт запрос', function () {
Http::fake();
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://169.254.169.254/hook',
]);
$this->postJson('/api/webhooks/test')->assertStatus(422);
Http::assertNothingSent();
});
test('POST webhooks/test пропускает публичный target_url', function () {
Http::fake(['*' => Http::response(['ok' => true], 200)]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://93.184.216.34/hook',
]);
$this->postJson('/api/webhooks/test')
->assertOk()
->assertJsonPath('ok', true);
Http::assertSentCount(1);
});