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 00cef7635b
commit f32366279f
8 changed files with 352 additions and 9 deletions
@@ -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);
@@ -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);
+28
View File
@@ -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<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
private static function resolve(string $host): array
{
+3
View File
@@ -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_\-]+');
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SystemSetting;
use App\Support\WebhookUrlGuard;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
uses(DatabaseTransactions::class);
beforeEach(function () {
SystemSetting::query()->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);
});
@@ -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-секрета после миграции поставщика.
@@ -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` (формат `<hex>` или
`sha256=<hex>`) = `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 — см. статус сессии.
@@ -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` (формат `<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"
}
]
```