From e1616311052ddc554dbadac490565bf41d00bf73 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 20 Nov 2022 23:42:14 -0500 Subject: [PATCH 1/2] Add UriFactory::createFromSapi() This method will provide a public API to create Uri instances from SERVER context. This helps libraries that depend on diactoros and have their own ServerRequest or Uri implementations use the logic in diactoros more efficiently. Signed-off-by: Mark Story --- src/ServerRequestFactory.php | 233 +------------------------------ src/UriFactory.php | 233 +++++++++++++++++++++++++++++++ test/UriFactoryTest.php | 256 +++++++++++++++++++++++++++++++++++ 3 files changed, 490 insertions(+), 232 deletions(-) create mode 100644 test/UriFactoryTest.php diff --git a/src/ServerRequestFactory.php b/src/ServerRequestFactory.php index 6835758d..5c6718e3 100644 --- a/src/ServerRequestFactory.php +++ b/src/ServerRequestFactory.php @@ -9,26 +9,8 @@ use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; -use function array_change_key_case; use function array_key_exists; -use function explode; -use function gettype; -use function implode; -use function is_array; -use function is_bool; use function is_callable; -use function is_string; -use function ltrim; -use function preg_match; -use function preg_replace; -use function sprintf; -use function str_contains; -use function strlen; -use function strrpos; -use function strtolower; -use function substr; - -use const CASE_LOWER; /** * Class for marshaling a request object from the current PHP environment. @@ -88,7 +70,7 @@ public static function fromGlobals( return $requestFilter(new ServerRequest( $server, $files, - self::marshalUriFromSapi($server, $headers), + UriFactory::createFromSapi($server, $headers), marshalMethodFromSapi($server), 'php://input', $headers, @@ -114,217 +96,4 @@ public function createServerRequest(string $method, $uri, array $serverParams = 'php://temp' ); } - - /** - * Marshal a Uri instance based on the values present in the $_SERVER array and headers. - * - * @param array> $headers - * @param array $server SAPI parameters - */ - private static function marshalUriFromSapi(array $server, array $headers): Uri - { - $uri = new Uri(''); - - // URI scheme - $https = false; - if (array_key_exists('HTTPS', $server)) { - $https = self::marshalHttpsValue($server['HTTPS']); - } elseif (array_key_exists('https', $server)) { - $https = self::marshalHttpsValue($server['https']); - } - - $uri = $uri->withScheme($https ? 'https' : 'http'); - - // Set the host - [$host, $port] = self::marshalHostAndPort($server, $headers); - if (! empty($host)) { - $uri = $uri->withHost($host); - if (! empty($port)) { - $uri = $uri->withPort($port); - } - } - - // URI path - $path = self::marshalRequestPath($server); - - // Strip query string - $path = explode('?', $path, 2)[0]; - - // URI query - $query = ''; - if (isset($server['QUERY_STRING'])) { - $query = ltrim((string) $server['QUERY_STRING'], '?'); - } - - // URI fragment - $fragment = ''; - if (str_contains($path, '#')) { - [$path, $fragment] = explode('#', $path, 2); - } - - return $uri - ->withPath($path) - ->withFragment($fragment) - ->withQuery($query); - } - - /** - * Marshal the host and port from the PHP environment. - * - * @param array> $headers - * @return array{string, int|null} Array of two items, host and port, - * in that order (can be passed to a list() operation). - */ - private static function marshalHostAndPort(array $server, array $headers): array - { - static $defaults = ['', null]; - - $host = self::getHeaderFromArray('host', $headers, false); - if ($host !== false) { - // Ignore obviously malformed host headers: - // - Whitespace is invalid within a hostname and break the URI representation within HTTP. - // non-printable characters other than SPACE and TAB are already rejected by HeaderSecurity. - // - A comma indicates that multiple host headers have been sent which is not legal - // and might be used in an attack where a load balancer sees a different host header - // than Diactoros. - if (! preg_match('/[\\t ,]/', $host)) { - return self::marshalHostAndPortFromHeader($host); - } - } - - if (! isset($server['SERVER_NAME'])) { - return $defaults; - } - - $host = (string) $server['SERVER_NAME']; - $port = isset($server['SERVER_PORT']) ? (int) $server['SERVER_PORT'] : null; - - if ( - ! isset($server['SERVER_ADDR']) - || ! preg_match('/^\[[0-9a-fA-F\:]+\]$/', $host) - ) { - return [$host, $port]; - } - - // Misinterpreted IPv6-Address - // Reported for Safari on Windows - return self::marshalIpv6HostAndPort($server, $port); - } - - /** - * @return array{string, int|null} Array of two items, host and port, - * in that order (can be passed to a list() operation). - */ - private static function marshalIpv6HostAndPort(array $server, ?int $port): array - { - $host = '[' . (string) $server['SERVER_ADDR'] . ']'; - $port = $port ?: 80; - $portSeparatorPos = strrpos($host, ':'); - - if (false === $portSeparatorPos) { - return [$host, $port]; - } - - if ($port . ']' === substr($host, $portSeparatorPos + 1)) { - // The last digit of the IPv6-Address has been taken as port - // Unset the port so the default port can be used - $port = null; - } - return [$host, $port]; - } - - /** - * Detect the path for the request - * - * Looks at a variety of criteria in order to attempt to autodetect the base - * request path, including: - * - * - IIS7 UrlRewrite environment - * - REQUEST_URI - * - ORIG_PATH_INFO - */ - private static function marshalRequestPath(array $server): string - { - // IIS7 with URL Rewrite: make sure we get the unencoded url - // (double slash problem). - $iisUrlRewritten = $server['IIS_WasUrlRewritten'] ?? null; - $unencodedUrl = $server['UNENCODED_URL'] ?? ''; - if ('1' === $iisUrlRewritten && is_string($unencodedUrl) && '' !== $unencodedUrl) { - return $unencodedUrl; - } - - $requestUri = $server['REQUEST_URI'] ?? null; - - if (is_string($requestUri)) { - return preg_replace('#^[^/:]+://[^/]+#', '', $requestUri); - } - - $origPathInfo = $server['ORIG_PATH_INFO'] ?? ''; - if (! is_string($origPathInfo) || '' === $origPathInfo) { - return '/'; - } - - return $origPathInfo; - } - - private static function marshalHttpsValue(mixed $https): bool - { - if (is_bool($https)) { - return $https; - } - - if (! is_string($https)) { - throw new Exception\InvalidArgumentException(sprintf( - 'SAPI HTTPS value MUST be a string or boolean; received %s', - gettype($https) - )); - } - - return 'on' === strtolower($https); - } - - /** - * @param string|list $host - * @return array Array of two items, host and port, in that order (can be - * passed to a list() operation). - */ - private static function marshalHostAndPortFromHeader($host): array - { - if (is_array($host)) { - $host = implode(', ', $host); - } - - $port = null; - - // works for regname, IPv4 & IPv6 - if (preg_match('|\:(\d+)$|', $host, $matches)) { - $host = substr($host, 0, -1 * (strlen($matches[1]) + 1)); - $port = (int) $matches[1]; - } - - return [$host, $port]; - } - - /** - * Retrieve a header value from an array of headers using a case-insensitive lookup. - * - * @template T - * @param array> $headers Key/value header pairs - * @param T $default Default value to return if header not found - * @return string|T - */ - private static function getHeaderFromArray(string $name, array $headers, $default = null) - { - $header = strtolower($name); - $headers = array_change_key_case($headers, CASE_LOWER); - if (! array_key_exists($header, $headers)) { - return $default; - } - - if (is_string($headers[$header])) { - return $headers[$header]; - } - - return implode(', ', $headers[$header]); - } } diff --git a/src/UriFactory.php b/src/UriFactory.php index f460e37d..085a90a4 100644 --- a/src/UriFactory.php +++ b/src/UriFactory.php @@ -7,6 +7,26 @@ use Psr\Http\Message\UriFactoryInterface; use Psr\Http\Message\UriInterface; +use function array_change_key_case; +use function array_key_exists; +use function explode; +use function gettype; +use function implode; +use function is_array; +use function is_bool; +use function is_string; +use function ltrim; +use function preg_match; +use function preg_replace; +use function sprintf; +use function str_contains; +use function strlen; +use function strrpos; +use function strtolower; +use function substr; + +use const CASE_LOWER; + class UriFactory implements UriFactoryInterface { /** @@ -16,4 +36,217 @@ public function createUri(string $uri = ''): UriInterface { return new Uri($uri); } + + /** + * Create a Uri instance based on the headers and $_SERVER data. + * + * @param array> $headers + * @param array $server SAPI parameters + */ + public static function createFromSapi(array $server, array $headers): Uri + { + $uri = new Uri(''); + + // URI scheme + $https = false; + if (array_key_exists('HTTPS', $server)) { + $https = self::marshalHttpsValue($server['HTTPS']); + } elseif (array_key_exists('https', $server)) { + $https = self::marshalHttpsValue($server['https']); + } + + $uri = $uri->withScheme($https ? 'https' : 'http'); + + // Set the host + [$host, $port] = self::marshalHostAndPort($server, $headers); + if (! empty($host)) { + $uri = $uri->withHost($host); + if (! empty($port)) { + $uri = $uri->withPort($port); + } + } + + // URI path + $path = self::marshalRequestPath($server); + + // Strip query string + $path = explode('?', $path, 2)[0]; + + // URI query + $query = ''; + if (isset($server['QUERY_STRING'])) { + $query = ltrim((string) $server['QUERY_STRING'], '?'); + } + + // URI fragment + $fragment = ''; + if (str_contains($path, '#')) { + [$path, $fragment] = explode('#', $path, 2); + } + + return $uri + ->withPath($path) + ->withFragment($fragment) + ->withQuery($query); + } + + /** + * Retrieve a header value from an array of headers using a case-insensitive lookup. + * + * @template T + * @param array> $headers Key/value header pairs + * @param T $default Default value to return if header not found + * @return string|T + */ + private static function getHeaderFromArray(string $name, array $headers, $default = null) + { + $header = strtolower($name); + $headers = array_change_key_case($headers, CASE_LOWER); + if (! array_key_exists($header, $headers)) { + return $default; + } + + if (is_string($headers[$header])) { + return $headers[$header]; + } + + return implode(', ', $headers[$header]); + } + + /** + * Marshal the host and port from the PHP environment. + * + * @param array> $headers + * @return array{string, int|null} Array of two items, host and port, + * in that order (can be passed to a list() operation). + */ + private static function marshalHostAndPort(array $server, array $headers): array + { + static $defaults = ['', null]; + + $host = self::getHeaderFromArray('host', $headers, false); + if ($host !== false) { + // Ignore obviously malformed host headers: + // - Whitespace is invalid within a hostname and break the URI representation within HTTP. + // non-printable characters other than SPACE and TAB are already rejected by HeaderSecurity. + // - A comma indicates that multiple host headers have been sent which is not legal + // and might be used in an attack where a load balancer sees a different host header + // than Diactoros. + if (! preg_match('/[\\t ,]/', $host)) { + return self::marshalHostAndPortFromHeader($host); + } + } + + if (! isset($server['SERVER_NAME'])) { + return $defaults; + } + + $host = (string) $server['SERVER_NAME']; + $port = isset($server['SERVER_PORT']) ? (int) $server['SERVER_PORT'] : null; + + if ( + ! isset($server['SERVER_ADDR']) + || ! preg_match('/^\[[0-9a-fA-F\:]+\]$/', $host) + ) { + return [$host, $port]; + } + + // Misinterpreted IPv6-Address + // Reported for Safari on Windows + return self::marshalIpv6HostAndPort($server, $port); + } + + /** + * @return array{string, int|null} Array of two items, host and port, + * in that order (can be passed to a list() operation). + */ + private static function marshalIpv6HostAndPort(array $server, ?int $port): array + { + $host = '[' . (string) $server['SERVER_ADDR'] . ']'; + $port = $port ?: 80; + $portSeparatorPos = strrpos($host, ':'); + + if (false === $portSeparatorPos) { + return [$host, $port]; + } + + if ($port . ']' === substr($host, $portSeparatorPos + 1)) { + // The last digit of the IPv6-Address has been taken as port + // Unset the port so the default port can be used + $port = null; + } + return [$host, $port]; + } + + /** + * Detect the path for the request + * + * Looks at a variety of criteria in order to attempt to autodetect the base + * request path, including: + * + * - IIS7 UrlRewrite environment + * - REQUEST_URI + * - ORIG_PATH_INFO + */ + private static function marshalRequestPath(array $server): string + { + // IIS7 with URL Rewrite: make sure we get the unencoded url + // (double slash problem). + $iisUrlRewritten = $server['IIS_WasUrlRewritten'] ?? null; + $unencodedUrl = $server['UNENCODED_URL'] ?? ''; + if ('1' === $iisUrlRewritten && is_string($unencodedUrl) && '' !== $unencodedUrl) { + return $unencodedUrl; + } + + $requestUri = $server['REQUEST_URI'] ?? null; + + if (is_string($requestUri)) { + return preg_replace('#^[^/:]+://[^/]+#', '', $requestUri); + } + + $origPathInfo = $server['ORIG_PATH_INFO'] ?? ''; + if (! is_string($origPathInfo) || '' === $origPathInfo) { + return '/'; + } + + return $origPathInfo; + } + + private static function marshalHttpsValue(mixed $https): bool + { + if (is_bool($https)) { + return $https; + } + + if (! is_string($https)) { + throw new Exception\InvalidArgumentException(sprintf( + 'SAPI HTTPS value MUST be a string or boolean; received %s', + gettype($https) + )); + } + + return 'on' === strtolower($https); + } + + /** + * @param string|list $host + * @return array Array of two items, host and port, in that order (can be + * passed to a list() operation). + */ + private static function marshalHostAndPortFromHeader($host): array + { + if (is_array($host)) { + $host = implode(', ', $host); + } + + $port = null; + + // works for regname, IPv4 & IPv6 + if (preg_match('|\:(\d+)$|', $host, $matches)) { + $host = substr($host, 0, -1 * (strlen($matches[1]) + 1)); + $port = (int) $matches[1]; + } + + return [$host, $port]; + } } diff --git a/test/UriFactoryTest.php b/test/UriFactoryTest.php new file mode 100644 index 00000000..786686e1 --- /dev/null +++ b/test/UriFactoryTest.php @@ -0,0 +1,256 @@ + '1', + 'UNENCODED_URL' => '/foo/bar', + ]; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame($server['UNENCODED_URL'], $uri->getPath()); + } + + public function testCreateFromSapiStripsSchemeHostAndPortInformationWhenPresent(): void + { + $server = [ + 'REQUEST_URI' => 'http://example.com:8000/foo/bar', + ]; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame('/foo/bar', $uri->getPath()); + } + + public function testCreateFromSapiUsesOrigPathInfoIfPresent(): void + { + $server = [ + 'ORIG_PATH_INFO' => '/foo/bar', + ]; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame('/foo/bar', $uri->getPath()); + } + + public function testCreateFromSapiFallsBackToRoot(): void + { + $server = []; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame('/', $uri->getPath()); + } + + public function testMarshalHostAndPortUsesHostHeaderWhenPresent(): void + { + $headers = ['Host' => ['example.com']]; + + $uri = UriFactory::createFromSapi([], $headers); + + $this->assertSame('example.com', $uri->getHost()); + $this->assertNull($uri->getPort()); + } + + public function testMarshalHostAndPortWillDetectPortInHostHeaderWhenPresent(): void + { + $headers = ['Host' => ['example.com:8000']]; + + $uri = UriFactory::createFromSapi([], $headers); + + $this->assertSame('example.com', $uri->getHost()); + $this->assertSame(8000, $uri->getPort()); + } + + public function testMarshalHostAndPortReturnsEmptyValuesIfNoHostHeaderAndNoServerName(): void + { + $uri = UriFactory::createFromSapi([], []); + + $this->assertSame('', $uri->getHost()); + $this->assertNull($uri->getPort()); + } + + public function testMarshalHostAndPortReturnsServerNameForHostWhenPresent(): void + { + $server = [ + 'SERVER_NAME' => 'example.com', + ]; + $headers = []; + + $uri = UriFactory::createFromSapi($server, $headers); + + $this->assertSame('example.com', $uri->getHost()); + $this->assertNull($uri->getPort()); + } + + public function testMarshalHostAndPortReturnsServerPortForPortWhenPresentWithServerName(): void + { + $server = [ + 'SERVER_NAME' => 'example.com', + 'SERVER_PORT' => 8000, + ]; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame('example.com', $uri->getHost()); + $this->assertSame(8000, $uri->getPort()); + } + + public function testMarshalHostAndPortReturnsServerNameForHostIfServerAddrPresentButHostIsNotIpv6Address(): void + { + $server = [ + 'SERVER_ADDR' => '127.0.0.1', + 'SERVER_NAME' => 'example.com', + ]; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame('example.com', $uri->getHost()); + } + + public function testMarshalHostAndPortReturnsServerAddrForHostIfPresentAndHostIsIpv6Address(): void + { + $server = [ + 'SERVER_ADDR' => 'FE80::0202:B3FF:FE1E:8329', + 'SERVER_NAME' => '[FE80::0202:B3FF:FE1E:8329]', + 'SERVER_PORT' => 8000, + ]; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame(strtolower('[FE80::0202:B3FF:FE1E:8329]'), $uri->getHost()); + $this->assertSame(8000, $uri->getPort()); + } + + public function testMarshalHostAndPortWillDetectPortInIpv6StyleHost(): void + { + $server = [ + 'SERVER_ADDR' => 'FE80::0202:B3FF:FE1E:8329', + 'SERVER_NAME' => '[FE80::0202:B3FF:FE1E:8329:80]', + ]; + + $uri = UriFactory::createFromSapi($server, []); + + $this->assertSame(strtolower('[FE80::0202:B3FF:FE1E:8329]'), $uri->getHost()); + $this->assertNull($uri->getPort()); + } + + /** @return non-empty-array */ + public function httpsParamProvider(): array + { + return [ + 'lowercase' => ['https'], + 'uppercase' => ['HTTPS'], + ]; + } + + /** + * @dataProvider httpsParamProvider + * @param non-empty-string $param + */ + public function testMarshalUriDetectsHttpsSchemeFromServerValue(string $param): void + { + $server = [ + $param => 'on', + ]; + $headers = ['Host' => ['example.com']]; + + $uri = UriFactory::createFromSapi($server, $headers); + + $this->assertInstanceOf(Uri::class, $uri); + $this->assertSame('https', $uri->getScheme()); + } + + /** @return iterable */ + public function httpsDisableParamProvider(): iterable + { + foreach ($this->httpsParamProvider() as $key => $data) { + $param = array_shift($data); + foreach (['lowercase-off', 'uppercase-off'] as $type) { + $key = sprintf('%s-%s', $key, $type); + $value = str_contains($type, 'lowercase') ? 'off' : 'OFF'; + yield $key => [$param, $value]; + } + } + } + + /** + * @dataProvider httpsDisableParamProvider + * @param non-empty-string $param + * @param 'off'|'OFF' $value + */ + public function testMarshalUriUsesHttpSchemeIfHttpsServerValueEqualsOff(string $param, string $value): void + { + $server = [ + $param => $value, + ]; + $headers = ['Host' => ['example.com']]; + + $uri = UriFactory::createFromSapi($server, $headers); + + $this->assertInstanceOf(Uri::class, $uri); + $this->assertSame('http', $uri->getScheme()); + } + + public function testMarshalUriStripsQueryStringFromRequestUri(): void + { + $server = [ + 'REQUEST_URI' => '/foo/bar?foo=bar', + ]; + $headers = [ + 'Host' => ['example.com'], + ]; + + $uri = UriFactory::createFromSapi($server, $headers); + + $this->assertInstanceOf(Uri::class, $uri); + $this->assertSame('/foo/bar', $uri->getPath()); + } + + public function testMarshalUriInjectsQueryStringFromServer(): void + { + $server = [ + 'REQUEST_URI' => '/foo/bar?foo=bar', + 'QUERY_STRING' => 'bar=baz', + ]; + $headers = [ + 'Host' => ['example.com'], + ]; + + $uri = UriFactory::createFromSapi($server, $headers); + + $this->assertInstanceOf(Uri::class, $uri); + $this->assertSame('bar=baz', $uri->getQuery()); + } + + public function testMarshalUriInjectsFragmentFromServer(): void + { + $server = [ + 'REQUEST_URI' => '/foo/bar#foo', + ]; + $headers = [ + 'Host' => ['example.com'], + ]; + + $uri = UriFactory::createFromSapi($server, $headers); + + $this->assertInstanceOf(Uri::class, $uri); + $this->assertSame('foo', $uri->getFragment()); + } +} From 779b9ecb66563e9139a7127d6099c13d065c520c Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 21 Nov 2022 22:11:56 -0500 Subject: [PATCH 2/2] Apply feedback from computers and humans Signed-off-by: Mark Story --- psalm-baseline.xml | 3 ++- src/UriFactory.php | 27 +++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 6fb6c6f4..6713fab6 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -215,8 +215,9 @@ $headers['cookie'] - + $headers + $server $iisUrlRewritten diff --git a/src/UriFactory.php b/src/UriFactory.php index 085a90a4..d0ce13af 100644 --- a/src/UriFactory.php +++ b/src/UriFactory.php @@ -14,6 +14,7 @@ use function implode; use function is_array; use function is_bool; +use function is_scalar; use function is_string; use function ltrim; use function preg_match; @@ -40,24 +41,21 @@ public function createUri(string $uri = ''): UriInterface /** * Create a Uri instance based on the headers and $_SERVER data. * + * @param array|int|float|string> $server SAPI parameters * @param array> $headers - * @param array $server SAPI parameters */ public static function createFromSapi(array $server, array $headers): Uri { $uri = new Uri(''); - // URI scheme - $https = false; + $isHttps = false; if (array_key_exists('HTTPS', $server)) { - $https = self::marshalHttpsValue($server['HTTPS']); + $isHttps = self::marshalHttpsValue($server['HTTPS']); } elseif (array_key_exists('https', $server)) { - $https = self::marshalHttpsValue($server['https']); + $isHttps = self::marshalHttpsValue($server['https']); } + $uri = $uri->withScheme($isHttps ? 'https' : 'http'); - $uri = $uri->withScheme($https ? 'https' : 'http'); - - // Set the host [$host, $port] = self::marshalHostAndPort($server, $headers); if (! empty($host)) { $uri = $uri->withHost($host); @@ -66,19 +64,16 @@ public static function createFromSapi(array $server, array $headers): Uri } } - // URI path $path = self::marshalRequestPath($server); // Strip query string $path = explode('?', $path, 2)[0]; - // URI query $query = ''; - if (isset($server['QUERY_STRING'])) { + if (isset($server['QUERY_STRING']) && is_scalar($server['QUERY_STRING'])) { $query = ltrim((string) $server['QUERY_STRING'], '?'); } - // URI fragment $fragment = ''; if (str_contains($path, '#')) { [$path, $fragment] = explode('#', $path, 2); @@ -122,6 +117,7 @@ private static function getHeaderFromArray(string $name, array $headers, $defaul */ private static function marshalHostAndPort(array $server, array $headers): array { + /** @var array{string, null} $defaults */ static $defaults = ['', null]; $host = self::getHeaderFromArray('host', $headers, false); @@ -192,12 +188,15 @@ private static function marshalRequestPath(array $server): string { // IIS7 with URL Rewrite: make sure we get the unencoded url // (double slash problem). + /** @var string|array|null $iisUrlRewritten */ $iisUrlRewritten = $server['IIS_WasUrlRewritten'] ?? null; - $unencodedUrl = $server['UNENCODED_URL'] ?? ''; + /** @var string|array $unencodedUrl */ + $unencodedUrl = $server['UNENCODED_URL'] ?? ''; if ('1' === $iisUrlRewritten && is_string($unencodedUrl) && '' !== $unencodedUrl) { return $unencodedUrl; } + /** @var string|array|null $requestUri */ $requestUri = $server['REQUEST_URI'] ?? null; if (is_string($requestUri)) { @@ -230,7 +229,7 @@ private static function marshalHttpsValue(mixed $https): bool /** * @param string|list $host - * @return array Array of two items, host and port, in that order (can be + * @return array{string, int|null} Array of two items, host and port, in that order (can be * passed to a list() operation). */ private static function marshalHostAndPortFromHeader($host): array