События по умолчанию для новой подписки. */ 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, ]]); } public function update(Request $request, OperationsLogger $ops): JsonResponse { $validated = $request->validate([ 'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'], ]); // SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную // сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка // событий) читает из БД только безопасные адреса. NB: будущая доставка // обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от // DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный). $blockReason = WebhookUrlGuard::blockReason($validated['target_url']); if ($blockReason !== null) { throw ValidationException::withMessages(['target_url' => [$blockReason]]); } $tenantId = (int) $request->user()->tenant_id; $sub = $this->currentSubscription($request); $plainSecret = null; // Capture before-state (null on first-time creation) $payloadBefore = $sub !== null ? ['target_url' => $sub->target_url, 'is_active' => (bool) $sub->is_active] : null; if ($sub === null) { $plainSecret = self::SECRET_PREFIX.Str::random(40); $sub = OutboundWebhookSubscription::query()->create([ 'tenant_id' => $tenantId, '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']]); } // 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(), ); $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); } // 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) { return response()->json([ 'ok' => false, 'status' => null, 'message' => $delivery['blockReason'], ], Response::HTTP_UNPROCESSABLE_ENTITY); } $testPayload = [ 'event' => 'webhook.test', 'sent_at' => now()->toIso8601String(), 'message' => 'Тестовая доставка webhook от Лидерра.', ]; // 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']}"]]; } // Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик). try { $response = Http::withOptions($httpOptions) ->timeout(10) ->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(); } }