diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 5fd17f72..aecead52 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -406,6 +406,20 @@ + + + + + + + + + + + + + + diff --git a/src/Uri.php b/src/Uri.php index 0c35fdc6..4e8260c2 100644 --- a/src/Uri.php +++ b/src/Uri.php @@ -10,6 +10,7 @@ use function array_keys; use function explode; +use function filter_var; use function implode; use function ltrim; use function parse_url; @@ -19,11 +20,15 @@ use function rawurlencode; use function sprintf; use function str_contains; +use function str_ends_with; use function str_split; use function str_starts_with; use function strtolower; use function substr; +use const FILTER_FLAG_IPV6; +use const FILTER_VALIDATE_IP; + /** * Implementation of Psr\Http\UriInterface. * @@ -274,8 +279,10 @@ public function withHost(string $host): UriInterface return $this; } + $host = $this->filterHost($host); + $new = clone $this; - $new->host = strtolower($host); + $new->host = $host; return $new; } @@ -290,11 +297,8 @@ public function withPort(?int $port): UriInterface return $this; } - if ($port !== null && ($port < 1 || $port > 65535)) { - throw new Exception\InvalidArgumentException(sprintf( - 'Invalid port "%d" specified; must be a valid TCP/UDP port', - $port - )); + if (null !== $port) { + $port = $this->filterPort($port); } $new = clone $this; @@ -393,8 +397,8 @@ private function parseUri(string $uri): void $this->scheme = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : ''; $this->userInfo = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : ''; - $this->host = isset($parts['host']) ? strtolower($parts['host']) : ''; - $this->port = $parts['port'] ?? null; + $this->host = isset($parts['host']) ? $this->filterHost($parts['host']) : ''; + $this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null; $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; $this->query = isset($parts['query']) ? $this->filterQuery($parts['query']) : ''; $this->fragment = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : ''; @@ -503,6 +507,63 @@ private function filterUserInfoPart(string $part): string ); } + /** + * Valid host subcomponent can be IP-literal, dotted IPv4 or reg-name + */ + private function filterHost(string $host): string + { + if ($host === '') { + return $host; + } + $host = strtolower($host); + + // only IP-literal is allowed colon + if (str_contains($host, ':')) { + /** + * RFC3986 defines IP-literal in the host subcomponent as an IPv6 address enclosed in brackets. + * While implementations are somewhat lenient, particularly php's parse_url(), enclosing IPv6 + * into the brackets here ensures uri authority is always valid even if assembled manually + * outside of this implementation. This would prevent last IPv6 segment from being treated + * as a port number. + */ + $ipv6 = $host; + if (str_starts_with($ipv6, '[') && str_ends_with($ipv6, ']')) { + $ipv6 = substr($ipv6, 1, -1); + } + $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + if (false === $ipv6) { + throw new Exception\InvalidArgumentException($host . ' Host contains invalid IPv6 address'); + } + + return '[' . $ipv6 . ']'; + } + + /** + * @todo consult with interop tests for a stricter validation across implementations. + * + * Check for forbidden RFC3986 gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + */ + if (preg_match('~[:/?#\[\]@]~', $host)) { + throw new Exception\InvalidArgumentException('Host contains forbidden characters'); + } + + return $host; + } + + private function filterPort(int $port): int + { + if ($port < 1 || $port > 65535) { + throw new Exception\InvalidArgumentException( + sprintf( + 'Invalid port "%d" specified; must be a valid TCP/UDP port', + $port + ) + ); + } + + return $port; + } + /** * Filters the path of a URI to ensure it is properly encoded. */ diff --git a/test/UriTest.php b/test/UriTest.php index 04df4df0..ef909458 100644 --- a/test/UriTest.php +++ b/test/UriTest.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Laminas\Diactoros\Uri; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionObject; @@ -26,6 +27,74 @@ public function testConstructorSetsAllProperties(): void $this->assertSame('quz', $uri->getFragment()); } + public function testConstructorSetsAllPropertiesWithIPv6(): void + { + $uri = new Uri('https://user:pass@[fe80::200:5aee:feaa:20a2]:3001/foo?bar=baz#quz'); + $this->assertSame('https', $uri->getScheme()); + $this->assertSame('user:pass', $uri->getUserInfo()); + $this->assertSame('[fe80::200:5aee:feaa:20a2]', $uri->getHost()); + $this->assertSame(3001, $uri->getPort()); + $this->assertSame('user:pass@[fe80::200:5aee:feaa:20a2]:3001', $uri->getAuthority()); + $this->assertSame('/foo', $uri->getPath()); + $this->assertSame('bar=baz', $uri->getQuery()); + $this->assertSame('quz', $uri->getFragment()); + } + + public function testConstructorSetsAllPropertiesWithShorthandIPv6(): void + { + $uri = new Uri('https://user:pass@[::1]:3001/foo?bar=baz#quz'); + $this->assertSame('https', $uri->getScheme()); + $this->assertSame('user:pass', $uri->getUserInfo()); + $this->assertSame('[::1]', $uri->getHost()); + $this->assertSame(3001, $uri->getPort()); + $this->assertSame('user:pass@[::1]:3001', $uri->getAuthority()); + $this->assertSame('/foo', $uri->getPath()); + $this->assertSame('bar=baz', $uri->getQuery()); + $this->assertSame('quz', $uri->getFragment()); + } + + public function testConstructorSetsAllPropertiesWithMalformedBracketlessIPv6(): void + { + $uri = new Uri('https://user:pass@fe80::200:5aee:feaa:20a2:3001/foo?bar=baz#quz'); + $this->assertSame('https', $uri->getScheme()); + $this->assertSame('user:pass', $uri->getUserInfo()); + $this->assertSame('[fe80::200:5aee:feaa:20a2]', $uri->getHost()); + $this->assertSame(3001, $uri->getPort()); + $this->assertSame('user:pass@[fe80::200:5aee:feaa:20a2]:3001', $uri->getAuthority()); + $this->assertSame('/foo', $uri->getPath()); + $this->assertSame('bar=baz', $uri->getQuery()); + $this->assertSame('quz', $uri->getFragment()); + } + + /** @return iterable */ + public static function invalidUriProvider(): iterable + { + foreach (self::invalidSchemes() as $key => $scheme) { + yield 'Unsupported scheme ' . $key => ["{$scheme[0]}://user:pass@local.example.com:3001/foo?bar=baz#quz"]; + } + + foreach (self::invalidPorts() as $key => $port) { + yield 'Invalid port ' . $key => ["https://user:pass@local.example.com:${port[0]}/foo?bar=baz#quz"]; + } + + yield from [ + 'Malformed URI' => ["http://invalid:%20https://example.com"], + 'Colon in non-IPv6 host' => ["https://user:pass@local:example.com:3001/foo?bar=baz#quz"], + 'Wrong bracket in the IPv6' => ["https://user:pass@fe80[::200:5aee:feaa:20a2]:3001/foo?bar=baz#quz"], + // percent encoding is allowed in URI but not in web urls particularly with idn encoding for dns. + // no validation for correct percent encoding either + // 'Percent in the host' => ["https://user:pass@local%example.com:3001/foo?bar=baz#quz"], + 'Bracket in the host' => ["https://user:pass@[local.example.com]:3001/foo?bar=baz#quz"], + ]; + } + + #[DataProvider('invalidUriProvider')] + public function testConstructorWithInvalidUriRaisesAnException(string $invalidUri): void + { + $this->expectException(InvalidArgumentException::class); + new Uri($invalidUri); + } + public function testCanSerializeToString(): void { $url = 'https://user:pass@local.example.com:3001/foo?bar=baz#quz'; @@ -95,11 +164,11 @@ public static function userInfoProvider(): array } /** - * @dataProvider userInfoProvider * @param non-empty-string $user * @param non-empty-string $credential * @param non-empty-string $expected */ + #[DataProvider('userInfoProvider')] public function testWithUserInfoEncodesUsernameAndPassword(string $user, string $credential, string $expected): void { $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); @@ -126,6 +195,36 @@ public function testWithHostReturnsSameInstanceWithProvidedHostIsSameAsBefore(): $this->assertSame('https://user:pass@local.example.com:3001/foo?bar=baz#quz', (string) $new); } + public function testWithHostEnclosesIPv6WithBrackets(): void + { + $uri = new Uri(); + $new = $uri->withHost('fe80::200:5aee:feaa:20a2'); + self::assertSame('[fe80::200:5aee:feaa:20a2]', $new->getHost()); + } + + /** @return iterable */ + public static function invalidHosts(): iterable + { + // RFC3986 gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + $forbiddenDelimeters = [':', '/', '?', '#', '[', ']', '@']; + + foreach ($forbiddenDelimeters as $delimeter) { + yield "Forbidden delimeter {$delimeter}" => ["example{$delimeter}localhost"]; + } + + yield "Double bracket IPv6" => ['[[::1]]']; + } + + #[DataProvider('invalidHosts')] + public function testWithHostRaisesExceptionForInvalidHost(string $host): void + { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + + $this->expectException(InvalidArgumentException::class); + + $uri->withHost($host); + } + /** @return non-empty-array */ public static function validPorts(): array { @@ -136,9 +235,9 @@ public static function validPorts(): array } /** - * @dataProvider validPorts * @param null|positive-int|numeric-string $port */ + #[DataProvider('validPorts')] public function testWithPortReturnsNewInstanceWithProvidedPort($port): void { $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); @@ -170,9 +269,7 @@ public static function invalidPorts(): array ]; } - /** - * @dataProvider invalidPorts - */ + #[DataProvider('invalidPorts')] public function testWithPortRaisesExceptionForInvalidPorts(mixed $port): void { $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); @@ -211,9 +308,7 @@ public static function invalidPaths(): array ]; } - /** - * @dataProvider invalidPaths - */ + #[DataProvider('invalidPaths')] public function testWithPathRaisesExceptionForInvalidPaths(mixed $path): void { $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); @@ -242,9 +337,7 @@ public static function invalidQueryStrings(): array ]; } - /** - * @dataProvider invalidQueryStrings - */ + #[DataProvider('invalidQueryStrings')] public function testWithQueryRaisesExceptionForInvalidQueryStrings(mixed $query): void { $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); @@ -286,10 +379,10 @@ public static function authorityInfo(): array } /** - * @dataProvider authorityInfo * @param non-empty-string $url * @param non-empty-string $expected */ + #[DataProvider('authorityInfo')] public function testRetrievingAuthorityReturnsExpectedValues(string $url, string $expected): void { $uri = new Uri($url); @@ -362,9 +455,9 @@ public static function invalidSchemes(): array } /** - * @dataProvider invalidSchemes * @param non-empty-string $scheme */ + #[DataProvider('invalidSchemes')] public function testConstructWithUnsupportedSchemeRaisesAnException(string $scheme): void { $this->expectException(InvalidArgumentException::class); @@ -374,9 +467,9 @@ public function testConstructWithUnsupportedSchemeRaisesAnException(string $sche } /** - * @dataProvider invalidSchemes * @param non-empty-string $scheme */ + #[DataProvider('invalidSchemes')] public function testMutatingWithUnsupportedSchemeRaisesAnException(string $scheme): void { $uri = new Uri('http://example.com'); @@ -425,10 +518,10 @@ public static function standardSchemePortCombinations(): array } /** - * @dataProvider standardSchemePortCombinations * @param non-empty-string $scheme * @param positive-int $port */ + #[DataProvider('standardSchemePortCombinations')] public function testAuthorityOmitsPortForStandardSchemePortCombinations(string $scheme, int $port): void { $uri = (new Uri()) @@ -453,10 +546,10 @@ public static function mutations(): array } /** - * @dataProvider mutations * @param 'withScheme'|'withUserInfo'|'withHost'|'withPort'|'withPath'|'withQuery'|'withFragment' $method * @param non-empty-string|positive-int $value */ + #[DataProvider('mutations')] public function testMutationResetsUriStringPropertyInClone(string $method, $value): void { $uri = new Uri('http://example.com/path?query=string#fragment'); @@ -504,10 +597,10 @@ public static function queryStringsForEncoding(): array } /** - * @dataProvider queryStringsForEncoding * @param non-empty-string $query * @param non-empty-string $expected */ + #[DataProvider('queryStringsForEncoding')] public function testQueryIsProperlyEncoded(string $query, string $expected): void { $uri = (new Uri())->withQuery($query); @@ -515,10 +608,10 @@ public function testQueryIsProperlyEncoded(string $query, string $expected): voi } /** - * @dataProvider queryStringsForEncoding * @param non-empty-string $query * @param non-empty-string $expected */ + #[DataProvider('queryStringsForEncoding')] public function testQueryIsNotDoubleEncoded(string $query, string $expected): void { $uri = (new Uri())->withQuery($expected); @@ -553,10 +646,10 @@ public function testUtf8Uri(): void } /** - * @dataProvider utf8PathsDataProvider * @param non-empty-string $url * @param non-empty-string $result */ + #[DataProvider('utf8PathsDataProvider')] public function testUtf8Path(string $url, string $result): void { $uri = new Uri($url); @@ -576,10 +669,10 @@ public static function utf8PathsDataProvider(): array } /** - * @dataProvider utf8QueryStringsDataProvider * @param non-empty-string $url * @param non-empty-string $result */ + #[DataProvider('utf8QueryStringsDataProvider')] public function testUtf8Query(string $url, string $result): void { $uri = new Uri($url);