|null $allowedHosts hosts an administrator vouched * for; null reads the setting at the moment of * the lookup, so saving the screen takes effect * on that same request instead of the next one. */ public function __construct(protected int $timeout = 6, protected ?array $allowedHosts = null) {} /** * Hosts an administrator marked as internal on the single sign-on screen. * * @return array */ public static function configuredHosts(): array { $raw = (string) Setting::get('sso.internal_hosts', ''); return array_values(array_filter(array_map( 'trim', preg_split('/[\s,]+/', $raw) ?: [], ))); } /** @throws \RuntimeException when the host is missing, unresolvable or private */ public function to(string $url): PendingRequest { $host = $this->host($url); return $this->pinned($url, $host, $this->resolve($host)); } /** * For a target the administrator owns, such as an n8n on the same LAN: * private networks are fine here, this machine and link-local are not. A * name that does not resolve is left to curl, which fails on it by itself. * * @throws \RuntimeException when the host is missing or must never be reached */ public function toOwn(string $url): PendingRequest { $host = $this->host($url); return $this->pinned($url, $host, $this->resolveOwn($host)); } /** Resolve once, vet every answer, and never delegate an unchecked lookup. */ public function resolveOwn(string $host): string { $addresses = $this->addresses($host); if ($addresses === []) { throw new \RuntimeException("Could not resolve {$host}."); } if (($ip = $this->forbiddenAmong($addresses)) !== null) { throw new \RuntimeException("{$host} resolves to {$ip}, which is never allowed."); } return $addresses[0]; } /** The address behind a URL that nothing may reach, or null when there is none. */ public function forbiddenAddress(string $url): ?string { return $this->forbiddenAmong($this->addresses($this->host($url))); } /** @throws \RuntimeException */ protected function host(string $url): string { $host = parse_url($url, PHP_URL_HOST); if (! is_string($host) || $host === '') { throw new \RuntimeException('That does not look like a URL.'); } // parse_url keeps the brackets on an IPv6 literal; with them on, neither // filter_var nor DNS recognises it and the address would go unchecked. return trim($host, '[]'); } protected function pinned(string $url, string $host, ?string $ip): PendingRequest { $request = Http::timeout($this->timeout)->withoutRedirecting(); // A literal needs no pin, and an unresolved name has nothing to pin to. if ($ip === null || filter_var($host, FILTER_VALIDATE_IP)) { return $request; } $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); $port = parse_url($url, PHP_URL_PORT) ?? match ($scheme) { 'http' => 80, 'https' => 443, default => throw new \RuntimeException('Only HTTP and HTTPS targets are allowed.'), }; // Pinned: curl connects to the address we vetted, not to whatever a // second lookup would return. return $request->withOptions(['curl' => [CURLOPT_RESOLVE => ["{$host}:{$port}:{$ip}"]]]); } /** * Every address a host answers with, or [] when it does not resolve. * * @return array */ public function addresses(string $host): array { // A literal address skips DNS but not the checks that follow. if (filter_var($host, FILTER_VALIDATE_IP)) { return [$host]; } return array_merge( gethostbynamel($host) ?: [], array_column(@dns_get_record($host, DNS_AAAA) ?: [], 'ipv6'), ); } /** @param array $addresses */ protected function forbiddenAmong(array $addresses): ?string { foreach ($addresses as $ip) { if ($this->isNeverReachable($ip)) { // The address the socket would reach, which is what the message // has to name. return $this->canonical($ip); } } return null; } /** @throws \RuntimeException */ public function resolve(string $host): string { $candidates = $this->addresses($host); if ($candidates === []) { throw new \RuntimeException("Could not resolve {$host}."); } $vouchedFor = $this->isAllowed($host); // Every answer has to pass: one private address among them is enough to // make the destination unsafe. foreach ($candidates as $candidate) { if ($this->isNeverReachable($candidate)) { throw new \RuntimeException("{$host} resolves to {$candidate}, which is link local or this machine. That address is never allowed."); } if (! $vouchedFor && ! $this->isPublic($candidate)) { throw new \RuntimeException("{$host} resolves to {$candidate}, which is on a private or local network. Add the host to the internal hosts list if that is deliberate."); } } return $candidates[0]; } public function isAllowed(string $host): bool { $allowed = $this->allowedHosts ?? static::configuredHosts(); return in_array(strtolower($host), array_map('strtolower', $allowed), true); } /** The ranges live in IpAddress, shared with the page SMTP check. */ public function isNeverReachable(string $ip): bool { return IpAddress::isNeverReachable($ip); } public function isPublic(string $ip): bool { return IpAddress::isPublic($ip); } protected function canonical(string $ip): string { return IpAddress::canonical($ip); } }