Files
portal/app/app/Http/Controllers/Api/SupplierWebhookController.php
T
Дмитрий 1fd6f7f597 fix(security): harden impersonation/webhook/tenant — audit A2/A3/B3/C2
- 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>
2026-05-15 19:16:13 +03:00

158 lines
6.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);
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';
}
}