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>
7.3 KiB
Спека: усиление 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-вектор; пиннинг не применяется).
- любой A/AAAA приватный/зарезервированный →
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), где$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).
Граничные случаи и совместимость
- Аддитивность: старый путь
{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/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-секрета по флагу после миграции поставщика, либо предложить иной порядок. Это конкретный предмет рекомендации, а не пустой слот.
[
{
"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"
}
]