From ac2a8ca9392dc78e2d61211591605c3bdf84134f Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Fri, 5 Jun 2026 00:01:09 +0200 Subject: [PATCH] fix(security): gate AI tools by user ability and block admin-URL SSRF The AI chat assistant scoped tool queries by company but ignored the per-user Bouncer abilities the rest of the app enforces, so any `use ai` holder could read customers, invoices, payments, and company financials their role couldn't otherwise see. Each AiTool now declares a required ability (entity-aligned); the registry hides unauthorized tools from the model and refuses to execute them as a backstop. Separately, admin/owner-supplied URLs were fetched server-side with no guard against private/reserved targets (SSRF): the AI base URL, the CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl validation rule (save-time) and runtime guards in each driver. - AiTool::requiredAbility() + mapping across all 12 tools - AiToolRegistry filters schemas() by ability and re-checks in execute() - PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new) - Rule wired into AI config (service + 3 controllers), exchange-rate, and file-disk endpoints; runtime guards in OpenRouterDriver, CurrencyConverterDriver, and FileDiskService - Tests for ability filtering, the guard, the rule, and 422 rejections --- .../Settings/AiConfigurationController.php | 3 +- .../CompanyAiConfigurationController.php | 3 +- .../Setup/AiConfigurationController.php | 3 +- app/Http/Requests/DiskEnvironmentRequest.php | 9 + .../Requests/ExchangeRateProviderRequest.php | 9 + app/Rules/PublicHttpUrl.php | 31 +++ app/Services/Ai/AiAssistantService.php | 2 +- app/Services/Ai/AiToolRegistry.php | 65 ++++- app/Services/Ai/Tools/AiTool.php | 17 ++ app/Services/Ai/Tools/GetCompanyStatsTool.php | 6 + app/Services/Ai/Tools/GetCustomerTool.php | 5 + app/Services/Ai/Tools/GetInvoiceTool.php | 5 + .../Ai/Tools/ListExpenseCategoriesTool.php | 7 + .../Ai/Tools/ListOverdueInvoicesTool.php | 5 + .../Ai/Tools/ListRecentPaymentsTool.php | 5 + .../Ai/Tools/RankExpenseCategoriesTool.php | 5 + .../Ai/Tools/RankTopCustomersTool.php | 5 + app/Services/Ai/Tools/RankTopItemsTool.php | 5 + app/Services/Ai/Tools/SearchCustomersTool.php | 5 + app/Services/Ai/Tools/SearchInvoicesTool.php | 5 + app/Services/Ai/Tools/SearchItemsTool.php | 5 + app/Services/AiConfigurationService.php | 3 +- app/Services/Storage/FileDiskService.php | 10 + app/Support/Ai/OpenRouterDriver.php | 34 ++- .../ExchangeRate/CurrencyConverterDriver.php | 13 +- app/Support/Net/BlockedUrlException.php | 14 + app/Support/Net/PrivateNetworkGuard.php | 246 ++++++++++++++++++ tests/Feature/Admin/FileDiskSsrfTest.php | 52 ++++ tests/Feature/Ai/AiBaseUrlSsrfTest.php | 63 +++++ tests/Feature/Ai/AiChatFlowTest.php | 61 +---- tests/Feature/Ai/AiToolAuthorizationTest.php | 174 +++++++++++++ .../ExchangeRate/ExchangeRateSsrfTest.php | 32 +++ tests/Support/ScriptedAiDriver.php | 74 ++++++ tests/Unit/AiToolRegistryTest.php | 7 +- tests/Unit/OpenRouterDriverTest.php | 26 ++ tests/Unit/PrivateNetworkGuardTest.php | 73 ++++++ tests/Unit/Rules/PublicHttpUrlTest.php | 39 +++ 37 files changed, 1052 insertions(+), 74 deletions(-) create mode 100644 app/Rules/PublicHttpUrl.php create mode 100644 app/Support/Net/BlockedUrlException.php create mode 100644 app/Support/Net/PrivateNetworkGuard.php create mode 100644 tests/Feature/Admin/FileDiskSsrfTest.php create mode 100644 tests/Feature/Ai/AiBaseUrlSsrfTest.php create mode 100644 tests/Feature/Ai/AiToolAuthorizationTest.php create mode 100644 tests/Feature/Company/ExchangeRate/ExchangeRateSsrfTest.php create mode 100644 tests/Support/ScriptedAiDriver.php create mode 100644 tests/Unit/OpenRouterDriverTest.php create mode 100644 tests/Unit/PrivateNetworkGuardTest.php create mode 100644 tests/Unit/Rules/PublicHttpUrlTest.php diff --git a/app/Http/Controllers/Admin/Settings/AiConfigurationController.php b/app/Http/Controllers/Admin/Settings/AiConfigurationController.php index c40ed522..7c3c378c 100644 --- a/app/Http/Controllers/Admin/Settings/AiConfigurationController.php +++ b/app/Http/Controllers/Admin/Settings/AiConfigurationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin\Settings; use App\Http\Controllers\Controller; +use App\Rules\PublicHttpUrl; use App\Services\AiConfigurationService; use App\Support\Ai\AiDriverFactory; use App\Support\Ai\AiException; @@ -86,7 +87,7 @@ class AiConfigurationController extends Controller $this->validate($request, [ 'ai_driver' => 'required|string', 'ai_api_key' => 'nullable|string', - 'ai_base_url' => 'nullable|string|url', + 'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl], ]); // If the masked placeholder was submitted, fall back to the stored key diff --git a/app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php b/app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php index 3f2eb12a..4f2084f3 100644 --- a/app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php +++ b/app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Company\Settings; use App\Http\Controllers\Controller; +use App\Rules\PublicHttpUrl; use App\Services\AiConfigurationService; use App\Support\Ai\AiDriverFactory; use App\Support\Ai\AiException; @@ -69,7 +70,7 @@ class CompanyAiConfigurationController extends Controller $this->validate($request, [ 'ai_driver' => 'required|string', 'ai_api_key' => 'nullable|string', - 'ai_base_url' => 'nullable|string|url', + 'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl], ]); $apiKey = $request->input('ai_api_key'); diff --git a/app/Http/Controllers/Setup/AiConfigurationController.php b/app/Http/Controllers/Setup/AiConfigurationController.php index f127d712..266a5e70 100644 --- a/app/Http/Controllers/Setup/AiConfigurationController.php +++ b/app/Http/Controllers/Setup/AiConfigurationController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Setup; use App\Http\Controllers\Controller; use App\Models\Setting; +use App\Rules\PublicHttpUrl; use App\Services\AiConfigurationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -51,7 +52,7 @@ class AiConfigurationController extends Controller 'ai_enabled' => 'required|in:YES,NO', 'ai_driver' => 'required_if:ai_enabled,YES|nullable|string', 'ai_api_key' => 'required_if:ai_enabled,YES|nullable|string', - 'ai_base_url' => 'nullable|string|url', + 'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl], 'ai_chat_enabled' => 'nullable|in:YES,NO', 'ai_chat_model' => 'nullable|string|max:200', 'ai_text_generation_enabled' => 'nullable|in:YES,NO', diff --git a/app/Http/Requests/DiskEnvironmentRequest.php b/app/Http/Requests/DiskEnvironmentRequest.php index 2f2f1838..e2ec555b 100644 --- a/app/Http/Requests/DiskEnvironmentRequest.php +++ b/app/Http/Requests/DiskEnvironmentRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\PublicHttpUrl; use Illuminate\Foundation\Http\FormRequest; class DiskEnvironmentRequest extends FormRequest @@ -39,6 +40,12 @@ class DiskEnvironmentRequest extends FormRequest 'required', 'string', ], + 'credentials.endpoint' => [ + 'nullable', + 'string', + 'url', + new PublicHttpUrl, + ], 'credentials.root' => [ 'required', 'string', @@ -68,6 +75,8 @@ class DiskEnvironmentRequest extends FormRequest 'credentials.endpoint' => [ 'required', 'string', + 'url', + new PublicHttpUrl, ], 'credentials.root' => [ 'required', diff --git a/app/Http/Requests/ExchangeRateProviderRequest.php b/app/Http/Requests/ExchangeRateProviderRequest.php index 72584c41..28aa8408 100644 --- a/app/Http/Requests/ExchangeRateProviderRequest.php +++ b/app/Http/Requests/ExchangeRateProviderRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\PublicHttpUrl; use Illuminate\Foundation\Http\FormRequest; class ExchangeRateProviderRequest extends FormRequest @@ -35,6 +36,14 @@ class ExchangeRateProviderRequest extends FormRequest 'driver_config' => [ 'nullable', ], + // Only the CurrencyConverter "DEDICATED" plan reads a custom URL from + // driver_config; guard it against SSRF (private/reserved targets). + 'driver_config.url' => [ + 'nullable', + 'string', + 'url', + new PublicHttpUrl, + ], 'active' => [ 'nullable', 'boolean', diff --git a/app/Rules/PublicHttpUrl.php b/app/Rules/PublicHttpUrl.php new file mode 100644 index 00000000..0b9a2b7d --- /dev/null +++ b/app/Rules/PublicHttpUrl.php @@ -0,0 +1,31 @@ +touch(); // bump updated_at for "recent" ordering $messages = $this->buildMessagesPayload($conversation); - $tools = $this->toolRegistry->schemas(); + $tools = $this->toolRegistry->schemas($conversation->user_id); for ($iteration = 0; $iteration < self::MAX_TOOL_ITERATIONS; $iteration++) { try { diff --git a/app/Services/Ai/AiToolRegistry.php b/app/Services/Ai/AiToolRegistry.php index 9116e8ec..4803647c 100644 --- a/app/Services/Ai/AiToolRegistry.php +++ b/app/Services/Ai/AiToolRegistry.php @@ -2,6 +2,7 @@ namespace App\Services\Ai; +use App\Models\User; use App\Services\Ai\Tools\AiTool; use InvalidArgumentException; @@ -28,6 +29,13 @@ class AiToolRegistry */ protected array $tools = []; + /** + * Per-user memo so a single turn doesn't re-query the user for every tool. + * + * @var array + */ + protected array $userCache = []; + public function register(AiTool $tool): void { $this->tools[$tool->name()] = $tool; @@ -47,15 +55,24 @@ class AiToolRegistry } /** - * Export all registered tools as the `tools` array for an OpenAI-style chat request. + * Export the tools the given user is authorized to use as the `tools` array + * for an OpenAI-style chat request. + * + * Tools the user lacks the required ability for are omitted entirely, so the + * LLM is never even told they exist. `execute()` re-checks as a backstop. * * @return array> */ - public function schemas(): array + public function schemas(int $userId): array { + $authorized = array_filter( + $this->tools, + fn (AiTool $tool): bool => $this->userCan($tool, $userId), + ); + return array_values(array_map( fn (AiTool $tool): array => $tool->toOpenAiToolSchema(), - $this->tools, + $authorized, )); } @@ -66,6 +83,10 @@ class AiToolRegistry * how we guarantee the `$companyId` and `$userId` arguments are session-authoritative * and never influenced by LLM output. * + * Authorization backstop: even though `schemas()` already hides tools the user + * can't use, we re-check the required ability here so a model that hallucinates + * an unauthorized tool name gets a structured error instead of data. + * * @param array $arguments * * @throws InvalidArgumentException When the tool name is not registered. @@ -78,14 +99,52 @@ class AiToolRegistry throw new InvalidArgumentException("Unknown AI tool: {$name}"); } + if (! $this->userCan($tool, $userId)) { + return [ + 'error' => 'unauthorized', + 'message' => 'You do not have permission to access this data.', + ]; + } + return $tool->execute($arguments, $companyId, $userId); } + /** + * Whether the user holds the Bouncer ability a tool requires. + * + * The ability is evaluated under the ambient Bouncer scope, which the + * `company` + `bouncer` (ScopeBouncer) middleware set to the active company + * on every AI request — the same way the app's policies check abilities. The + * resolved user is always the conversation owner (ChatController binds it to + * the request user), so this never trusts an identifier from LLM output. + */ + protected function userCan(AiTool $tool, int $userId): bool + { + $required = $tool->requiredAbility(); + + if ($required === null) { + return true; + } + + [$ability, $model] = $required; + + $user = $this->userCache[$userId] ??= User::find($userId); + + if ($user === null) { + return false; + } + + return $model === null + ? $user->can($ability) + : $user->can($ability, $model); + } + /** * Test-only: reset the registry between tests that exercise different tool sets. */ public function flush(): void { $this->tools = []; + $this->userCache = []; } } diff --git a/app/Services/Ai/Tools/AiTool.php b/app/Services/Ai/Tools/AiTool.php index cc1c9818..cef115d1 100644 --- a/app/Services/Ai/Tools/AiTool.php +++ b/app/Services/Ai/Tools/AiTool.php @@ -16,6 +16,10 @@ namespace App\Services\Ai\Tools; * time from the caller's session. This is the v1 prompt-injection defense. * - `execute()` — actually runs the query. Receives the resolved `$companyId` * and `$userId` from the session, plus the arguments the LLM chose. + * - `requiredAbility()` — the Bouncer ability the caller must hold for this + * tool to be offered to the LLM and executed. Enforces per-user permissions + * on top of company scoping, so a restricted role can't read data via the + * assistant that it couldn't read through the normal API. * * Tools are **read-only** by contract. There is intentionally no mutation * surface in v1 — the chat assistant cannot create, update, or delete @@ -68,6 +72,19 @@ abstract class AiTool */ abstract public function execute(array $arguments, int $companyId, int $userId): mixed; + /** + * The Bouncer ability a caller must hold to use this tool, as a + * `[ability, modelClass]` pair — or null if no ability beyond `use ai`. + * + * The registry checks this against the session user before exposing the tool + * to the LLM and again before executing it, so the assistant honours the same + * per-user permissions as the rest of the app. The model element may be null + * for gate-style abilities that take no model (e.g. `dashboard`). + * + * @return array{0: string, 1: class-string|null}|null + */ + abstract public function requiredAbility(): ?array; + /** * Convert this tool into an OpenAI-style tools array entry. * diff --git a/app/Services/Ai/Tools/GetCompanyStatsTool.php b/app/Services/Ai/Tools/GetCompanyStatsTool.php index 1ec9362b..69ea6e0e 100644 --- a/app/Services/Ai/Tools/GetCompanyStatsTool.php +++ b/app/Services/Ai/Tools/GetCompanyStatsTool.php @@ -58,6 +58,12 @@ class GetCompanyStatsTool extends AiTool ]; } + public function requiredAbility(): ?array + { + // Cross-entity financial snapshot — gated like the company dashboard. + return ['dashboard', null]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $period = (string) ($arguments['period'] ?? 'this_month'); diff --git a/app/Services/Ai/Tools/GetCustomerTool.php b/app/Services/Ai/Tools/GetCustomerTool.php index 0afda3a8..803e9397 100644 --- a/app/Services/Ai/Tools/GetCustomerTool.php +++ b/app/Services/Ai/Tools/GetCustomerTool.php @@ -31,6 +31,11 @@ class GetCustomerTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-customer', Customer::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $customer = Customer::query() diff --git a/app/Services/Ai/Tools/GetInvoiceTool.php b/app/Services/Ai/Tools/GetInvoiceTool.php index 7677dd36..afd38dbc 100644 --- a/app/Services/Ai/Tools/GetInvoiceTool.php +++ b/app/Services/Ai/Tools/GetInvoiceTool.php @@ -33,6 +33,11 @@ class GetInvoiceTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-invoice', Invoice::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $invoice = Invoice::query() diff --git a/app/Services/Ai/Tools/ListExpenseCategoriesTool.php b/app/Services/Ai/Tools/ListExpenseCategoriesTool.php index 142fce15..e9d756cc 100644 --- a/app/Services/Ai/Tools/ListExpenseCategoriesTool.php +++ b/app/Services/Ai/Tools/ListExpenseCategoriesTool.php @@ -2,6 +2,7 @@ namespace App\Services\Ai\Tools; +use App\Models\Expense; use App\Models\ExpenseCategory; class ListExpenseCategoriesTool extends AiTool @@ -25,6 +26,12 @@ class ListExpenseCategoriesTool extends AiTool ]; } + public function requiredAbility(): ?array + { + // Expense categories are gated by the expense ability (see ExpenseCategoryPolicy). + return ['view-expense', Expense::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $categories = ExpenseCategory::query() diff --git a/app/Services/Ai/Tools/ListOverdueInvoicesTool.php b/app/Services/Ai/Tools/ListOverdueInvoicesTool.php index f2f7707d..0b565cba 100644 --- a/app/Services/Ai/Tools/ListOverdueInvoicesTool.php +++ b/app/Services/Ai/Tools/ListOverdueInvoicesTool.php @@ -25,6 +25,11 @@ class ListOverdueInvoicesTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-invoice', Invoice::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $invoices = Invoice::query() diff --git a/app/Services/Ai/Tools/ListRecentPaymentsTool.php b/app/Services/Ai/Tools/ListRecentPaymentsTool.php index ddc60688..4ddde204 100644 --- a/app/Services/Ai/Tools/ListRecentPaymentsTool.php +++ b/app/Services/Ai/Tools/ListRecentPaymentsTool.php @@ -47,6 +47,11 @@ class ListRecentPaymentsTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-payment', Payment::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $days = min((int) ($arguments['days'] ?? self::DEFAULT_DAYS), self::MAX_DAYS); diff --git a/app/Services/Ai/Tools/RankExpenseCategoriesTool.php b/app/Services/Ai/Tools/RankExpenseCategoriesTool.php index 7b6a5f7d..fdd8b68d 100644 --- a/app/Services/Ai/Tools/RankExpenseCategoriesTool.php +++ b/app/Services/Ai/Tools/RankExpenseCategoriesTool.php @@ -53,6 +53,11 @@ class RankExpenseCategoriesTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-expense', Expense::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $period = (string) ($arguments['period'] ?? 'all_time'); diff --git a/app/Services/Ai/Tools/RankTopCustomersTool.php b/app/Services/Ai/Tools/RankTopCustomersTool.php index fe7cf0a1..11fc29fc 100644 --- a/app/Services/Ai/Tools/RankTopCustomersTool.php +++ b/app/Services/Ai/Tools/RankTopCustomersTool.php @@ -71,6 +71,11 @@ class RankTopCustomersTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-customer', Customer::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $metric = (string) ($arguments['metric'] ?? 'invoiced_total'); diff --git a/app/Services/Ai/Tools/RankTopItemsTool.php b/app/Services/Ai/Tools/RankTopItemsTool.php index 7c0afacc..d61da586 100644 --- a/app/Services/Ai/Tools/RankTopItemsTool.php +++ b/app/Services/Ai/Tools/RankTopItemsTool.php @@ -61,6 +61,11 @@ class RankTopItemsTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-item', Item::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $metric = (string) ($arguments['metric'] ?? 'revenue'); diff --git a/app/Services/Ai/Tools/SearchCustomersTool.php b/app/Services/Ai/Tools/SearchCustomersTool.php index 11669457..72b52cb7 100644 --- a/app/Services/Ai/Tools/SearchCustomersTool.php +++ b/app/Services/Ai/Tools/SearchCustomersTool.php @@ -39,6 +39,11 @@ class SearchCustomersTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-customer', Customer::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT); diff --git a/app/Services/Ai/Tools/SearchInvoicesTool.php b/app/Services/Ai/Tools/SearchInvoicesTool.php index d5f908f4..89fd2f13 100644 --- a/app/Services/Ai/Tools/SearchInvoicesTool.php +++ b/app/Services/Ai/Tools/SearchInvoicesTool.php @@ -55,6 +55,11 @@ class SearchInvoicesTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-invoice', Invoice::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT); diff --git a/app/Services/Ai/Tools/SearchItemsTool.php b/app/Services/Ai/Tools/SearchItemsTool.php index 75a2f068..fe3f2a16 100644 --- a/app/Services/Ai/Tools/SearchItemsTool.php +++ b/app/Services/Ai/Tools/SearchItemsTool.php @@ -39,6 +39,11 @@ class SearchItemsTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return ['view-item', Item::class]; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT); diff --git a/app/Services/AiConfigurationService.php b/app/Services/AiConfigurationService.php index f976c3c4..f37ee37a 100644 --- a/app/Services/AiConfigurationService.php +++ b/app/Services/AiConfigurationService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\CompanySetting; use App\Models\Setting; +use App\Rules\PublicHttpUrl; use App\Support\Ai\AiDriver; use App\Support\Ai\AiDriverFactory; use Illuminate\Support\Facades\Crypt; @@ -203,7 +204,7 @@ class AiConfigurationService 'ai_enabled' => ['nullable', 'in:YES,NO'], 'ai_driver' => ['required_if:ai_enabled,YES', 'nullable', 'string', 'in:'.implode(',', $availableDrivers)], 'ai_api_key' => ['required_if:ai_enabled,YES', 'nullable', 'string'], - 'ai_base_url' => ['nullable', 'string', 'url'], + 'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl], 'ai_chat_enabled' => ['nullable', 'in:YES,NO'], 'ai_chat_model' => ['nullable', 'string', 'max:200'], 'ai_text_generation_enabled' => ['nullable', 'in:YES,NO'], diff --git a/app/Services/Storage/FileDiskService.php b/app/Services/Storage/FileDiskService.php index 420ccf4d..8fb237e4 100644 --- a/app/Services/Storage/FileDiskService.php +++ b/app/Services/Storage/FileDiskService.php @@ -3,6 +3,7 @@ namespace App\Services\Storage; use App\Models\FileDisk; +use App\Support\Net\PrivateNetworkGuard; use Illuminate\Http\Request; class FileDiskService @@ -103,6 +104,15 @@ class FileDiskService public function validateCredentials(array $credentials, string $driver): bool { + // SSRF guard: reject S3/Spaces (or any) endpoints that resolve to a + // private or reserved host before we make a live request to them. + if (isset($credentials['endpoint']) + && is_string($credentials['endpoint']) + && $credentials['endpoint'] !== '' + && PrivateNetworkGuard::blockedReason($credentials['endpoint']) !== null) { + return false; + } + // Create a temporary disk config for validation $baseConfig = config('filesystems.disks.'.$driver, []); diff --git a/app/Support/Ai/OpenRouterDriver.php b/app/Support/Ai/OpenRouterDriver.php index 38d40c23..5f0642dc 100644 --- a/app/Support/Ai/OpenRouterDriver.php +++ b/app/Support/Ai/OpenRouterDriver.php @@ -2,6 +2,8 @@ namespace App\Support\Ai; +use App\Support\Net\BlockedUrlException; +use App\Support\Net\PrivateNetworkGuard; use Illuminate\Support\Facades\Http; use Throwable; @@ -23,12 +25,19 @@ class OpenRouterDriver extends AiDriver protected const TIMEOUT_SECONDS = 120; + /** Memoised, SSRF-validated base URL so we don't re-resolve DNS per request. */ + private ?string $validatedBaseUrl = null; + public function chatCompletion( array $messages, string $model, array $tools = [], array $options = [], ): AiChatResponse { + // Resolve (and SSRF-validate) the URL before the try so a blocked base + // URL surfaces as `invalid_base_url`, not a generic `server_error`. + $endpoint = $this->getBaseUrl().'/chat/completions'; + $payload = array_filter([ 'model' => $model, 'messages' => $messages, @@ -43,7 +52,7 @@ class OpenRouterDriver extends AiDriver ->timeout(self::TIMEOUT_SECONDS) ->acceptJson() ->asJson() - ->post($this->getBaseUrl().'/chat/completions', $payload); + ->post($endpoint, $payload); } catch (Throwable $e) { throw new AiException( 'OpenRouter request failed: '.$e->getMessage(), @@ -86,11 +95,15 @@ class OpenRouterDriver extends AiDriver public function validateConnection(): array { + // Resolve (and SSRF-validate) the URL before the try so a blocked base + // URL surfaces as `invalid_base_url`, not a generic `server_error`. + $endpoint = $this->getBaseUrl().'/models'; + try { $response = Http::withToken($this->apiKey) ->timeout(30) ->acceptJson() - ->get($this->getBaseUrl().'/models'); + ->get($endpoint); } catch (Throwable $e) { throw new AiException( 'Unable to reach OpenRouter: '.$e->getMessage(), @@ -198,8 +211,21 @@ class OpenRouterDriver extends AiDriver protected function getBaseUrl(): string { - $url = $this->config['base_url'] ?? self::DEFAULT_BASE_URL; + if ($this->validatedBaseUrl !== null) { + return $this->validatedBaseUrl; + } - return rtrim($url, '/'); + $configured = (string) ($this->config['base_url'] ?? ''); + $url = rtrim($configured !== '' ? $configured : self::DEFAULT_BASE_URL, '/'); + + // SSRF guard: never let an admin/owner-supplied base URL point the + // server (with the bearer token attached) at a private/reserved host. + try { + PrivateNetworkGuard::assertAllowed($url); + } catch (BlockedUrlException $e) { + throw new AiException('Invalid AI base URL: '.$e->getMessage(), 'invalid_base_url', 0, $e); + } + + return $this->validatedBaseUrl = $url; } } diff --git a/app/Support/ExchangeRate/CurrencyConverterDriver.php b/app/Support/ExchangeRate/CurrencyConverterDriver.php index 0e0da118..3532bad5 100644 --- a/app/Support/ExchangeRate/CurrencyConverterDriver.php +++ b/app/Support/ExchangeRate/CurrencyConverterDriver.php @@ -2,6 +2,8 @@ namespace App\Support\ExchangeRate; +use App\Support\Net\BlockedUrlException; +use App\Support\Net\PrivateNetworkGuard; use Illuminate\Support\Facades\Http; class CurrencyConverterDriver extends ExchangeRateDriver @@ -47,11 +49,20 @@ class CurrencyConverterDriver extends ExchangeRateDriver { $type = $this->config['type'] ?? 'FREE'; - return match ($type) { + $url = match ($type) { 'PREMIUM' => 'https://api.currconv.com', 'PREPAID' => 'https://prepaid.currconv.com', 'FREE' => 'https://free.currconv.com', 'DEDICATED' => $this->config['url'] ?? 'https://free.currconv.com', }; + + // SSRF guard: the DEDICATED plan lets the user supply this URL. + try { + PrivateNetworkGuard::assertAllowed($url); + } catch (BlockedUrlException $e) { + throw new ExchangeRateException('Invalid provider URL: '.$e->getMessage(), 'invalid_url'); + } + + return $url; } } diff --git a/app/Support/Net/BlockedUrlException.php b/app/Support/Net/BlockedUrlException.php new file mode 100644 index 00000000..23173f92 --- /dev/null +++ b/app/Support/Net/BlockedUrlException.php @@ -0,0 +1,14 @@ + + */ + private const BLOCKED_IPV4 = [ + '0.0.0.0/8', // "this" network / unspecified + '10.0.0.0/8', // RFC1918 private + '100.64.0.0/10', // RFC6598 carrier-grade NAT + '127.0.0.0/8', // loopback + '169.254.0.0/16', // link-local (incl. cloud metadata 169.254.169.254) + '172.16.0.0/12', // RFC1918 private + '192.0.0.0/24', // IETF protocol assignments + '192.0.2.0/24', // TEST-NET-1 + '192.168.0.0/16', // RFC1918 private + '198.18.0.0/15', // benchmarking + '198.51.100.0/24', // TEST-NET-2 + '203.0.113.0/24', // TEST-NET-3 + '240.0.0.0/4', // reserved (incl. 255.255.255.255 broadcast) + ]; + + /** + * IPv6 CIDR blocks that must never be the target of a server-side request. + * + * IPv4-mapped addresses (::ffff:0:0/96) are handled explicitly by unwrapping + * the embedded IPv4 in {@see self::ipIsBlocked()}. + * + * @var array + */ + private const BLOCKED_IPV6 = [ + '::1/128', // loopback + '::/128', // unspecified + 'fc00::/7', // unique local address + 'fe80::/10', // link-local + '64:ff9b::/96', // NAT64 (embeds IPv4) + '2001:db8::/32', // documentation + ]; + + /** + * Return a human-readable reason when the URL must NOT be requested, or null + * when it is allowed (including the fail-open unresolvable-host case). + */ + public static function blockedReason(string $url): ?string + { + $url = trim($url); + if ($url === '') { + return null; + } + + $parts = parse_url($url); + if ($parts === false || ! isset($parts['scheme']) || ! isset($parts['host'])) { + return 'URL must include a scheme and host'; + } + + $scheme = strtolower($parts['scheme']); + if (! in_array($scheme, ['http', 'https'], true)) { + return 'URL scheme must be http or https'; + } + + // parse_url keeps IPv6 literals wrapped in brackets; strip them. + $host = trim($parts['host'], '[]'); + if ($host === '') { + return 'URL must include a host'; + } + + // IP literal — check directly without DNS. + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return self::ipIsBlocked($host) + ? "URL host {$host} is a private or reserved address" + : null; + } + + // Hostname — resolve and check every address it points at. + $ips = self::resolveHost($host); + if ($ips === []) { + // Unresolvable: not provably unsafe. The actual request will fail. + return null; + } + + foreach ($ips as $ip) { + if (self::ipIsBlocked($ip)) { + return "URL host {$host} resolves to a private or reserved address ({$ip})"; + } + } + + return null; + } + + /** + * Throw when the URL is not safe to request. Used by runtime driver guards. + * + * @throws BlockedUrlException + */ + public static function assertAllowed(string $url): void + { + $reason = self::blockedReason($url); + + if ($reason !== null) { + throw new BlockedUrlException($reason); + } + } + + /** + * Whether a single IP address falls inside any blocked range. + */ + public static function ipIsBlocked(string $ip): bool + { + // Normalise IPv4-mapped IPv6 (e.g. ::ffff:10.0.0.1) to its embedded IPv4. + $mapped = self::extractMappedIpv4($ip); + if ($mapped !== null) { + return self::ipIsBlocked($mapped); + } + + $isIpv4 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + $blocks = $isIpv4 ? self::BLOCKED_IPV4 : self::BLOCKED_IPV6; + + foreach ($blocks as $cidr) { + if (self::ipInCidr($ip, $cidr)) { + return true; + } + } + + return false; + } + + /** + * Whether an IP (v4 or v6) is contained in the given CIDR block. + */ + public static function ipInCidr(string $ip, string $cidr): bool + { + [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, null); + + $ipBin = @inet_pton($ip); + $subnetBin = @inet_pton((string) $subnet); + + if ($ipBin === false || $subnetBin === false) { + return false; + } + + // Different address families (v4 vs v6) can never match. + if (strlen($ipBin) !== strlen($subnetBin)) { + return false; + } + + $bits = (int) $bits; + $wholeBytes = intdiv($bits, 8); + $remainderBits = $bits % 8; + + if ($wholeBytes > 0 && strncmp($ipBin, $subnetBin, $wholeBytes) !== 0) { + return false; + } + + if ($remainderBits > 0) { + $mask = (~((1 << (8 - $remainderBits)) - 1)) & 0xFF; + + if ((ord($ipBin[$wholeBytes]) & $mask) !== (ord($subnetBin[$wholeBytes]) & $mask)) { + return false; + } + } + + return true; + } + + /** + * Resolve a hostname to all of its A and AAAA addresses. + * + * @return array + */ + private static function resolveHost(string $host): array + { + $ips = []; + + if (function_exists('gethostbynamel')) { + $v4 = @gethostbynamel($host); + if (is_array($v4)) { + $ips = $v4; + } + } + + if (function_exists('dns_get_record')) { + $records = @dns_get_record($host, DNS_AAAA); + if (is_array($records)) { + foreach ($records as $record) { + if (isset($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + } + + return array_values(array_unique($ips)); + } + + /** + * Extract the embedded IPv4 from an IPv4-mapped IPv6 address (::ffff:0:0/96), + * or null if the value is not such an address. + */ + private static function extractMappedIpv4(string $ip): ?string + { + $bin = @inet_pton($ip); + if ($bin === false || strlen($bin) !== 16) { + return null; + } + + // ::ffff:0:0/96 = 10 zero bytes followed by 0xff 0xff. + $prefix = str_repeat("\x00", 10)."\xff\xff"; + if (strncmp($bin, $prefix, 12) !== 0) { + return null; + } + + $ipv4 = inet_ntop(substr($bin, 12, 4)); + + return $ipv4 === false ? null : $ipv4; + } +} diff --git a/tests/Feature/Admin/FileDiskSsrfTest.php b/tests/Feature/Admin/FileDiskSsrfTest.php new file mode 100644 index 00000000..ff42fa61 --- /dev/null +++ b/tests/Feature/Admin/FileDiskSsrfTest.php @@ -0,0 +1,52 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->withHeaders(['company' => $user->companies()->first()->id]); + Sanctum::actingAs($user, ['*']); +}); + +test('rejects a doSpaces disk whose endpoint targets a private host', function () { + postJson('/api/v1/disks', [ + 'name' => 'evil-spaces', + 'driver' => 'doSpaces', + 'credentials' => [ + 'key' => 'k', + 'secret' => 's', + 'region' => 'nyc3', + 'bucket' => 'b', + 'endpoint' => 'http://169.254.169.254', + 'root' => '/', + ], + 'set_as_default' => false, + ])->assertStatus(422)->assertJsonValidationErrors('credentials.endpoint'); +}); + +test('rejects an s3 disk whose endpoint targets a private host', function () { + postJson('/api/v1/disks', [ + 'name' => 'evil-s3', + 'driver' => 's3', + 'credentials' => [ + 'key' => 'k', + 'secret' => 's', + 'region' => 'us-east-1', + 'bucket' => 'b', + 'endpoint' => 'http://10.1.2.3', + 'root' => '/', + ], + 'set_as_default' => false, + ])->assertStatus(422)->assertJsonValidationErrors('credentials.endpoint'); +}); diff --git a/tests/Feature/Ai/AiBaseUrlSsrfTest.php b/tests/Feature/Ai/AiBaseUrlSsrfTest.php new file mode 100644 index 00000000..aff39b95 --- /dev/null +++ b/tests/Feature/Ai/AiBaseUrlSsrfTest.php @@ -0,0 +1,63 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $this->user = User::find(1); + $this->companyId = $this->user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->user, ['*']); +}); + +test('admin AI config rejects a private base URL', function () { + postJson('/api/v1/ai/config', [ + 'ai_enabled' => 'YES', + 'ai_driver' => 'openrouter', + 'ai_api_key' => 'sk-test', + 'ai_base_url' => 'http://169.254.169.254/v1', + 'ai_chat_enabled' => 'YES', + 'ai_chat_model' => 'openai/gpt-4o', + ])->assertStatus(422)->assertJsonValidationErrors('ai_base_url'); +}); + +test('admin AI config still accepts a public base URL', function () { + postJson('/api/v1/ai/config', [ + 'ai_enabled' => 'YES', + 'ai_driver' => 'openrouter', + 'ai_api_key' => 'sk-test', + 'ai_base_url' => 'https://openrouter.ai/api/v1', + 'ai_chat_enabled' => 'YES', + 'ai_chat_model' => 'openai/gpt-4o', + ])->assertOk(); +}); + +test('admin AI test-connection rejects a private base URL', function () { + postJson('/api/v1/ai/test', [ + 'ai_driver' => 'openrouter', + 'ai_api_key' => 'sk-test', + 'ai_base_url' => 'http://127.0.0.1:11434', + ])->assertStatus(422)->assertJsonValidationErrors('ai_base_url'); +}); + +test('company AI config rejects a private base URL', function () { + postJson('/api/v1/company/ai/config', [ + 'use_custom_ai_config' => 'YES', + 'ai_enabled' => 'YES', + 'ai_driver' => 'openrouter', + 'ai_api_key' => 'company-key', + 'ai_base_url' => 'http://10.0.0.5', + 'ai_chat_enabled' => 'YES', + 'ai_chat_model' => 'openai/gpt-4o', + ])->assertStatus(422)->assertJsonValidationErrors('ai_base_url'); +}); diff --git a/tests/Feature/Ai/AiChatFlowTest.php b/tests/Feature/Ai/AiChatFlowTest.php index af369b4d..a98ffe68 100644 --- a/tests/Feature/Ai/AiChatFlowTest.php +++ b/tests/Feature/Ai/AiChatFlowTest.php @@ -10,70 +10,11 @@ use App\Support\Ai\AiDriverFactory; use App\Support\Ai\AiException; use Illuminate\Support\Facades\Artisan; use Laravel\Sanctum\Sanctum; +use Tests\Support\ScriptedAiDriver; use function Pest\Laravel\getJson; use function Pest\Laravel\postJson; -/** - * Test double for AiDriver that returns pre-queued responses from an array, - * so tests can script tool-call loops without hitting any real LLM API. - * - * Usage: - * ScriptedAiDriver::setResponses([ - * new AiChatResponse(message: null, toolCalls: [...]), - * new AiChatResponse(message: 'Final answer'), - * ]); - */ -class ScriptedAiDriver extends AiDriver -{ - /** @var array */ - public static array $responses = []; - - public static int $callCount = 0; - - /** @var array> */ - public static array $lastMessages = []; - - public static function reset(): void - { - self::$responses = []; - self::$callCount = 0; - self::$lastMessages = []; - } - - /** - * @param array $responses - */ - public static function setResponses(array $responses): void - { - self::$responses = $responses; - self::$callCount = 0; - } - - public function chatCompletion( - array $messages, - string $model, - array $tools = [], - array $options = [], - ): AiChatResponse { - self::$lastMessages = $messages; - $response = self::$responses[self::$callCount] ?? new AiChatResponse(message: 'Default test reply'); - self::$callCount++; - - return $response; - } - - public function textCompletion(string $prompt, string $model, array $options = []): string - { - return 'text completion test'; - } - - public function validateConnection(): array - { - return ['ok' => true]; - } -} - beforeEach(function () { Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]); Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); diff --git a/tests/Feature/Ai/AiToolAuthorizationTest.php b/tests/Feature/Ai/AiToolAuthorizationTest.php new file mode 100644 index 00000000..503f59e9 --- /dev/null +++ b/tests/Feature/Ai/AiToolAuthorizationTest.php @@ -0,0 +1,174 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $this->owner = User::find(1); + $this->companyId = $this->owner->companies()->first()->id; + + // A second user in the same company with NO abilities granted yet. + $this->restricted = User::factory()->create(); + $this->restricted->companies()->attach($this->companyId); + + app(AiConfigurationService::class)->saveGlobalConfig([ + 'ai_enabled' => 'YES', + 'ai_driver' => 'scripted', + 'ai_api_key' => 'test-key', + 'ai_chat_enabled' => 'YES', + 'ai_chat_model' => 'test-model', + ]); + + AiDriverFactory::register('scripted', ScriptedAiDriver::class); + ScriptedAiDriver::reset(); +}); + +afterEach(fn () => ScriptedAiDriver::reset()); + +/** + * Grant a Bouncer ability to a user within the company scope, mirroring how + * CompanyService seeds the owner's abilities. + */ +function grantAbility(User $user, int $companyId, string $ability, ?string $model = null): void +{ + BouncerFacade::scope()->to($companyId); + + $model === null + ? BouncerFacade::allow($user)->to($ability) + : BouncerFacade::allow($user)->to($ability, $model); +} + +test('tools the user lacks the ability for are hidden from the LLM', function () { + grantAbility($this->restricted, $this->companyId, 'view-invoice', Invoice::class); + + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->restricted, ['*']); + + ScriptedAiDriver::setResponses([new AiChatResponse(message: 'ok')]); + + postJson('/api/v1/ai/chat', ['message' => 'hi'])->assertOk(); + + $toolNames = collect(ScriptedAiDriver::$lastTools)->pluck('function.name'); + + // Invoice tools visible (has view-invoice)... + expect($toolNames)->toContain('search_invoices') + ->and($toolNames)->toContain('get_invoice') + ->and($toolNames)->toContain('list_overdue_invoices'); + + // ...everything the user can't view is hidden. + expect($toolNames)->not->toContain('search_customers') + ->and($toolNames)->not->toContain('get_customer') + ->and($toolNames)->not->toContain('rank_top_customers') + ->and($toolNames)->not->toContain('list_recent_payments') + ->and($toolNames)->not->toContain('search_items') + ->and($toolNames)->not->toContain('list_expense_categories') + ->and($toolNames)->not->toContain('get_company_stats'); +}); + +test('a user with no abilities is offered no tools at all', function () { + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->restricted, ['*']); + + ScriptedAiDriver::setResponses([new AiChatResponse(message: 'ok')]); + + postJson('/api/v1/ai/chat', ['message' => 'hi'])->assertOk(); + + expect(ScriptedAiDriver::$lastTools)->toBe([]); +}); + +test('executing an unauthorized tool returns an unauthorized error to the model', function () { + grantAbility($this->restricted, $this->companyId, 'view-invoice', Invoice::class); + + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->restricted, ['*']); + + // The model is scripted to call a tool it was never offered (search_customers). + ScriptedAiDriver::setResponses([ + new AiChatResponse( + message: null, + toolCalls: [[ + 'id' => 'call_1', + 'name' => 'search_customers', + 'arguments' => ['query' => 'acme'], + ]], + finishReason: 'tool_calls', + ), + new AiChatResponse(message: 'done'), + ]); + + postJson('/api/v1/ai/chat', ['message' => 'list every customer'])->assertOk(); + + $toolMessage = AiMessage::where('role', 'tool')->latest('id')->first(); + + expect($toolMessage)->not->toBeNull() + ->and($toolMessage->content)->toContain('unauthorized'); +}); + +test('a user with view-customer can use the customer tools', function () { + grantAbility($this->restricted, $this->companyId, 'view-customer', Customer::class); + + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->restricted, ['*']); + + ScriptedAiDriver::setResponses([ + new AiChatResponse( + message: null, + toolCalls: [[ + 'id' => 'call_1', + 'name' => 'search_customers', + 'arguments' => ['query' => ''], + ]], + finishReason: 'tool_calls', + ), + new AiChatResponse(message: 'Here are your customers.'), + ]); + + postJson('/api/v1/ai/chat', ['message' => 'show customers'])->assertOk(); + + $toolMessage = AiMessage::where('role', 'tool')->latest('id')->first(); + + expect($toolMessage->content)->not->toContain('unauthorized') + ->and($toolMessage->content)->toContain('customers'); + + $toolNames = collect(ScriptedAiDriver::$lastTools)->pluck('function.name'); + expect($toolNames)->toContain('search_customers') + ->and($toolNames)->not->toContain('search_invoices'); +}); + +test('a fully-privileged owner is offered every tool, including stats', function () { + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->owner, ['*']); + + ScriptedAiDriver::setResponses([new AiChatResponse(message: 'ok')]); + + postJson('/api/v1/ai/chat', ['message' => 'hi'])->assertOk(); + + $toolNames = collect(ScriptedAiDriver::$lastTools)->pluck('function.name'); + + expect($toolNames)->toContain('search_invoices') + ->and($toolNames)->toContain('search_customers') + ->and($toolNames)->toContain('list_recent_payments') + ->and($toolNames)->toContain('search_items') + ->and($toolNames)->toContain('list_expense_categories') + ->and($toolNames)->toContain('get_company_stats'); +}); diff --git a/tests/Feature/Company/ExchangeRate/ExchangeRateSsrfTest.php b/tests/Feature/Company/ExchangeRate/ExchangeRateSsrfTest.php new file mode 100644 index 00000000..907a5bd9 --- /dev/null +++ b/tests/Feature/Company/ExchangeRate/ExchangeRateSsrfTest.php @@ -0,0 +1,32 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->withHeaders(['company' => $user->companies()->first()->id]); + Sanctum::actingAs($user, ['*']); +}); + +test('rejects a currency_converter DEDICATED provider whose url is private', function () { + postJson('/api/v1/exchange-rate-providers', [ + 'driver' => 'currency_converter', + 'key' => 'test-key', + 'driver_config' => [ + 'type' => 'DEDICATED', + 'url' => 'http://169.254.169.254', + ], + 'active' => false, + ])->assertStatus(422)->assertJsonValidationErrors('driver_config.url'); +}); diff --git a/tests/Support/ScriptedAiDriver.php b/tests/Support/ScriptedAiDriver.php new file mode 100644 index 00000000..cb862936 --- /dev/null +++ b/tests/Support/ScriptedAiDriver.php @@ -0,0 +1,74 @@ + */ + public static array $responses = []; + + public static int $callCount = 0; + + /** @var array> */ + public static array $lastMessages = []; + + /** @var array> */ + public static array $lastTools = []; + + public static function reset(): void + { + self::$responses = []; + self::$callCount = 0; + self::$lastMessages = []; + self::$lastTools = []; + } + + /** + * @param array $responses + */ + public static function setResponses(array $responses): void + { + self::$responses = $responses; + self::$callCount = 0; + } + + public function chatCompletion( + array $messages, + string $model, + array $tools = [], + array $options = [], + ): AiChatResponse { + self::$lastMessages = $messages; + self::$lastTools = $tools; + $response = self::$responses[self::$callCount] ?? new AiChatResponse(message: 'Default test reply'); + self::$callCount++; + + return $response; + } + + public function textCompletion(string $prompt, string $model, array $options = []): string + { + return 'text completion test'; + } + + public function validateConnection(): array + { + return ['ok' => true]; + } +} diff --git a/tests/Unit/AiToolRegistryTest.php b/tests/Unit/AiToolRegistryTest.php index 505e374c..c5b6403a 100644 --- a/tests/Unit/AiToolRegistryTest.php +++ b/tests/Unit/AiToolRegistryTest.php @@ -39,6 +39,11 @@ class FakeAiTool extends AiTool ]; } + public function requiredAbility(): ?array + { + return null; + } + public function execute(array $arguments, int $companyId, int $userId): mixed { $this->lastArgs = $arguments; @@ -64,7 +69,7 @@ test('schemas returns OpenAI-format tool entries for every registered tool', fun $registry->register(new FakeAiTool('alpha')); $registry->register(new FakeAiTool('beta')); - $schemas = $registry->schemas(); + $schemas = $registry->schemas(1); expect($schemas)->toHaveCount(2); expect($schemas[0]['type'])->toBe('function'); diff --git a/tests/Unit/OpenRouterDriverTest.php b/tests/Unit/OpenRouterDriverTest.php new file mode 100644 index 00000000..87ed833f --- /dev/null +++ b/tests/Unit/OpenRouterDriverTest.php @@ -0,0 +1,26 @@ + 'http://169.254.169.254']); + + expect(fn () => $driver->validateConnection())->toThrow(AiException::class); +}); + +test('the thrown AI exception carries the invalid_base_url error key', function () { + $driver = new OpenRouterDriver('test-key', ['base_url' => 'http://127.0.0.1:11434']); + + try { + $driver->validateConnection(); + test()->fail('Expected AiException was not thrown'); + } catch (AiException $e) { + expect($e->errorKey)->toBe('invalid_base_url'); + } +}); diff --git a/tests/Unit/PrivateNetworkGuardTest.php b/tests/Unit/PrivateNetworkGuardTest.php new file mode 100644 index 00000000..bc5d4d89 --- /dev/null +++ b/tests/Unit/PrivateNetworkGuardTest.php @@ -0,0 +1,73 @@ +not->toBeNull(); +})->with([ + 'loopback v4' => 'http://127.0.0.1', + 'loopback v4 with port/path' => 'http://127.0.0.1:8080/v1/chat', + 'rfc1918 10/8' => 'http://10.0.0.1', + 'rfc1918 10/8 upper' => 'http://10.255.255.255/models', + 'rfc1918 172.16/12' => 'http://172.16.0.5', + 'rfc1918 172.31' => 'https://172.31.255.255', + 'rfc1918 192.168/16' => 'http://192.168.1.1', + 'link-local / cloud metadata' => 'http://169.254.169.254/latest/meta-data/', + 'unspecified 0.0.0.0' => 'http://0.0.0.0', + 'cgnat 100.64/10' => 'http://100.64.0.1', + 'broadcast' => 'http://255.255.255.255', + 'loopback v6' => 'http://[::1]', + 'ula v6' => 'http://[fd00::1]', + 'link-local v6' => 'http://[fe80::1]', + 'ipv4-mapped v6' => 'http://[::ffff:10.0.0.1]', + 'localhost' => 'http://localhost', + 'bad scheme ftp' => 'ftp://1.1.1.1', + 'bad scheme file' => 'file:///etc/passwd', + 'no scheme or host' => 'justsometext', +]); + +test('blockedReason allows public addresses and fails open on unresolvable hosts', function (string $url) { + expect(PrivateNetworkGuard::blockedReason($url))->toBeNull(); +})->with([ + 'cloudflare dns literal' => 'https://1.1.1.1', + 'google dns literal with path' => 'https://8.8.8.8/v1/models?x=1', + 'public literal' => 'http://93.184.216.34', + 'unresolvable .invalid (fail-open)' => 'https://surely-not-a-real-host.invalid/api', +]); + +test('blockedReason returns null for an empty string', function () { + expect(PrivateNetworkGuard::blockedReason(''))->toBeNull(); + expect(PrivateNetworkGuard::blockedReason(' '))->toBeNull(); +}); + +test('assertAllowed throws on a blocked URL', function () { + expect(fn () => PrivateNetworkGuard::assertAllowed('http://169.254.169.254')) + ->toThrow(BlockedUrlException::class); +}); + +test('assertAllowed is silent on a public URL', function () { + PrivateNetworkGuard::assertAllowed('https://1.1.1.1'); + + expect(true)->toBeTrue(); +}); + +test('ipIsBlocked classifies individual addresses', function () { + expect(PrivateNetworkGuard::ipIsBlocked('10.0.0.1'))->toBeTrue(); + expect(PrivateNetworkGuard::ipIsBlocked('169.254.169.254'))->toBeTrue(); + expect(PrivateNetworkGuard::ipIsBlocked('::1'))->toBeTrue(); + expect(PrivateNetworkGuard::ipIsBlocked('fd00::1'))->toBeTrue(); + expect(PrivateNetworkGuard::ipIsBlocked('::ffff:192.168.0.1'))->toBeTrue(); + + expect(PrivateNetworkGuard::ipIsBlocked('1.1.1.1'))->toBeFalse(); + expect(PrivateNetworkGuard::ipIsBlocked('2606:4700:4700::1111'))->toBeFalse(); +}); + +test('ipInCidr handles v4 boundaries and rejects cross-family comparisons', function () { + expect(PrivateNetworkGuard::ipInCidr('172.16.0.0', '172.16.0.0/12'))->toBeTrue(); + expect(PrivateNetworkGuard::ipInCidr('172.31.255.255', '172.16.0.0/12'))->toBeTrue(); + expect(PrivateNetworkGuard::ipInCidr('172.32.0.0', '172.16.0.0/12'))->toBeFalse(); + + // v6 address against a v4 CIDR must never match. + expect(PrivateNetworkGuard::ipInCidr('::1', '10.0.0.0/8'))->toBeFalse(); +}); diff --git a/tests/Unit/Rules/PublicHttpUrlTest.php b/tests/Unit/Rules/PublicHttpUrlTest.php new file mode 100644 index 00000000..e507fd34 --- /dev/null +++ b/tests/Unit/Rules/PublicHttpUrlTest.php @@ -0,0 +1,39 @@ + $url], + ['base_url' => [new PublicHttpUrl]] + ); + + expect($validator->passes())->toBeTrue(); +})->with([ + 'https literal' => 'https://1.1.1.1/api/v1', + 'unresolvable host fails open' => 'https://surely-not-a-real-host.invalid/api', +]); + +test('fails for private or reserved URLs', function (string $url) { + $validator = Validator::make( + ['base_url' => $url], + ['base_url' => [new PublicHttpUrl]] + ); + + expect($validator->passes())->toBeFalse(); +})->with([ + 'cloud metadata' => 'http://169.254.169.254/latest/meta-data/', + 'loopback' => 'http://127.0.0.1:11434', + 'rfc1918' => 'http://10.0.0.5', + 'localhost' => 'http://localhost', +]); + +test('passes for empty values so it composes with nullable', function () { + $validator = Validator::make( + ['base_url' => ''], + ['base_url' => ['nullable', new PublicHttpUrl]] + ); + + expect($validator->passes())->toBeTrue(); +});