# Спека: усиление 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` (формат `<hex>` или `sha256=<hex>`) с
  `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"
  }
]
```
