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>
This commit is contained in:
Дмитрий
2026-06-17 20:21:11 +03:00
parent a0048448e1
commit b81a372e8f
8 changed files with 352 additions and 9 deletions
@@ -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);