2026-05-15 22:13:32 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
|
|
|
use App\Models\OutboundWebhookSubscription;
|
2026-05-22 18:18:11 +03:00
|
|
|
|
use App\Services\Audit\OperationsLogger;
|
2026-05-21 19:14:11 +03:00
|
|
|
|
use App\Support\WebhookUrlGuard;
|
2026-05-15 22:13:32 +03:00
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
|
use Illuminate\Support\Facades\Hash;
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
use Illuminate\Support\Str;
|
2026-05-22 03:25:16 +03:00
|
|
|
|
use Illuminate\Validation\ValidationException;
|
2026-05-15 22:13:32 +03:00
|
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
|
|
|
|
|
|
* Endpoints под auth:sanctum + tenant.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Одна подписка-ряд на тенанта. Секрет генерируется при создании и
|
|
|
|
|
|
* показывается ОДИН раз (в БД — bcrypt secret_hash + secret_prefix).
|
|
|
|
|
|
*
|
|
|
|
|
|
* test(): MVP делает unsigned connectivity-проверку (реальный POST на
|
|
|
|
|
|
* target_url, отчёт по HTTP-статусу). HMAC-подписанная доставка событий —
|
|
|
|
|
|
* отдельный пост-MVP эпик (outbound-pipeline пока не построен).
|
|
|
|
|
|
*/
|
|
|
|
|
|
class WebhookSettingsController extends Controller
|
|
|
|
|
|
{
|
|
|
|
|
|
private const SECRET_PREFIX = 'whsec_';
|
|
|
|
|
|
|
|
|
|
|
|
/** @var list<string> События по умолчанию для новой подписки. */
|
|
|
|
|
|
private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed'];
|
|
|
|
|
|
|
|
|
|
|
|
public function show(Request $request): JsonResponse
|
|
|
|
|
|
{
|
|
|
|
|
|
$sub = $this->currentSubscription($request);
|
|
|
|
|
|
|
|
|
|
|
|
if ($sub === null) {
|
|
|
|
|
|
return response()->json(['data' => null]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return response()->json(['data' => [
|
|
|
|
|
|
'target_url' => $sub->target_url,
|
|
|
|
|
|
'secret_prefix' => $sub->secret_prefix,
|
|
|
|
|
|
'events' => $sub->events,
|
|
|
|
|
|
'is_active' => $sub->is_active,
|
|
|
|
|
|
]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 18:18:11 +03:00
|
|
|
|
public function update(Request $request, OperationsLogger $ops): JsonResponse
|
2026-05-15 22:13:32 +03:00
|
|
|
|
{
|
|
|
|
|
|
$validated = $request->validate([
|
|
|
|
|
|
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-22 03:25:16 +03:00
|
|
|
|
// SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную
|
|
|
|
|
|
// сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка
|
|
|
|
|
|
// событий) читает из БД только безопасные адреса. NB: будущая доставка
|
|
|
|
|
|
// обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от
|
|
|
|
|
|
// DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный).
|
|
|
|
|
|
$blockReason = WebhookUrlGuard::blockReason($validated['target_url']);
|
|
|
|
|
|
if ($blockReason !== null) {
|
|
|
|
|
|
throw ValidationException::withMessages(['target_url' => [$blockReason]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 18:18:11 +03:00
|
|
|
|
$tenantId = (int) $request->user()->tenant_id;
|
2026-05-15 22:13:32 +03:00
|
|
|
|
$sub = $this->currentSubscription($request);
|
|
|
|
|
|
$plainSecret = null;
|
|
|
|
|
|
|
2026-05-22 18:18:11 +03:00
|
|
|
|
// Capture before-state (null on first-time creation)
|
|
|
|
|
|
$payloadBefore = $sub !== null
|
|
|
|
|
|
? ['target_url' => $sub->target_url, 'is_active' => (bool) $sub->is_active]
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
2026-05-15 22:13:32 +03:00
|
|
|
|
if ($sub === null) {
|
|
|
|
|
|
$plainSecret = self::SECRET_PREFIX.Str::random(40);
|
|
|
|
|
|
$sub = OutboundWebhookSubscription::query()->create([
|
2026-05-22 18:18:11 +03:00
|
|
|
|
'tenant_id' => $tenantId,
|
2026-05-15 22:13:32 +03:00
|
|
|
|
'user_id' => (int) $request->user()->id,
|
|
|
|
|
|
'name' => 'Webhook',
|
|
|
|
|
|
'target_url' => $validated['target_url'],
|
|
|
|
|
|
'secret_hash' => Hash::make($plainSecret),
|
|
|
|
|
|
'secret_prefix' => substr($plainSecret, 0, 10),
|
|
|
|
|
|
'events' => self::DEFAULT_EVENTS,
|
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$sub->update(['target_url' => $validated['target_url']]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 18:18:11 +03:00
|
|
|
|
// Audit: log target_url change (no secret_hash or plaintext secret)
|
|
|
|
|
|
$ops->record(
|
|
|
|
|
|
tenantId: $tenantId,
|
|
|
|
|
|
userId: (int) $request->user()->id,
|
|
|
|
|
|
entityType: 'webhook_settings',
|
|
|
|
|
|
entityId: $sub->id,
|
|
|
|
|
|
event: 'webhook_settings.updated',
|
|
|
|
|
|
payloadBefore: $payloadBefore,
|
|
|
|
|
|
payloadAfter: ['target_url' => $sub->target_url, 'is_active' => (bool) $sub->is_active],
|
|
|
|
|
|
ip: $request->ip(),
|
|
|
|
|
|
userAgent: $request->userAgent(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-15 22:13:32 +03:00
|
|
|
|
$payload = [
|
|
|
|
|
|
'target_url' => $sub->target_url,
|
|
|
|
|
|
'secret_prefix' => $sub->secret_prefix,
|
|
|
|
|
|
'events' => $sub->events,
|
|
|
|
|
|
'is_active' => $sub->is_active,
|
|
|
|
|
|
];
|
|
|
|
|
|
if ($plainSecret !== null) {
|
|
|
|
|
|
$payload['secret'] = $plainSecret;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return response()->json(['data' => $payload]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function test(Request $request): JsonResponse
|
|
|
|
|
|
{
|
|
|
|
|
|
$sub = $this->currentSubscription($request);
|
|
|
|
|
|
|
|
|
|
|
|
if ($sub === null) {
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
|
'message' => 'Сначала сохраните URL webhook.',
|
|
|
|
|
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 20:21:11 +03:00
|
|
|
|
// SSRF-гард + DNS-rebind пиннинг: ОДИН резолв target_url даёт причину
|
|
|
|
|
|
// блокировки И безопасный IP. Блокируем адреса во внутренней/зарезервированной
|
|
|
|
|
|
// сети (cloud-metadata 169.254.169.254, loopback, RFC1918), которые
|
|
|
|
|
|
// https://-валидация на сохранении не ловит.
|
|
|
|
|
|
$delivery = WebhookUrlGuard::safeDeliveryIp($sub->target_url);
|
|
|
|
|
|
if ($delivery['blockReason'] !== null) {
|
2026-05-21 19:14:11 +03:00
|
|
|
|
return response()->json([
|
|
|
|
|
|
'ok' => false,
|
|
|
|
|
|
'status' => null,
|
2026-06-17 20:21:11 +03:00
|
|
|
|
'message' => $delivery['blockReason'],
|
2026-05-21 19:14:11 +03:00
|
|
|
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 22:13:32 +03:00
|
|
|
|
$testPayload = [
|
|
|
|
|
|
'event' => 'webhook.test',
|
|
|
|
|
|
'sent_at' => now()->toIso8601String(),
|
|
|
|
|
|
'message' => 'Тестовая доставка webhook от Лидерра.',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-06-17 20:21:11 +03:00
|
|
|
|
// DNS-rebind пиннинг: подключаемся к УЖЕ проверенному IP, не давая
|
|
|
|
|
|
// HTTP-клиенту резолвить хост повторно (TOCTOU). Host/SNI — исходный хост.
|
|
|
|
|
|
$httpOptions = [];
|
|
|
|
|
|
if ($delivery['ip'] !== null) {
|
|
|
|
|
|
$host = trim((string) parse_url($sub->target_url, PHP_URL_HOST), '[]');
|
|
|
|
|
|
$port = parse_url($sub->target_url, PHP_URL_PORT) ?? 443;
|
|
|
|
|
|
$httpOptions['curl'] = [CURLOPT_RESOLVE => ["{$host}:{$port}:{$delivery['ip']}"]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 19:14:11 +03:00
|
|
|
|
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
|
2026-05-15 22:13:32 +03:00
|
|
|
|
try {
|
2026-06-17 20:21:11 +03:00
|
|
|
|
$response = Http::withOptions($httpOptions)
|
|
|
|
|
|
->timeout(10)
|
2026-05-15 22:13:32 +03:00
|
|
|
|
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
|
|
|
|
|
->post($sub->target_url, $testPayload);
|
|
|
|
|
|
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
|
'ok' => $response->successful(),
|
|
|
|
|
|
'status' => $response->status(),
|
|
|
|
|
|
'message' => $response->successful()
|
|
|
|
|
|
? "Тестовый запрос доставлен (HTTP {$response->status()})."
|
|
|
|
|
|
: "Endpoint ответил HTTP {$response->status()}.",
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
|
'ok' => false,
|
|
|
|
|
|
'status' => null,
|
|
|
|
|
|
'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function currentSubscription(Request $request): ?OutboundWebhookSubscription
|
|
|
|
|
|
{
|
|
|
|
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
|
|
|
|
|
|
|
|
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
|
|
|
|
|
|
return OutboundWebhookSubscription::query()
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->orderByDesc('id')
|
|
|
|
|
|
->first();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|