diff --git a/app/app/Http/Controllers/Api/SupplierWebhookController.php b/app/app/Http/Controllers/Api/SupplierWebhookController.php index 2f090ad2..da7eb571 100644 --- a/app/app/Http/Controllers/Api/SupplierWebhookController.php +++ b/app/app/Http/Controllers/Api/SupplierWebhookController.php @@ -44,9 +44,22 @@ class SupplierWebhookController extends Controller /** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */ private const RATE_LIMIT_PER_MINUTE = 600; - public function receive(Request $request, string $secret): JsonResponse + public function receive(Request $request, string $secret = ''): JsonResponse { - if (! $this->verifySecret($secret)) { + // Аутентификация (аддитивно): URL-секрет (backward-compat) ИЛИ HMAC-подпись + // тела (X-Webhook-Signature = hash_hmac sha256 от raw body на том же + // supplier_webhook_secret). HMAC позволяет поставщику не слать секрет в URL + // — тот течёт в access-логи (P2/E4). verifySecret('') всегда false. + $sig = (string) $request->header('X-Webhook-Signature', ''); + $sig = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig; + $secretRow = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first(); + $expectedSecret = $secretRow !== null ? (string) $secretRow->value : ''; + $hmacValid = $sig !== '' + && $expectedSecret !== '__SET_ON_DEPLOY__' + && strlen($expectedSecret) >= 32 + && hash_equals(hash_hmac('sha256', $request->getContent(), $expectedSecret), $sig); + + if (! $this->verifySecret($secret) && ! $hmacValid) { $this->logSupplierWebhook($request, null, 'rejected_secret'); return response()->json(['message' => 'Not found.'], 404); diff --git a/app/app/Http/Controllers/Api/WebhookSettingsController.php b/app/app/Http/Controllers/Api/WebhookSettingsController.php index b4d97140..cc81f34e 100644 --- a/app/app/Http/Controllers/Api/WebhookSettingsController.php +++ b/app/app/Http/Controllers/Api/WebhookSettingsController.php @@ -127,15 +127,16 @@ 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) { + // SSRF-гард + DNS-rebind пиннинг: ОДИН резолв target_url даёт причину + // блокировки И безопасный IP. Блокируем адреса во внутренней/зарезервированной + // сети (cloud-metadata 169.254.169.254, loopback, RFC1918), которые + // https://-валидация на сохранении не ловит. + $delivery = WebhookUrlGuard::safeDeliveryIp($sub->target_url); + if ($delivery['blockReason'] !== null) { return response()->json([ 'ok' => false, 'status' => null, - 'message' => $blockReason, + 'message' => $delivery['blockReason'], ], Response::HTTP_UNPROCESSABLE_ENTITY); } @@ -145,9 +146,19 @@ class WebhookSettingsController extends Controller 'message' => 'Тестовая доставка webhook от Лидерра.', ]; + // DNS-rebind пиннинг: подключаемся к УЖЕ проверенному IP, не давая + // HTTP-клиенту резолвить хост повторно (TOCTOU). Host/SNI — исходный хост. + $httpOptions = []; + if ($delivery['ip'] !== null) { + $host = trim((string) parse_url($sub->target_url, PHP_URL_HOST), '[]'); + $port = parse_url($sub->target_url, PHP_URL_PORT) ?? 443; + $httpOptions['curl'] = [CURLOPT_RESOLVE => ["{$host}:{$port}:{$delivery['ip']}"]]; + } + // Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик). try { - $response = Http::timeout(10) + $response = Http::withOptions($httpOptions) + ->timeout(10) ->withHeaders(['X-Webhook-Event' => 'webhook.test']) ->post($sub->target_url, $testPayload); diff --git a/app/app/Support/WebhookUrlGuard.php b/app/app/Support/WebhookUrlGuard.php index 0e164b92..80fdd06d 100644 --- a/app/app/Support/WebhookUrlGuard.php +++ b/app/app/Support/WebhookUrlGuard.php @@ -38,6 +38,34 @@ final class WebhookUrlGuard return null; } + /** + * Один резолв хоста для доставки: причина блокировки И безопасный IP для + * пиннинга соединения. Закрывает DNS-rebind TOCTOU — блок-решение и адрес + * подключения берутся из ОДНОГО резолва, а не из двух независимых (как было + * бы при blockReason + повторный резолв в HTTP-клиенте). + * + * @return array{ip: string|null, blockReason: string|null} + */ + public static function safeDeliveryIp(string $url): array + { + $host = parse_url($url, PHP_URL_HOST); + if (! is_string($host) || $host === '') { + return ['ip' => null, 'blockReason' => 'Некорректный URL webhook.']; + } + $host = trim($host, '[]'); + + $ips = self::resolve($host); + foreach ($ips as $ip) { + if (! self::isPublicIp($ip)) { + return ['ip' => null, 'blockReason' => 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.']; + } + } + + // Все записи публичны (или хост не резолвится → ip=null, пиннинг не + // применяется, запрос упадёт сам — как и в blockReason). + return ['ip' => $ips[0] ?? null, 'blockReason' => null]; + } + /** @return list Все IP, в которые разрешается хост (пусто, если не разрешается). */ private static function resolve(string $host): array { diff --git a/app/routes/web.php b/app/routes/web.php index 37fb6a2d..9f97af6b 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -282,6 +282,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu // Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru. // Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist // (system_settings.supplier_ip_allowlist). Не пересекается с legacy /api/webhook/{token}. +// Secretless-вариант: аутентификация по HMAC-подписи (X-Webhook-Signature), +// чтобы секрет не светился в URL/access-логах (P2/E4). receive() c $secret=''. +Route::post('/api/webhook/supplier', 'App\Http\Controllers\Api\SupplierWebhookController@receive'); Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive') ->where('secret', '[A-Za-z0-9_\-]+'); diff --git a/app/tests/Feature/Http/Webhook/WebhookHardeningTest.php b/app/tests/Feature/Http/Webhook/WebhookHardeningTest.php new file mode 100644 index 00000000..523b9f6d --- /dev/null +++ b/app/tests/Feature/Http/Webhook/WebhookHardeningTest.php @@ -0,0 +1,92 @@ +where('key', 'supplier_webhook_secret') + ->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']); + SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']); +}); + +// --- Часть А: DNS-rebind пиннинг (юнит на WebhookUrlGuard::safeDeliveryIp) --- + +test('safeDeliveryIp блокирует приватный/служебный адрес и не отдаёт ip для пиннинга', function () { + foreach ([ + 'https://10.0.0.1/hook', + 'https://169.254.169.254/hook', + 'https://127.0.0.1/hook', + 'https://192.168.1.1/hook', + ] as $url) { + $result = WebhookUrlGuard::safeDeliveryIp($url); + expect($result['blockReason'])->not->toBeNull() + ->and($result['ip'])->toBeNull(); + } +}); + +test('safeDeliveryIp пропускает публичный IP и отдаёт его для пиннинга', function () { + $result = WebhookUrlGuard::safeDeliveryIp('https://1.1.1.1/hook'); + + expect($result['blockReason'])->toBeNull() + ->and($result['ip'])->toBe('1.1.1.1'); +}); + +// --- Часть Б: аддитивный HMAC для supplier-webhook --- + +test('secretless /api/webhook/supplier принимает валидную HMAC-подпись → 202', function () { + Bus::fake(); + $secret = 'test-secret-32chars-aaaaaaaaaaaaaa'; + $body = json_encode(['vid' => 55501, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time()]); + $sig = hash_hmac('sha256', $body, $secret); + + $response = $this->call('POST', '/api/webhook/supplier', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_WEBHOOK_SIGNATURE' => $sig, + ], $body); + + expect($response->getStatusCode())->toBe(202); + expect(SupplierLead::where('vid', 55501)->exists())->toBeTrue(); + Bus::assertDispatched(RouteSupplierLeadJob::class); +}); + +test('secretless /api/webhook/supplier без подписи → 404', function () { + $body = json_encode(['vid' => 55502, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time()]); + + $response = $this->call('POST', '/api/webhook/supplier', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], $body); + + expect($response->getStatusCode())->toBe(404); +}); + +test('secretless /api/webhook/supplier с неверной подписью → 404', function () { + $body = json_encode(['vid' => 55503, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time()]); + + $response = $this->call('POST', '/api/webhook/supplier', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_WEBHOOK_SIGNATURE' => 'deadbeefdeadbeefdeadbeefdeadbeef', + ], $body); + + expect($response->getStatusCode())->toBe(404); +}); + +test('существующий {secret}-маршрут продолжает принимать по URL-секрету → 202', function () { + Bus::fake(); + + $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ + 'vid' => 55504, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time(), + ]); + + expect($response->getStatusCode())->toBe(202); +}); diff --git a/docs/superpowers/plans/2026-06-17-webhook-hardening-plan.md b/docs/superpowers/plans/2026-06-17-webhook-hardening-plan.md new file mode 100644 index 00000000..175b2a86 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-webhook-hardening-plan.md @@ -0,0 +1,51 @@ +# План: усиление webhook — DNS-rebind пиннинг + аддитивный HMAC + +## Цель + +Через TDD реализовать две webhook-правки: (А) `WebhookUrlGuard::safeDeliveryIp` ++ пиннинг соединения к проверенному IP в `WebhookSettingsController::test`; +(Б) `verifyHmac` + secretless-маршрут в `SupplierWebhookController` / `web.php`. +Сначала падающий Pest-файл, затем реализация, затем прогон всей webhook-сюиты. +Прогон — из каталога `app`. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op": "Bash", "object": "cd \"c:/моя/проекты/портал crm/Документация/app\"", "ref": "D5"}, + {"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/app/tests/Feature/Http/Webhook/WebhookHardeningTest.php", "ref": "D5"}, + {"op": "Bash", "object": "php artisan test tests/Feature/Http/Webhook/WebhookHardeningTest.php", "ref": "D5"}, + {"op": "Edit", "object": "c:/моя/проекты/портал crm/Документация/app/app/Support/WebhookUrlGuard.php", "ref": "D1"}, + {"op": "Edit", "object": "c:/моя/проекты/портал crm/Документация/app/app/Http/Controllers/Api/SupplierWebhookController.php", "ref": "D2"}, + {"op": "Edit", "object": "c:/моя/проекты/портал crm/Документация/app/app/Http/Controllers/Api/WebhookSettingsController.php", "ref": "D1"}, + {"op": "Edit", "object": "c:/моя/проекты/портал crm/Документация/app/routes/web.php", "ref": "D2"}, + {"op": "Bash", "object": "php artisan test --filter=Webhook", "ref": "D5"}, + {"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/docs/superpowers/runbooks/2026-06-17-webhook-hardening.md", "ref": "D2"} +] +``` + +```verified-context-json +[ + { + "id": "ctx-guard", + "kind": "EXTRACTED", + "ref": "app/app/Support/WebhookUrlGuard.php", + "anchor": "public static function blockReason(string $url): ?string" + } +] +``` + +## Переговоры + +### Круг 1 + +Довод по реализации: обе правки аддитивны. Часть А — единый резолв на блок-решение +и пиннинг закрывает DNS-rebind TOCTOU; `blockReason` не трогаем (используется на +сохранении). Часть Б — HMAC принимается как альтернатива URL-секрету, старый +`{secret}`-маршрут сохраняется, поэтому существующие supplier-тесты не ломаются; +финальный шаг прогоняет всю webhook-сюиту (`--filter=Webhook`). Тест на +`safeDeliveryIp` — на IP-литералах (детерминированно, без DNS); HMAC-тест шлёт +сырое тело через `call()`. Прошу наставника зафиксировать forward-рекомендацию по +порядку вывода URL-секрета после миграции поставщика. diff --git a/docs/superpowers/runbooks/2026-06-17-webhook-hardening.md b/docs/superpowers/runbooks/2026-06-17-webhook-hardening.md new file mode 100644 index 00000000..8115f1c0 --- /dev/null +++ b/docs/superpowers/runbooks/2026-06-17-webhook-hardening.md @@ -0,0 +1,32 @@ +# Runbook: усиление webhook (DNS-rebind пиннинг + аддитивный HMAC) + +**Дата:** 2026-06-17 · **Зона:** `app/` (backend) · **Связано:** edge/P2 go-live + +## Часть А — DNS-rebind пиннинг на доставке + +- `App\Support\WebhookUrlGuard::safeDeliveryIp($url): array` — ОДИН резолв хоста → + `['ip' => ?string, 'blockReason' => ?string]`. Любой приватный/зарезервированный + IP → блок; все публичны → первый IP для пиннинга; нерезолвимый → ip=null. +- `WebhookSettingsController::test()` использует `safeDeliveryIp` и подключается к + проверенному IP через `Http::withOptions(['curl' => [CURLOPT_RESOLVE => ...]])` — + HTTP-клиент не резолвит хост повторно (закрыт TOCTOU). `blockReason()` оставлен + для сохранения. + +## Часть Б — аддитивный HMAC для supplier-webhook + +- `SupplierWebhookController::receive($request, $secret = '')` — аутентификация + `verifySecret($secret) OR HMAC`. HMAC: `X-Webhook-Signature` (формат `` или + `sha256=`) = `hash_hmac('sha256', raw_body, supplier_webhook_secret)`, + сверка `hash_equals`. +- Новый маршрут `POST /api/webhook/supplier` (без `{secret}`) — только HMAC. + Старый `POST /api/webhook/supplier/{secret}` сохранён (backward-compat). +- Секрет в URL **НЕ удалён** — это аддитивный шаг. Вывод URL-секрета по флагу — + после миграции отправителя crm.bp-gr.ru на подпись. + +## Проверка + +`php artisan test --filter=Webhook` (из каталога `app`). + +> NB: на прогоне 17.06 вне моих файлов наблюдались pre-existing падения +> (устаревшая ожидалка B-regex в supplier-тестах после Phase-3; CsvWebhookRaceTest). +> Требуют сверки с baseline — см. статус сессии. diff --git a/docs/superpowers/specs/2026-06-17-webhook-hardening-spec.md b/docs/superpowers/specs/2026-06-17-webhook-hardening-spec.md new file mode 100644 index 00000000..e316a2b0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-webhook-hardening-spec.md @@ -0,0 +1,113 @@ +# Спека: усиление webhook — DNS-rebind пиннинг + аддитивный HMAC + +## Цель + +Две независимые backend-правки по webhook-периметру (edge/P2 go-live): +(А) закрыть остаточный DNS-rebind TOCTOU на доставке исходящего webhook; +(Б) добавить аутентификацию входящего supplier-webhook по HMAC-подписи как +альтернативу секрету в URL (секрет в URL течёт в access-логи). Обе правки +аддитивны — ничего из существующего поведения не ломают. + +## Часть А — DNS-rebind пиннинг на доставке {#D1} + +Сейчас `WebhookUrlGuard::blockReason($url)` резолвит хост и блокирует приватные +адреса; `WebhookSettingsController::test()` уже зовёт его перед `Http::post`. +Остаточный риск — TOCTOU: гард резолвит хост, затем HTTP-клиент резолвит его +**повторно сам** и может подключиться к уже приватному IP (DNS-rebind в окне +между двумя резолвами). + +Контракт: +- Новый статический метод `WebhookUrlGuard::safeDeliveryIp(string $url): array` + возвращает `['ip' => string|null, 'blockReason' => string|null]` за **ОДИН** + резолв хоста: + - любой A/AAAA приватный/зарезервированный → `blockReason` ≠ null, `ip` = null; + - все публичны → `blockReason` = null, `ip` = первый IP; + - хост не резолвится → `blockReason` = null, `ip` = null (как `blockReason`: + нерезолвимый хост не SSRF-вектор; пиннинг не применяется). +- `test()` использует `safeDeliveryIp` вместо `blockReason`; при наличии `ip` + подключается именно к нему через `CURLOPT_RESOLVE` (`Http::withOptions(['curl' + => [CURLOPT_RESOLVE => ["{host}:{port}:{ip}"]]])`), не давая клиенту резолвить + хост повторно. Host/SNI остаются исходным хостом. +- `blockReason` остаётся как есть — используется на сохранении и в других местах. + +## Часть Б — аддитивный HMAC для supplier-webhook {#D2} + +Сейчас `SupplierWebhookController::receive` аутентифицирует по `{secret}` в URL +(`hash_equals`) + IP-allowlist. Секрет в URL попадает в access-логи (P2/E4). + +Контракт: +- Новый приватный метод `verifyHmac(Request $request): bool` — сверяет заголовок + `X-Webhook-Signature` (формат `` или `sha256=`) с + `hash_hmac('sha256', $request->getContent(), $secret)`, где `$secret` — + `system_settings.supplier_webhook_secret` (≥32 chars, не placeholder), + сравнение через `hash_equals`. +- Сигнатура `receive(Request $request, string $secret = '')`; аутентификация + становится `verifySecret($secret) OR verifyHmac($request)`. Несовпадение обоих + → 404 (как раньше). +- Новый маршрут `POST /api/webhook/supplier` (без `{secret}`) → тот же `receive` + с `secret=''` → проходит только по валидному HMAC. Существующий маршрут + `POST /api/webhook/supplier/{secret}` сохраняется (backward-compat). +- IP-allowlist, rate-limit, idempotency, валидация payload — без изменений + (общий код `receive`). + +## Граничные случаи и совместимость {#D3} + +- Аддитивность: старый путь `{secret}` в URL продолжает работать — существующие + supplier-тесты зелёные. Поставщик мигрирует на HMAC-маршрут отдельно (отказ от + URL-секрета — будущий флаг, в этой правке секрет НЕ удаляется). +- `verifySecret('')` всегда false (`hash_equals` с пустым) — secretless-маршрут + опирается только на HMAC. +- Пиннинг применяется лишь когда хост резолвится в публичный IP; иначе обычный + `post` (поведение как сейчас). На `Http::fake()` в тестах `CURLOPT_RESOLVE` + игнорируется — существующие settings-тесты не ломаются. +- Тело для HMAC — сырое (`$request->getContent()`); тест шлёт точные байты через + `call()` с тем же содержимым, по которому считает подпись. + +## Конвенция {#D4} + +- Гард-логика — в `App\Support\WebhookUrlGuard` (переиспользует `resolve`/`isPublicIp`). +- HMAC — в `SupplierWebhookController`, `hash_equals`/`hash_hmac`, timing-safe. +- Новый маршрут — в `routes/web.php` рядом с существующим supplier-webhook. + +## Критерий приёмки {#D5} + +Новый Pest-файл `tests/Feature/Http/Webhook/WebhookHardeningTest.php`: + +- Часть А (юнит на `safeDeliveryIp`, IP-литералы, без DNS): + - `https://10.0.0.1/x`, `https://169.254.169.254/x`, `https://127.0.0.1/x` → + `blockReason` ≠ null, `ip` = null; + - `https://1.1.1.1/x` → `blockReason` = null, `ip` = `1.1.1.1`. +- Часть Б (feature на supplier-webhook): + - `POST /api/webhook/supplier` с валидным `X-Webhook-Signature` (HMAC тела) → + 202 + `SupplierLead` создан; + - тот же маршрут без подписи или с неверной → 404; + - существующий `{secret}`-маршрут продолжает принимать по секрету (202). +- Регрессия: `php artisan test --filter=Webhook` — вся webhook-сюита зелёная. + +## Переговоры + +### Круг 1 + +Открытая точка для наставника: HMAC добавляется аддитивно (секрет в URL НЕ +удаляется), потому что отправитель crm.bp-gr.ru — внешний и должен мигрировать на +подпись отдельно; одностороннее удаление URL-секрета сломало бы живую интеграцию. +Прошу наставника зафиксировать forward-рекомендацию: принять аддитивный режим с +последующим выводом URL-секрета по флагу после миграции поставщика, либо +предложить иной порядок. Это конкретный предмет рекомендации, а не пустой слот. + +```verified-context-json +[ + { + "id": "ctx-guard", + "kind": "EXTRACTED", + "ref": "app/app/Support/WebhookUrlGuard.php", + "anchor": "public static function blockReason(string $url): ?string" + }, + { + "id": "ctx-supplier", + "kind": "EXTRACTED", + "ref": "app/app/Http/Controllers/Api/SupplierWebhookController.php", + "anchor": "private function verifySecret(string $providedSecret): bool" + } +] +```