Files
portal/app/tests/Feature/Http/Webhook/WebhookHardeningTest.php
T
Дмитрий b81a372e8f 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>
2026-06-17 20:21:11 +03:00

93 lines
3.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
});