Files
portal/app/app/Http/Controllers/Api/SupplierWebhookController.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

207 lines
9.1 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);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Платформенный endpoint для входящего stream'а лидов от crm.bp-gr.ru.
*
* URL: POST /api/webhook/supplier/{secret}
*
* Защита (defense-in-depth, spec §5.1):
* 1. {secret} в URL — platform-wide токен (≥32 chars), хранится в
* system_settings.supplier_webhook_secret. hash_equals для сравнения.
* 2. IP allowlist (system_settings.supplier_ip_allowlist) — JSON array IP/CIDR.
* Пустой массив = пропускать всех (DEV mode); на prod заполнить.
*
* Несовпадение secret OR IP → 404 (не палим существование endpoint'а).
*
* Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
* 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
*
* Единственный приёмник входящих лидов от crm.bp-gr.ru (legacy per-tenant
* webhook был удалён вместе с ProcessWebhookJob).
*
* Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на
* production env теперь fail-closed (`verifyIpAllowlist` возвращает false если
* env=production AND allowlist пустой). На dev/testing — fail-open для localhost
* development. Admin ОБЯЗАН заполнить allowlist реальными IP/CIDR поставщика
* перед production deploy. Дополнительно: hash_equals на уровне `verifySecret`
* timing-safe только при равной длине; rate-limit per-IP добавится в Plan 3+.
*/
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
{
// Аутентификация (аддитивно): 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);
}
if (! $this->verifyIpAllowlist($request->ip())) {
$this->logSupplierWebhook($request, null, 'rejected_ip');
return response()->json(['message' => 'Not found.'], 404);
}
// Audit-fix C2: per-IP rate-limit. Endpoint secret-gated, но защищаем
// от flood даже с валидным secret (DoS-guard). Лимит с запасом для
// легитимного stream'а лидов от crm.bp-gr.ru.
$rateKey = 'supplier-webhook:'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
$retryAfter = RateLimiter::availableIn($rateKey);
$this->logSupplierWebhook($request, null, 'rate_limited');
return response()->json([
'message' => 'Превышен лимит запросов.',
'retry_after' => $retryAfter,
], 429)->header('Retry-After', (string) $retryAfter);
}
RateLimiter::hit($rateKey, 60);
// Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные
// (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH
// "no partition of relation deals found for row" в RouteSupplierLeadJob.
// Окно ±24h защищает от wildly out-of-range значений (старый/будущий
// дроп от поставщика); покрывает retry-задержки + clock-drift серверов.
$minTime = now()->subDay()->getTimestamp();
$maxTime = now()->addDay()->getTimestamp();
$validated = $request->validate([
'vid' => 'required|integer|min:1',
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
'tag' => 'nullable|string|max:255',
'phones' => 'nullable|array',
'phones.*' => 'string|regex:/^7\d{10}$/',
]);
$existing = SupplierLead::query()->where('vid', $validated['vid'])->first();
if ($existing !== null) {
return response()->json([
'status' => 'already_processed',
'supplier_lead_id' => $existing->id,
], 200);
}
$platform = $this->parsePlatform($validated['project']);
$lead = SupplierLead::create([
'platform' => $platform,
'raw_payload' => $validated,
'vid' => $validated['vid'],
'phone' => $validated['phone'],
'received_at' => now(),
'source' => 'webhook',
]);
RouteSupplierLeadJob::dispatch($lead->id);
$this->logSupplierWebhook($request, $lead->id, 'received');
return response()->json([
'status' => 'accepted',
'supplier_lead_id' => $lead->id,
], 202);
}
/**
* Audit-fix: log all supplier webhook outcomes to webhook_log.
* Covers 4 exit points: received / rejected_secret / rejected_ip / rate_limited.
* Silently skips if table does not exist (safe for migrations in progress).
*/
private function logSupplierWebhook(Request $request, ?int $leadId, string $status): void
{
if (! \Schema::hasTable('webhook_log')) {
return;
}
try {
DB::table('webhook_log')->insert([
'tenant_id' => null,
'raw_payload' => '{}',
'source' => 'supplier',
'status' => $status,
'lead_id' => $leadId,
'ip_address' => $request->ip(),
'created_at' => now(),
]);
} catch (\Throwable) {
// Never let logging failure break the primary response
}
}
private function verifySecret(string $providedSecret): bool
{
$row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
if ($row === null) {
return false;
}
$expected = (string) $row->value;
if ($expected === '__SET_ON_DEPLOY__' || strlen($expected) < 32) {
return false;
}
return hash_equals($expected, $providedSecret);
}
private function verifyIpAllowlist(?string $clientIp): bool
{
if ($clientIp === null) {
return false;
}
$row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first();
if ($row === null) {
return true;
}
$list = json_decode((string) $row->value, true) ?: [];
if ($list === []) {
// Plan 2.6 fix #ii: production env — пустой allowlist fail-closed (защита
// от забытого override schema seed); dev/testing — fail-open для localhost
// development. CV.11 audit WARN #5: inline-warning lines 34-39 признавал
// проблему, теперь enforced на уровне env.
return ! app()->environment('production');
}
return IpUtils::checkIp($clientIp, $list);
}
private function parsePlatform(string $project): string
{
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
// приводил к неверной маршрутизации).
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
return $m[1];
}
return 'DIRECT';
}
}