> */ private const LOCAL_PROVIDER_PORTS = [ 'ollama' => [11434], 'lm_studio' => [1234], 'vllm' => [8000], 'localai' => [8080], ]; private readonly Closure $resolver; /** @var list */ private readonly array $customProviderHosts; /** * @param (Closure(string): list)|null $resolver * @param list|null $customProviderHosts */ public function __construct(?Closure $resolver = null, ?array $customProviderHosts = null) { $this->resolver = $resolver ?? fn (string $host): array => $this->resolveHost($host); $configuredHosts = $customProviderHosts ?? config('ai.security.custom_provider_allowed_hosts', []); $this->customProviderHosts = array_values(array_unique(array_filter(array_map( fn (mixed $host): string => $this->normalizeHost((string) $host), is_array($configuredHosts) ? $configuredHosts : [], )))); } public function assertAllowed(string $provider, string $mode, string $baseUrl): void { $endpoint = $this->endpoint($baseUrl); if (isset(self::LOCAL_PROVIDER_PORTS[$provider])) { if ($mode !== 'local' || ! in_array($endpoint['port'], self::LOCAL_PROVIDER_PORTS[$provider], true)) { throw new InvalidArgumentException('Local AI provider mode or port is not allowed.'); } if (! $this->isLoopbackHost($endpoint['host']) || ! in_array($endpoint['scheme'], ['http', 'https'], true)) { throw new InvalidArgumentException('Local AI providers are restricted to loopback addresses.'); } return; } if ($provider === 'openai') { if ($mode !== 'online' || $endpoint['scheme'] !== 'https' || $endpoint['host'] !== 'api.openai.com' || $endpoint['port'] !== 443) { throw new InvalidArgumentException('OpenAI connections must use the official HTTPS endpoint.'); } return; } if ($provider !== 'openai_compatible' || $mode !== 'online') { throw new InvalidArgumentException('Unknown AI provider policy.'); } if ($endpoint['scheme'] !== 'https' || $endpoint['port'] !== 443 || ! in_array($endpoint['host'], $this->customProviderHosts, true)) { throw new InvalidArgumentException('Custom online AI providers require an exact HTTPS host allowlist entry on port 443.'); } } /** @return array{allow_redirects: false, curl: array>} */ public function requestOptions(string $provider, string $mode, string $baseUrl): array { $this->assertAllowed($provider, $mode, $baseUrl); $endpoint = $this->endpoint($baseUrl); $addresses = ($this->resolver)($endpoint['host']); if ($addresses === []) { throw new InvalidArgumentException('AI provider hostname could not be resolved safely.'); } $local = isset(self::LOCAL_PROVIDER_PORTS[$provider]); foreach ($addresses as $address) { if (! filter_var($address, FILTER_VALIDATE_IP)) { throw new InvalidArgumentException('AI provider resolved to an invalid address.'); } if ($local ? ! $this->isLoopbackIp($address) : ! $this->isPublicIp($address)) { throw new InvalidArgumentException('AI provider resolved to a disallowed network address.'); } } if (! defined('CURLOPT_RESOLVE')) { throw new InvalidArgumentException('Secure DNS pinning is unavailable in this PHP runtime.'); } $address = str_contains($addresses[0], ':') ? '['.$addresses[0].']' : $addresses[0]; return [ 'allow_redirects' => false, 'curl' => [constant('CURLOPT_RESOLVE') => ["{$endpoint['host']}:{$endpoint['port']}:{$address}"]], ]; } /** @return array{scheme: string, host: string, port: int} */ private function endpoint(string $baseUrl): array { if (! filter_var($baseUrl, FILTER_VALIDATE_URL)) { throw new InvalidArgumentException('AI provider URL is invalid.'); } $parts = parse_url($baseUrl); if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { throw new InvalidArgumentException('AI provider URL is incomplete.'); } if (isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) { throw new InvalidArgumentException('AI provider URL credentials, query strings, and fragments are not allowed.'); } $scheme = strtolower($parts['scheme']); $host = $this->normalizeHost($parts['host']); if ($host === '' || (! filter_var($host, FILTER_VALIDATE_IP) && ! preg_match('/^[a-z0-9.-]+$/', $host))) { throw new InvalidArgumentException('AI provider hostname is invalid.'); } return [ 'scheme' => $scheme, 'host' => $host, 'port' => (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80)), ]; } /** @return list */ private function resolveHost(string $host): array { if (filter_var($host, FILTER_VALIDATE_IP)) { return [$host]; } if ($host === 'localhost') { return ['127.0.0.1']; } $addresses = []; foreach (dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $record) { $address = $record['ip'] ?? $record['ipv6'] ?? null; if (is_string($address)) { $addresses[] = $address; } } return array_values(array_unique($addresses)); } private function normalizeHost(string $host): string { return strtolower(rtrim(trim($host), '.')); } private function isLoopbackHost(string $host): bool { return $host === 'localhost' || (filter_var($host, FILTER_VALIDATE_IP) && $this->isLoopbackIp($host)); } private function isLoopbackIp(string $address): bool { if ($address === '::1') { return true; } if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return false; } return str_starts_with($address, '127.'); } private function isPublicIp(string $address): bool { return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; } }