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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
Reference in New Issue
Block a user