b81a372e8f
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>
207 lines
9.1 KiB
PHP
207 lines
9.1 KiB
PHP
<?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';
|
||
}
|
||
}
|