From 2a34ee880a6356cbe41baec27eddcaf3438cb92d 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: Thu, 21 May 2026 19:14:11 +0300 Subject: [PATCH] =?UTF-8?q?fix(security):=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=D1=8B=20+?= =?UTF-8?q?=20SSRF-=D0=B3=D0=B0=D1=80=D0=B4=20webhook=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=20go-live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- .../Controllers/Api/DashboardController.php | 7 +- .../Controllers/Api/ManagerController.php | 17 ++- .../Api/WebhookSettingsController.php | 16 ++- app/app/Support/WebhookUrlGuard.php | 106 ++++++++++++++++++ app/routes/web.php | 20 +++- app/tests/Feature/DashboardSummaryTest.php | 34 ++++-- .../Feature/EndpointAuthHardeningTest.php | 58 ++++++++++ app/tests/Feature/LeadStatusesIndexTest.php | 20 +++- app/tests/Feature/LookupsTest.php | 21 ++-- app/tests/Feature/WebhookSsrfGuardTest.php | 67 +++++++++++ 10 files changed, 320 insertions(+), 46 deletions(-) create mode 100644 app/app/Support/WebhookUrlGuard.php create mode 100644 app/tests/Feature/EndpointAuthHardeningTest.php create mode 100644 app/tests/Feature/WebhookSsrfGuardTest.php diff --git a/app/app/Http/Controllers/Api/DashboardController.php b/app/app/Http/Controllers/Api/DashboardController.php index 61de0ab3..be929e01 100644 --- a/app/app/Http/Controllers/Api/DashboardController.php +++ b/app/app/Http/Controllers/Api/DashboardController.php @@ -28,10 +28,9 @@ class DashboardController extends Controller public function summary(Request $request): JsonResponse { - $tenantId = (int) $request->query('tenant_id', '0'); - if ($tenantId < 1) { - return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422); - } + // Go-live (audit J3): tenant_id из authed-user (auth:sanctum + tenant + // middleware), НЕ из параметра запроса — закрывает кросс-tenant утечку KPI. + $tenantId = (int) $request->user()->tenant_id; $tenant = Tenant::find($tenantId); if ($tenant === null) { diff --git a/app/app/Http/Controllers/Api/ManagerController.php b/app/app/Http/Controllers/Api/ManagerController.php index 8bbb0467..ded53009 100644 --- a/app/app/Http/Controllers/Api/ManagerController.php +++ b/app/app/Http/Controllers/Api/ManagerController.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\Tenant; use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -23,20 +22,18 @@ class ManagerController extends Controller /** GET /api/managers?tenant_id={id} */ public function index(Request $request): JsonResponse { - $tenantId = (int) $request->query('tenant_id', '0'); - if ($tenantId < 1) { - return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422); - } - - $tenant = Tenant::find($tenantId); - if ($tenant === null) { - return response()->json(['message' => 'Тенант не найден.'], 404); - } + // Go-live: tenant_id из authed-user (auth:sanctum + tenant middleware), + // НЕ из параметра запроса — закрывает кросс-tenant утечку списка пользователей. + $tenantId = (int) $request->user()->tenant_id; $users = DB::transaction(function () use ($tenantId) { DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + // Явный where(tenant_id) — defense-in-depth поверх RLS: роли с + // BYPASSRLS (crm_supplier_worker / dev-superuser) RLS не применяют, + // поэтому tenant-scope нельзя оставлять только на SET LOCAL. return User::query() + ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->where('is_active', true) ->orderBy('first_name') diff --git a/app/app/Http/Controllers/Api/WebhookSettingsController.php b/app/app/Http/Controllers/Api/WebhookSettingsController.php index 3cbba832..a56bec4c 100644 --- a/app/app/Http/Controllers/Api/WebhookSettingsController.php +++ b/app/app/Http/Controllers/Api/WebhookSettingsController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\OutboundWebhookSubscription; +use App\Support\WebhookUrlGuard; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -95,14 +96,25 @@ class WebhookSettingsController extends Controller ], Response::HTTP_UNPROCESSABLE_ENTITY); } + // SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во + // внутренней/зарезервированной сети (cloud-metadata 169.254.169.254, + // loopback, RFC1918), которые https://-валидация на сохранении не ловит. + $blockReason = WebhookUrlGuard::blockReason($sub->target_url); + if ($blockReason !== null) { + return response()->json([ + 'ok' => false, + 'status' => null, + 'message' => $blockReason, + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + $testPayload = [ 'event' => 'webhook.test', 'sent_at' => now()->toIso8601String(), 'message' => 'Тестовая доставка webhook от Лидерра.', ]; - // MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных - // IP) — пост-MVP security-review; URL уже ограничен https:// валидацией. + // Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик). try { $response = Http::timeout(10) ->withHeaders(['X-Webhook-Event' => 'webhook.test']) diff --git a/app/app/Support/WebhookUrlGuard.php b/app/app/Support/WebhookUrlGuard.php new file mode 100644 index 00000000..0e164b92 --- /dev/null +++ b/app/app/Support/WebhookUrlGuard.php @@ -0,0 +1,106 @@ + Все IP, в которые разрешается хост (пусто, если не разрешается). */ + private static function resolve(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return [$host]; // IP-литерал — без DNS + } + + $ips = []; + $v4 = gethostbynamel($host); + if (is_array($v4)) { + $ips = array_merge($ips, $v4); + } + $aaaa = @dns_get_record($host, DNS_AAAA); + if (is_array($aaaa)) { + foreach ($aaaa as $rec) { + if (isset($rec['ipv6']) && is_string($rec['ipv6'])) { + $ips[] = $rec['ipv6']; + } + } + } + + return array_values(array_unique($ips)); + } + + private static function isPublicIp(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + return filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) !== false; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + $lower = strtolower($ip); + // loopback / unspecified + if ($lower === '::1' || $lower === '::') { + return false; + } + // link-local fe80::/10 + if (preg_match('/^fe[89ab]/', $lower) === 1) { + return false; + } + // unique-local fc00::/7 + if ($lower[0] === 'f' && in_array($lower[1], ['c', 'd'], true)) { + return false; + } + // IPv4-mapped ::ffff:a.b.c.d — проверить встроенный IPv4 + if (str_contains($lower, '::ffff:')) { + $v4 = substr($lower, (int) strrpos($lower, ':') + 1); + if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + return self::isPublicIp($v4); + } + } + + return filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) !== false; + } + + return false; + } +} diff --git a/app/routes/web.php b/app/routes/web.php index e3fa4ba7..1d5e19ea 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -194,9 +194,12 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () { Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test'); }); -// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без -// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant'). -Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary'); +// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). Go-live: auth:sanctum +// + tenant; tenant_id из auth()->user()->tenant_id (SetTenantContext), НЕ из параметра +// запроса — закрывает кросс-tenant утечку KPI (как DealController J1). +Route::middleware(['auth:sanctum', 'tenant'])->group(function () { + Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary'); +}); // Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit): // auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id @@ -228,8 +231,15 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () { }); // Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters). -Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index'); -Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index'); +// Go-live: auth:sanctum. /api/managers — tenant-scoped (tenant_id из authed-user, НЕ из +// параметра — закрывает кросс-tenant утечку списка пользователей); /api/lead-statuses — +// глобальная таблица (без tenant_id), нужен только auth:sanctum. +Route::middleware(['auth:sanctum', 'tenant'])->group(function () { + Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index'); +}); +Route::middleware('auth:sanctum')->group(function () { + Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index'); +}); // Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS. // Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия). diff --git a/app/tests/Feature/DashboardSummaryTest.php b/app/tests/Feature/DashboardSummaryTest.php index a07c71f8..7aa82bfb 100644 --- a/app/tests/Feature/DashboardSummaryTest.php +++ b/app/tests/Feature/DashboardSummaryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use App\Models\Deal; use App\Models\Project; use App\Models\Tenant; +use App\Models\User; use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -36,12 +37,14 @@ function makeDashboardDeal( ]); } -it('422 без tenant_id', function () { - $this->getJson('/api/dashboard/summary')->assertStatus(422); -}); +/** Авторизоваться как пользователь данного тенанта (auth:sanctum + tenant). */ +function actingForTenant(Tenant $tenant): void +{ + test()->actingAs(User::factory()->for($tenant)->create()); +} -it('404 для несуществующего тенанта', function () { - $this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404); +it('401 без авторизации', function () { + $this->getJson('/api/dashboard/summary')->assertStatus(401); }); it('возвращает структуру summary с range по умолчанию 7d', function () { @@ -50,7 +53,8 @@ it('возвращает структуру summary с range по умолчан 'balance_rub' => '14250.00', 'balance_leads' => 285, ]); - $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}") + actingForTenant($tenant); + $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('range', '7d') ->assertJsonPath('balance.amount_rub', '14250.00') @@ -67,6 +71,7 @@ it('возвращает структуру summary с range по умолчан it('leads_received считает только сделки окна, без deleted и is_test', function () { $tenant = Tenant::factory()->create(); + actingForTenant($tenant); $project = Project::factory()->create(['tenant_id' => $tenant->id]); // 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад) makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); @@ -76,30 +81,32 @@ it('leads_received считает только сделки окна, без del makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true); makeDashboardDeal($tenant, $project, 'new', now()->subDays(8)); - $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d") + $this->getJson('/api/dashboard/summary?range=7d') ->assertOk() ->assertJsonPath('leads_received.value', 3); }); it('conversion = доля статуса won в окне', function () { $tenant = Tenant::factory()->create(); + actingForTenant($tenant); $project = Project::factory()->create(['tenant_id' => $tenant->id]); makeDashboardDeal($tenant, $project, 'won', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); // 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби) - $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}") + $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('conversion.value', 25); }); it('active_projects считает is_active=true + limit из limits', function () { $tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]); + actingForTenant($tenant); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); - $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}") + $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('active_projects.active', 2) ->assertJsonPath('active_projects.limit', 10); @@ -107,11 +114,12 @@ it('active_projects считает is_active=true + limit из limits', function it('funnel группирует живые сделки по статусу', function () { $tenant = Tenant::factory()->create(); + actingForTenant($tenant); $project = Project::factory()->create(['tenant_id' => $tenant->id]); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'won', now()->subDays(1)); - $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}") + $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('funnel.new', 2) ->assertJsonPath('funnel.won', 1); @@ -119,7 +127,8 @@ it('funnel группирует живые сделки по статусу', fu it('activity возвращает 7 точек и 7 меток', function () { $tenant = Tenant::factory()->create(); - $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}") + actingForTenant($tenant); + $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonCount(7, 'activity.points') ->assertJsonCount(7, 'activity.labels'); @@ -129,11 +138,12 @@ it('runway_days использует фикс. 7д-окно независимо // balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70. // Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно). $tenant = Tenant::factory()->create(['balance_leads' => 70]); + actingForTenant($tenant); $project = Project::factory()->create(['tenant_id' => $tenant->id]); for ($i = 0; $i <= 6; $i++) { makeDashboardDeal($tenant, $project, 'new', now()->subDays($i)); } - $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=today") + $this->getJson('/api/dashboard/summary?range=today') ->assertOk() ->assertJsonPath('balance.runway_days', 70); }); diff --git a/app/tests/Feature/EndpointAuthHardeningTest.php b/app/tests/Feature/EndpointAuthHardeningTest.php new file mode 100644 index 00000000..aebcbade --- /dev/null +++ b/app/tests/Feature/EndpointAuthHardeningTest.php @@ -0,0 +1,58 @@ +getJson('/api/dashboard/summary')->assertStatus(401); +}); + +test('GET /api/managers без авторизации возвращает 401', function () { + $this->getJson('/api/managers')->assertStatus(401); +}); + +test('GET /api/lead-statuses без авторизации возвращает 401', function () { + $this->getJson('/api/lead-statuses')->assertStatus(401); +}); + +// --- cross-tenant: tenant_id из user, параметр чужого тенанта игнорируется --- + +test('dashboard/summary берёт tenant из authed-user, игнорирует ?tenant_id чужого', function () { + $mine = Tenant::factory()->create(['balance_rub' => '111.00', 'balance_leads' => 11]); + $other = Tenant::factory()->create(['balance_rub' => '999.00', 'balance_leads' => 99]); + $this->actingAs(User::factory()->for($mine)->create()); + + $this->getJson("/api/dashboard/summary?tenant_id={$other->id}") + ->assertOk() + ->assertJsonPath('balance.amount_rub', '111.00'); +}); + +test('managers берёт tenant из authed-user, не отдаёт пользователей чужого тенанта', function () { + $mine = Tenant::factory()->create(); + $other = Tenant::factory()->create(); + $me = User::factory()->for($mine)->create(['first_name' => 'Свой', 'last_name' => 'Менеджер', 'is_active' => true]); + User::factory()->for($other)->create(['first_name' => 'Чужой', 'last_name' => 'Менеджер', 'is_active' => true]); + $this->actingAs($me); + + $names = $this->getJson("/api/managers?tenant_id={$other->id}") + ->assertOk() + ->json('managers.*.name'); + + expect($names)->toContain('Свой М.'); + expect($names)->not->toContain('Чужой М.'); +}); diff --git a/app/tests/Feature/LeadStatusesIndexTest.php b/app/tests/Feature/LeadStatusesIndexTest.php index b52808db..e20f8a88 100644 --- a/app/tests/Feature/LeadStatusesIndexTest.php +++ b/app/tests/Feature/LeadStatusesIndexTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Models\Tenant; +use App\Models\User; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; @@ -9,11 +11,23 @@ use Illuminate\Support\Facades\DB; * Тесты GET /api/lead-statuses — глобальный lookup статусов воронки. * * Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных - * статусов воронки: new/viewed/in_progress/won/lost). + * статусов воронки: new/viewed/in_progress/won/lost). Go-live: эндпоинт за + * auth:sanctum (глобальная таблица — tenant-middleware не нужен). */ uses(DatabaseTransactions::class); +/** Авторизоваться любым пользователем (lead-statuses требует только auth:sanctum). */ +function authLeadStatuses(): void +{ + test()->actingAs(User::factory()->for(Tenant::factory())->create()); +} + +test('GET /api/lead-statuses без авторизации возвращает 401', function () { + $this->getJson('/api/lead-statuses')->assertStatus(401); +}); + test('GET /api/lead-statuses возвращает 200 и не пустой список', function () { + authLeadStatuses(); $r = $this->getJson('/api/lead-statuses'); $r->assertStatus(200); @@ -22,6 +36,7 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп }); test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () { + authLeadStatuses(); $r = $this->getJson('/api/lead-statuses'); $slugs = collect($r->json('lead_statuses'))->pluck('slug')->all(); @@ -32,6 +47,7 @@ test('GET /api/lead-statuses возвращает все 5 системных с }); test('GET /api/lead-statuses возвращает поля slug, name_ru, color_hex, sort_order, is_system', function () { + authLeadStatuses(); $r = $this->getJson('/api/lead-statuses'); $first = $r->json('lead_statuses.0'); @@ -42,6 +58,7 @@ test('GET /api/lead-statuses возвращает поля slug, name_ru, color_ }); test('GET /api/lead-statuses сортирует по sort_order', function () { + authLeadStatuses(); $r = $this->getJson('/api/lead-statuses'); $sortOrders = collect($r->json('lead_statuses'))->pluck('sort_order')->all(); @@ -51,6 +68,7 @@ test('GET /api/lead-statuses сортирует по sort_order', function () { }); test('GET /api/lead-statuses включает кастомный slug, добавленный после seed', function () { + authLeadStatuses(); DB::table('lead_statuses')->insert([ 'slug' => 'custom_test_'.bin2hex(random_bytes(3)), 'name_ru' => 'Кастомный тест', diff --git a/app/tests/Feature/LookupsTest.php b/app/tests/Feature/LookupsTest.php index 90af4587..181ad0e0 100644 --- a/app/tests/Feature/LookupsTest.php +++ b/app/tests/Feature/LookupsTest.php @@ -15,7 +15,8 @@ beforeEach(function () { test('GET /api/managers возвращает active users тенанта', function () { DB::statement('SET app.current_tenant_id = '.$this->tenant->id); - User::factory()->for($this->tenant)->create([ + // actingAs одного из активных пользователей тенанта — он сам входит в список. + $ivan = User::factory()->for($this->tenant)->create([ 'first_name' => 'Иван', 'last_name' => 'Петров', 'is_active' => true, ]); User::factory()->for($this->tenant)->create([ @@ -25,7 +26,8 @@ test('GET /api/managers возвращает active users тенанта', funct 'first_name' => 'Удалённый', 'is_active' => false, ]); - $r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id); + $this->actingAs($ivan); + $r = $this->getJson('/api/managers'); $r->assertStatus(200); $managers = $r->json('managers'); expect($managers)->toHaveCount(2); @@ -35,28 +37,23 @@ test('GET /api/managers возвращает active users тенанта', funct test('GET /api/managers возвращает initials с fallback на email', function () { DB::statement('SET app.current_tenant_id = '.$this->tenant->id); - User::factory()->for($this->tenant)->create([ + $admin = User::factory()->for($this->tenant)->create([ 'email' => 'admin@example.ru', 'first_name' => null, 'last_name' => null, 'is_active' => true, ]); - $r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id); + $this->actingAs($admin); + $r = $this->getJson('/api/managers'); $r->assertStatus(200); $manager = $r->json('managers.0'); expect($manager['name'])->toBe('admin@example.ru'); expect($manager['initials'])->toBe('AD'); }); -test('GET /api/managers 422 без tenant_id', function () { - $r = $this->getJson('/api/managers'); - $r->assertStatus(422); -}); - -test('GET /api/managers 404 unknown tenant', function () { - $r = $this->getJson('/api/managers?tenant_id=999999'); - $r->assertStatus(404); +test('GET /api/managers без авторизации возвращает 401', function () { + $this->getJson('/api/managers')->assertStatus(401); }); test('POST /api/deals 422 если manager_id не принадлежит tenant\'у', function () { diff --git a/app/tests/Feature/WebhookSsrfGuardTest.php b/app/tests/Feature/WebhookSsrfGuardTest.php new file mode 100644 index 00000000..6dd90562 --- /dev/null +++ b/app/tests/Feature/WebhookSsrfGuardTest.php @@ -0,0 +1,67 @@ +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); +});