JezK
Edit File: ProxyOptions.php
<?php declare(strict_types=1); namespace GuzzleHttp; use GuzzleHttp\Exception\InvalidArgumentException; use GuzzleHttp\Psr7\Rfc3986; use Psr\Http\Message\UriInterface; final class ProxyOptions { private function __construct() { } /** * Resolve Guzzle's documented proxy request option for a request URI. * * @param mixed $proxy Proxy option as passed via request transfer options. * * @throws InvalidArgumentException */ public static function resolve( UriInterface $uri, #[\SensitiveParameter] $proxy ): ProxySelection { if ($proxy === null) { return ProxySelection::none(); } if (!\is_array($proxy)) { if (!\is_string($proxy)) { throw new InvalidArgumentException('proxy must be a string or array'); } return ProxySelection::proxy($proxy); } $schemeProxy = $proxy[$uri->getScheme()] ?? null; if ($schemeProxy !== null && !\is_string($schemeProxy)) { throw new InvalidArgumentException('proxy values must be strings'); } // A matching "no" entry is always a final decision, even when the // array selects no proxy for the request scheme. Without this, an // option-level bypass could fall through to handler-level environment // fallback and route through a proxy the user excluded. $noProxy = isset($proxy['no']) ? self::normalizeNoProxy($proxy['no']) : []; if ($noProxy !== [] && self::isUriInNoProxy($uri, $noProxy)) { return ProxySelection::bypassed(); } if ($schemeProxy === null) { return ProxySelection::none(); } return ProxySelection::proxy($schemeProxy); } /** * Validate a proxy URL and return its lowercased scheme. * * A proxy is an authority ([userinfo@]host[:port]) with an optional scheme; * a scheme-less value is an HTTP proxy. The scheme is matched anchored at * the start, so leading junk before it is rejected as malformed, and the * host and port grammar is delegated to Psr7\Rfc3986. The whole string is * validated up front so a malformed proxy fails the same way on every * handler, but the original value is what callers pin, so no normalization * reaches the wire. The error message never includes the proxy, which may * carry credentials. * * @throws InvalidArgumentException on a malformed proxy URL */ public static function proxyScheme( #[\SensitiveParameter] string $proxy ): string { $parts = \explode('://', $proxy, 2); if (\count($parts) === 1) { $scheme = 'http'; $authority = $proxy; } else { [$scheme, $authority] = $parts; $scheme = Psr7\Utils::asciiToLower($scheme); if ($scheme === '' || !Rfc3986::isValidScheme($scheme)) { throw new InvalidArgumentException('Invalid proxy URL.'); } } if (!self::isValidProxyAuthority($authority)) { throw new InvalidArgumentException('Invalid proxy URL.'); } return $scheme; } /** * Whether the string is a valid proxy authority: [userinfo@]host[:port]. * * A single trailing slash is tolerated; anything else after the authority * (a path, query, or fragment) is rejected. Userinfo is not policed here * (the proxy handles its own credentials); host and port grammar are * delegated to Psr7\Rfc3986, where a zero port is accepted and left for the * transport to handle as it did before. */ private static function isValidProxyAuthority(string $authority): bool { // A single trailing slash is tolerated; once removed, a proxy authority // ([userinfo@]host[:port]) cannot contain a path, query, or fragment // delimiter anywhere — including one before a later '@'. if (\str_ends_with($authority, '/')) { $authority = \substr($authority, 0, \strlen($authority) - 1); } if ($authority === '' || \strpbrk($authority, '/?#') !== false) { return false; } // The host cannot contain '@', so userinfo is everything before the // last one. It is not validated here. A value that is only userinfo, // with nothing after the last '@', has no host and is rejected. $segments = \explode('@', $authority); $authority = $segments[\count($segments) - 1]; if ($authority === '') { return false; } $port = null; if (\str_starts_with($authority, '[')) { $parts = \explode(']', $authority, 2); if (\count($parts) !== 2) { return false; } $host = $parts[0].']'; $remainder = $parts[1]; if ($remainder !== '') { if (!\str_starts_with($remainder, ':')) { return false; } [, $port] = \explode(':', $remainder, 2); } } elseif (\strpos($authority, ':') !== false) { [$host, $port] = \explode(':', $authority, 2); } else { $host = $authority; } // A dangling "host:" carries no port, matching the previous behavior. if ($port === '') { $port = null; } if ($host === '' || !Rfc3986::isValidHost($host)) { return false; } return $port === null || Rfc3986::isValidPort($port); } /** * Normalize a no-proxy list from request options or NO_PROXY. * * @param mixed $noProxy No-proxy value as passed via request transfer options. * * @return string[] * * @throws InvalidArgumentException */ public static function normalizeNoProxy($noProxy): array { if ($noProxy === null) { return []; } if (\is_string($noProxy)) { // Entries may be separated by whitespace as well as commas, // matching the no_proxy environment variable conventions. $noProxy = \preg_split('/[\s,]+/', $noProxy); if ($noProxy === false) { throw new \RuntimeException('Unable to split the proxy no list: '.\preg_last_error_msg()); } } elseif (!\is_array($noProxy)) { throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings'); } $result = []; foreach ($noProxy as $area) { if (!\is_string($area)) { throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings'); } $area = \trim($area, " \n\r\t\0\x0B"); if ($area !== '') { $result[] = $area; } } return $result; } /** * Returns true if the provided URI matches any of the no-proxy areas. * * @param string[] $noProxy An array of host, host-and-port, or CIDR patterns. * * @throws InvalidArgumentException */ public static function isUriInNoProxy(UriInterface $uri, array $noProxy): bool { self::assertNoProxyList($noProxy); $target = self::parseNoProxyTarget($uri); if ($target === null) { return false; } foreach ($noProxy as $area) { $area = \trim($area, " \n\r\t\0\x0B"); if ($area === '*') { return true; } $rule = self::parseNoProxyRule($area); if ($rule !== null && self::noProxyRuleMatches($target, $rule)) { return true; } } return false; } /** * Returns true if the provided host matches any of the no-proxy areas. * * This method will strip a port from the host if it is present. Domain * patterns are matched case-insensitively. Exact IP literal patterns are * matched by their normalized binary address, and a host or pattern * written in the inet_aton() shorthand a transport reads as an address, * such as 127.1 or 0x7f000001, is matched as that address. * * Areas are matched in the following cases: * 1. "*" (without quotes) always matches any hosts. * 2. An exact domain or IP literal match. * 3. A bare domain or a leading-dot domain matches itself and its * subdomains. e.g. 'mit.edu' and '.mit.edu' both match 'mit.edu' * and 'foo.mit.edu'. * 4. IP CIDR entries match IP literal hosts. e.g. '192.168.0.0/16' will * match '192.168.1.10' and 'fd00::/8' will match '[fd00::1]'. * * @param string $host Host to check against the patterns. * @param string[] $noProxy An array of host or CIDR patterns. * * @throws InvalidArgumentException */ public static function isHostInNoProxy(string $host, array $noProxy): bool { if ($host === '') { throw new InvalidArgumentException('Empty host provided'); } self::assertNoProxyList($noProxy); $target = self::parseNoProxyHostString($host); if ($target === null) { return false; } foreach ($noProxy as $area) { $area = \trim($area, " \n\r\t\0\x0B"); if ($area === '*') { return true; } $rule = self::parseNoProxyRule($area); if ($rule !== null && self::noProxyRuleMatches($target, $rule)) { return true; } } return false; } /** * @param array<array-key, mixed> $noProxy * * @throws InvalidArgumentException */ private static function assertNoProxyList(array $noProxy): void { foreach ($noProxy as $area) { if (!\is_string($area)) { throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings'); } } } /** * @return array{type: string, value: string, port: int|null}|null */ private static function parseNoProxyTarget(UriInterface $uri): ?array { $host = $uri->getHost(); if ($host === '') { return null; } return self::parseNoProxyHost($host, $uri->getPort() ?? self::getDefaultPort($uri->getScheme())); } /** * @return array{type: string, value: string, port: int|null}|null */ private static function parseNoProxyHostString(string $host): ?array { $hostAndPort = self::splitNoProxyHostAndPort($host); if ($hostAndPort === null) { return null; } [$host] = $hostAndPort; return self::parseNoProxyHost($host, null); } /** * @return array{type: string, value: string, port: int|null}|array{type: string, value: string, prefix: int}|null */ private static function parseNoProxyRule(string $area): ?array { $area = \trim($area, " \n\r\t\0\x0B"); if ($area === '' || $area === '*') { return null; } // A single leading dot is ignored: ".example.com" matches // example.com and its subdomains exactly like a bare domain, // consistent with every libcurl era. The strip runs before the // CIDR check so ".10.0.0.0/8" is a live rule on every path. if ($area[0] === '.') { $area = \substr($area, 1); if ($area === '') { return null; } } if (\strpos($area, '/') !== false) { return self::parseNoProxyCidrRule($area); } $hostAndPort = self::splitNoProxyHostAndPort($area); if ($hostAndPort === null) { return null; } [$host, $port] = $hostAndPort; if ($host === '*') { return [ 'type' => 'wildcard', 'value' => '*', 'port' => $port, ]; } return self::parseNoProxyHost($host, $port); } /** * @return array{type: string, value: string, port: int|null}|null */ private static function parseNoProxyHost(string $host, ?int $port): ?array { if ($host !== '' && $host[0] === '[') { if (\substr($host, -1) !== ']') { return null; } $address = \substr($host, 1, -1); if (!\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { return null; } $host = $address; } $packedIp = self::packHostAddress($host); if ($packedIp !== false) { return [ 'type' => 'ip', 'value' => $packedIp, 'port' => $port, ]; } if ($host === '' || \strpos($host, ':') !== false) { return null; } // Normalize a single DNS root dot for no-proxy domain matching. if (\substr($host, -1) === '.') { $host = \substr($host, 0, -1); if ($host === '') { return null; } } return [ 'type' => 'domain', 'value' => Psr7\Utils::asciiToLower($host), 'port' => $port, ]; } /** * @return array{0: string, 1: int|null}|null */ private static function splitNoProxyHostAndPort(string $area): ?array { if ($area !== '' && $area[0] === '[') { $closingBracket = \strpos($area, ']'); if ($closingBracket === false) { return null; } $host = \substr($area, 0, $closingBracket + 1); $tail = \substr($area, $closingBracket + 1); if ($tail === '') { return [$host, null]; } if ($tail[0] !== ':') { return null; } $port = self::parseNoProxyPort(\substr($tail, 1)); return $port === null ? null : [$host, $port]; } if (self::packHostAddress($area) !== false) { return [$area, null]; } $colon = \strrpos($area, ':'); if ($colon === false) { return [$area, null]; } $port = self::parseNoProxyPort(\substr($area, $colon + 1)); if ($port === null) { return null; } return [\substr($area, 0, $colon), $port]; } private static function parseNoProxyPort(string $port): ?int { return self::parseBoundedUnsignedInteger($port, 65535); } private static function getDefaultPort(string $scheme): ?int { if ($scheme === 'http') { return 80; } if ($scheme === 'https') { return 443; } return null; } /** * @return array{type: string, value: string, prefix: int}|null */ private static function parseNoProxyCidrRule(string $area): ?array { $slash = \strpos($area, '/'); if ($slash === false) { return null; } $prefix = \substr($area, $slash + 1); $network = \substr($area, 0, $slash); if ($network !== '' && $network[0] === '[' && \substr($network, -1) === ']') { $network = \substr($network, 1, -1); } // A bare rule denotes a host identity, but a CIDR rule uses network // configuration syntax, so the inet_aton() shorthand is not read as // a network: 127/8 would name 0.0.0.127/8 rather than 127.0.0.0/8. $network = self::packIpAddress($network); if ($network === false) { return null; } $prefix = self::parseBoundedUnsignedInteger($prefix, \strlen($network) * 8); if ($prefix === null) { return null; } return [ 'type' => 'cidr', 'value' => $network, 'prefix' => $prefix, ]; } private static function parseBoundedUnsignedInteger(string $value, int $max): ?int { if ($value === '' || !\ctype_digit($value)) { return null; } $normalized = \ltrim($value, '0'); $normalized = $normalized === '' ? '0' : $normalized; $limit = (string) $max; if (\strlen($normalized) > \strlen($limit) || (\strlen($normalized) === \strlen($limit) && \strcmp($normalized, $limit) > 0)) { return null; } return (int) $normalized; } /** * @param array{type: string, value: string, port: int|null} $target * @param array{type: string, value: string, port?: int|null, prefix?: int|null} $rule */ private static function noProxyRuleMatches(array $target, array $rule): bool { if ($rule['type'] === 'wildcard') { return ($rule['port'] ?? null) === null || $rule['port'] === $target['port']; } if ($rule['type'] === 'cidr') { if ($target['type'] !== 'ip' || !isset($rule['prefix'])) { return false; } if (\strlen($target['value']) !== \strlen($rule['value'])) { return false; } return self::ipMatchesPrefix($target['value'], $rule['value'], $rule['prefix']); } if (($rule['port'] ?? null) !== null && $rule['port'] !== $target['port']) { return false; } if ($rule['type'] !== $target['type']) { return false; } if ($rule['type'] === 'ip') { return $rule['value'] === $target['value']; } if ($target['value'] === $rule['value']) { return true; } $suffix = '.'.$rule['value']; return \substr($target['value'], -\strlen($suffix)) === $suffix; } /** * @return string|false */ private static function packIpAddress(string $ip) { if (\filter_var($ip, \FILTER_VALIDATE_IP)) { return \inet_pton($ip); } return false; } /** * @return string|false */ private static function packHostAddress(string $host) { $packed = self::packIpAddress($host); if ($packed !== false) { return $packed; } // A transport reads the inet_aton() shorthand as an address too, so a // rule and a request host that name one address have to match // whichever spelling each of them happens to use. return HostIdentity::numericIpv4ToBinary($host) ?? false; } private static function ipMatchesPrefix(string $address, string $network, int $prefix): bool { $fullBytes = \intdiv($prefix, 8); $remainingBits = $prefix % 8; if ($fullBytes > 0 && \substr($address, 0, $fullBytes) !== \substr($network, 0, $fullBytes)) { return false; } if ($remainingBits === 0) { return true; } $mask = (0xFF << (8 - $remainingBits)) & 0xFF; return (\ord($address[$fullBytes]) & $mask) === (\ord($network[$fullBytes]) & $mask); } }