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);