pin($url, $strict) !== null; } /** * Sends one request (POST form when $post is given, otherwise GET) and returns the decoded JSON object. * * @throws OAuthException with a sanitised message */ public function request(string $url, bool $strict, ?array $post = null, array $headers = []): array { $pin = $this->pin($url, $strict); if ($pin === null) { throw new OAuthException('endpoint not allowed'); } $options = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROXY => '', CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 20, CURLOPT_MAXFILESIZE => 1048576, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers), ]; if (!empty($pin)) { $options[CURLOPT_RESOLVE] = $pin; } if ($post !== null) { $options[CURLOPT_POST] = true; $options[CURLOPT_POSTFIELDS] = http_build_query($post); } [$body, $code] = $this->send($options); if ($body === false) { throw new OAuthException('connection failed'); } $data = json_decode($body, true); $ok = ($code >= 200 && $code < 300); if ($ok && is_array($data)) { return $data; } throw new OAuthException($this->errorText($code, $ok, $data)); } /** * IPv4 and IPv6 addresses of a host. * * @return string[] */ protected function resolve(string $host): array { $ips = @gethostbynamel($host) ?: []; foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $record) { $ips[] = $record['ipv6'] ?? ''; } return $ips; } /** * Runs curl with the given options. * * @return array{0: string|false, 1: int} response body (false on connection errors) and HTTP status */ protected function send(array $options): array { $ch = curl_init(); curl_setopt_array($ch, $options); $body = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [is_string($body) ? $body : false, $code]; } /** * Applies the endpoint policy. Returns null when the URL is not allowed, otherwise the CURLOPT_RESOLVE * pin (empty when not strict). */ private function pin(string $url, bool $strict): ?array { if (strlen($url) > self::MAX_URL_LENGTH || preg_match('/[\x00-\x20\x7f]/', $url)) { return null; } $parts = parse_url($url); if (!$parts || strtolower($parts['scheme'] ?? '') !== 'https' || isset($parts['user']) || isset($parts['pass'])) { return null; } $host = strtolower($parts['host'] ?? ''); if (!filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) || !filter_var($url, FILTER_VALIDATE_URL)) { return null; } if (!$strict) { return []; } $ips = array_values(array_unique($this->resolve($host))); if (empty($ips)) { return null; } $pinned = []; foreach ($ips as $ip) { if (!$this->isPublic($ip)) { return null; } $pinned[] = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $ip . ']' : $ip; } return [$host . ':' . (int)($parts['port'] ?? 443) . ':' . implode(',', $pinned)]; } /** * Public unicast address only: no private, loopback, link-local or reserved ranges, no CGNAT (100.64/10), * benchmarking (198.18/15) or multicast/reserved (224/3); IPv6 only global unicast 2000::/3 without * 6to4 (2002::/16), Teredo (2001::/32) and documentation (2001:db8::/32). */ private function isPublic(string $ip): bool { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return false; } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { $long = ip2long($ip); return ($long & 0xFFC00000) != 0x64400000 && ($long & 0xFFFE0000) != 0xC6120000 && ($long & 0xE0000000) != 0xE0000000; } $hex = bin2hex((string)inet_pton($ip)); return ($hex[0] === '2' || $hex[0] === '3') && substr($hex, 0, 4) !== '2002' && !in_array(substr($hex, 0, 8), ['20010000', '20010db8'], true); } /** * Error text that is safe to store: "HTTP " plus the provider's error / error_description. */ private function errorText(int $code, bool $ok, mixed $data): string { $text = 'HTTP ' . $code . ($ok ? ': malformed response' : ''); $detail = []; foreach (['error', 'error_description'] as $key) { if (is_array($data) && isset($data[$key]) && is_scalar($data[$key]) && (string)$data[$key] !== '') { $detail[] = (string)$data[$key]; } } if (!empty($detail)) { $text .= ': ' . implode(' - ', $detail); } return mb_substr(trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', strip_tags($text))), 0, self::MAX_ERROR_LENGTH); } }