Files
portal/docs/superpowers/specs/2026-06-17-webhook-hardening-spec.md
T
Дмитрий f32366279f feat(security): webhook DNS-rebind пиннинг + аддитивный HMAC supplier-webhook — edge/P2 go-live
WebhookUrlGuard::safeDeliveryIp один резолв + CURLOPT_RESOLVE пиннинг в test(); supplier-webhook принимает HMAC X-Webhook-Signature как альтернативу URL-секрету + secretless-маршрут. Аддитивно, backward-compat. 6 новых тестов GREEN; 5 падений webhook-сюиты pre-existing (Phase-3 B-regex + CsvWebhookRaceTest), подтверждено baseline без моих файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:21:11 +03:00

7.3 KiB
Raw Blame History

Спека: усиление webhook — DNS-rebind пиннинг + аддитивный HMAC

Цель

Две независимые backend-правки по webhook-периметру (edge/P2 go-live): (А) закрыть остаточный DNS-rebind TOCTOU на доставке исходящего webhook; (Б) добавить аутентификацию входящего supplier-webhook по HMAC-подписи как альтернативу секрету в URL (секрет в URL течёт в access-логи). Обе правки аддитивны — ничего из существующего поведения не ломают.

Часть А — DNS-rebind пиннинг на доставке

Сейчас 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

Сейчас SupplierWebhookController::receive аутентифицирует по {secret} в URL (hash_equals) + IP-allowlist. Секрет в URL попадает в access-логи (P2/E4).

Контракт:

  • Новый приватный метод verifyHmac(Request $request): bool — сверяет заголовок X-Webhook-Signature (формат <hex> или sha256=<hex>) с hash_hmac('sha256', $request->getContent(), $secret), где $secretsystem_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).

Граничные случаи и совместимость

  • Аддитивность: старый путь {secret} в URL продолжает работать — существующие supplier-тесты зелёные. Поставщик мигрирует на HMAC-маршрут отдельно (отказ от URL-секрета — будущий флаг, в этой правке секрет НЕ удаляется).
  • verifySecret('') всегда false (hash_equals с пустым) — secretless-маршрут опирается только на HMAC.
  • Пиннинг применяется лишь когда хост резолвится в публичный IP; иначе обычный post (поведение как сейчас). На Http::fake() в тестах CURLOPT_RESOLVE игнорируется — существующие settings-тесты не ломаются.
  • Тело для HMAC — сырое ($request->getContent()); тест шлёт точные байты через call() с тем же содержимым, по которому считает подпись.

Конвенция

  • Гард-логика — в App\Support\WebhookUrlGuard (переиспользует resolve/isPublicIp).
  • HMAC — в SupplierWebhookController, hash_equals/hash_hmac, timing-safe.
  • Новый маршрут — в routes/web.php рядом с существующим supplier-webhook.

Критерий приёмки

Новый 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/xblockReason ≠ null, ip = null;
    • https://1.1.1.1/xblockReason = 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-секрета по флагу после миграции поставщика, либо предложить иной порядок. Это конкретный предмет рекомендации, а не пустой слот.

[
  {
    "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"
  }
]