1fd6f7f597
- A2: impersonation _dev_plain_code в ответе init только в local/testing - A3: X-Tenant-Id принимается только в local/testing (anti-spoof тенанта) - B3: WebhookReceiveController isHmacRequired() default false→true (fail-secure) - C2: SupplierWebhookController per-IP rate-limit 600/min (DoS-guard) - WebhookReceiveTest обновлён под B3 (отсутствие настройки → 401) Tests: 70/70 passed (323 assertions) — Webhook/Impersonation/Tenant suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
6.6 KiB
PHP
158 lines
6.6 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 (поставщик может ретранслировать одни и те же лиды).
|
||
*
|
||
* Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно
|
||
* на WebhookReceiveController — не пересекается.
|
||
*
|
||
* 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
|
||
{
|
||
if (! $this->verifySecret($secret)) {
|
||
return response()->json(['message' => 'Not found.'], 404);
|
||
}
|
||
|
||
if (! $this->verifyIpAllowlist($request->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);
|
||
|
||
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', 'regex:/^B[123]_.+$/'],
|
||
'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);
|
||
|
||
return response()->json([
|
||
'status' => 'accepted',
|
||
'supplier_lead_id' => $lead->id,
|
||
], 202);
|
||
}
|
||
|
||
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
|
||
{
|
||
preg_match('/^(B[123])_/', $project, $m);
|
||
|
||
return $m[1] ?? 'B1';
|
||
}
|
||
}
|