diff --git a/components/Components/Authority.php b/components/Components/Authority.php index f333fa83..f81e859c 100644 --- a/components/Components/Authority.php +++ b/components/Components/Authority.php @@ -22,7 +22,6 @@ use League\Uri\Uri; use League\Uri\UriString; use Psr\Http\Message\UriInterface as Psr7UriInterface; -use SensitiveParameter; use Stringable; final class Authority extends Component implements AuthorityInterface @@ -34,7 +33,7 @@ final class Authority extends Component implements AuthorityInterface public function __construct( Stringable|string|null $host, Stringable|string|int|null $port = null, - #[SensitiveParameter] Stringable|string|null $userInfo = null + Stringable|string|null $userInfo = null ) { $this->host = !$host instanceof HostInterface ? Host::new($host) : $host; $this->port = !$port instanceof PortInterface ? Port::new($port) : $port; @@ -47,7 +46,7 @@ public function __construct( /** * @throws SyntaxError If the component contains invalid HostInterface part. */ - public static function new(#[SensitiveParameter] Stringable|string|null $value = null): self + public static function new(Stringable|string|null $value = null): self { $components = UriString::parseAuthority(self::filterComponent($value)); @@ -64,7 +63,7 @@ public static function new(#[SensitiveParameter] Stringable|string|null $value = /** * Create a new instance from a URI object. */ - public static function fromUri(#[SensitiveParameter] Stringable|string $uri): self + public static function fromUri(Stringable|string $uri): self { $uri = self::filterUri($uri); @@ -87,7 +86,7 @@ public static function fromUri(#[SensitiveParameter] Stringable|string $uri): se * port? : ?int * } $components */ - public static function fromComponents(#[SensitiveParameter] array $components): self + public static function fromComponents(array $components): self { $components += ['user' => null, 'pass' => null, 'host' => null, 'port' => null]; @@ -104,7 +103,7 @@ public function value(): ?string } private static function getAuthorityValue( - #[SensitiveParameter] UserInfoInterface $userInfo, + UserInfoInterface $userInfo, HostInterface $host, PortInterface $port ): ?string { @@ -180,7 +179,7 @@ public function withPort(Stringable|string|int|null $port): AuthorityInterface }; } - public function withUserInfo(Stringable|string|null $user, #[SensitiveParameter] Stringable|string|null $password = null): AuthorityInterface + public function withUserInfo(Stringable|string|null $user, Stringable|string|null $password = null): AuthorityInterface { $userInfo = new UserInfo($user, $password); @@ -200,7 +199,7 @@ public function withUserInfo(Stringable|string|null $user, #[SensitiveParameter] * * Create a new instance from a URI object. */ - public static function createFromUri(#[SensitiveParameter] UriInterface|Psr7UriInterface $uri): self + public static function createFromUri(UriInterface|Psr7UriInterface $uri): self { return self::fromUri($uri); } @@ -215,7 +214,7 @@ public static function createFromUri(#[SensitiveParameter] UriInterface|Psr7UriI * * Returns a new instance from a string or a stringable object. */ - public static function createFromString(#[SensitiveParameter] Stringable|string $authority): self + public static function createFromString(Stringable|string $authority): self { return self::new($authority); } @@ -255,7 +254,7 @@ public static function createFromNull(): self * port? : ?int * } $components */ - public static function createFromComponents(#[SensitiveParameter] array $components): self + public static function createFromComponents(array $components): self { return self::fromComponents($components); } diff --git a/components/Components/UserInfo.php b/components/Components/UserInfo.php index 7ee758dd..67f398c4 100644 --- a/components/Components/UserInfo.php +++ b/components/Components/UserInfo.php @@ -21,7 +21,6 @@ use League\Uri\Exceptions\SyntaxError; use League\Uri\Uri; use Psr\Http\Message\UriInterface as Psr7UriInterface; -use SensitiveParameter; use Stringable; use function explode; @@ -36,7 +35,6 @@ final class UserInfo extends Component implements UserInfoInterface */ public function __construct( Stringable|string|null $username, - #[SensitiveParameter] Stringable|string|null $password = null ) { $this->username = $this->validateComponent($username); @@ -51,7 +49,7 @@ public function __construct( /** * Create a new instance from a URI object. */ - public static function fromUri(#[SensitiveParameter] Stringable|string $uri): self + public static function fromUri(Stringable|string $uri): self { $uri = self::filterUri($uri); @@ -64,7 +62,7 @@ public static function fromUri(#[SensitiveParameter] Stringable|string $uri): se /** * Create a new instance from an Authority object. */ - public static function fromAuthority(#[SensitiveParameter] Stringable|string|null $authority): self + public static function fromAuthority(Stringable|string|null $authority): self { return match (true) { $authority instanceof AuthorityInterface => self::new($authority->getUserInfo()), @@ -80,7 +78,7 @@ public static function fromAuthority(#[SensitiveParameter] Stringable|string|nul * * @param array{user? : ?string, pass? : ?string} $components */ - public static function fromComponents(#[SensitiveParameter] array $components): self + public static function fromComponents(array $components): self { $components += ['user' => null, 'pass' => null]; @@ -93,7 +91,7 @@ public static function fromComponents(#[SensitiveParameter] array $components): /** * Creates a new instance from an encoded string. */ - public static function new(#[SensitiveParameter] Stringable|string|null $value = null): self + public static function new(Stringable|string|null $value = null): self { if ($value instanceof UriComponentInterface) { $value = $value->value(); @@ -164,7 +162,7 @@ public function withUser(Stringable|string|null $username): self }; } - public function withPass(#[SensitiveParameter] Stringable|string|null $password): self + public function withPass(Stringable|string|null $password): self { $password = $this->validateComponent($password); @@ -185,7 +183,7 @@ public function withPass(#[SensitiveParameter] Stringable|string|null $password) * * Create a new instance from a URI object. */ - public static function createFromUri(#[SensitiveParameter] Psr7UriInterface|UriInterface $uri): self + public static function createFromUri(Psr7UriInterface|UriInterface $uri): self { return self::fromUri($uri); } @@ -200,7 +198,7 @@ public static function createFromUri(#[SensitiveParameter] Psr7UriInterface|UriI * * Create a new instance from an Authority object. */ - public static function createFromAuthority(#[SensitiveParameter] AuthorityInterface|Stringable|string $authority): self + public static function createFromAuthority(AuthorityInterface|Stringable|string $authority): self { return self::fromAuthority($authority); } @@ -215,7 +213,7 @@ public static function createFromAuthority(#[SensitiveParameter] AuthorityInterf * * Creates a new instance from an encoded string. */ - public static function createFromString(#[SensitiveParameter] Stringable|string $userInfo): self + public static function createFromString(Stringable|string $userInfo): self { return self::new($userInfo); } diff --git a/docs/uri/7.0/base-uri.md b/docs/uri/7.0/base-uri.md index 08a740b6..42cf1da3 100644 --- a/docs/uri/7.0/base-uri.md +++ b/docs/uri/7.0/base-uri.md @@ -30,6 +30,17 @@ echo $baseUri; // display 'http://www.example.com' The instance also implements PHP's `Stringable` and `JsonSerializable` interface. +In addition to all the non-destructive rules from RFC3968, during instantiation, the class +will convert the host if possible: + +- to its IPv4 decimal representation +- to its compressed IPv6 representation (since version **7.5**) + +```php +BaseUri::from('https://0:443/')->getUriString(); // returns 'https://0.0.0.0/ +BaseUri::from('FtP://[1050:0000:0000:0000:0005:0000:300c:326b]/path')->getUriString(); // returns 'ftp://[1050::5:0:300c:326b]/path +``` + ## URI resolution The `BaseUri::resolve` resolves a URI as a browser would for a relative URI while diff --git a/interfaces/Encoder.php b/interfaces/Encoder.php index 4324e03c..3b205669 100644 --- a/interfaces/Encoder.php +++ b/interfaces/Encoder.php @@ -16,7 +16,6 @@ use Closure; use League\Uri\Contracts\UriComponentInterface; use League\Uri\Exceptions\SyntaxError; -use SensitiveParameter; use Stringable; use function preg_match; @@ -58,7 +57,7 @@ public static function encodeUser(Stringable|string|null $component): ?string * * Generic delimiters ":" MUST NOT be encoded */ - public static function encodePassword(#[SensitiveParameter] Stringable|string|null $component): ?string + public static function encodePassword(Stringable|string|null $component): ?string { static $pattern = '/[^'.self::REGEXP_PART_UNRESERVED.self::REGEXP_PART_SUBDELIM.':]+|'.self::REGEXP_PART_ENCODED.'/'; diff --git a/uri/BaseUri.php b/uri/BaseUri.php index 5de77a93..906915f4 100644 --- a/uri/BaseUri.php +++ b/uri/BaseUri.php @@ -19,10 +19,12 @@ use League\Uri\Exceptions\MissingFeature; use League\Uri\Idna\Converter; use League\Uri\IPv4\Converter as IPv4Converter; +use League\Uri\IPv6\Converter as IPv6Converter; use Psr\Http\Message\UriFactoryInterface; use Psr\Http\Message\UriInterface as Psr7UriInterface; use Stringable; +use function array_map; use function array_pop; use function array_reduce; use function count; @@ -48,6 +50,8 @@ class BaseUri implements Stringable, JsonSerializable, UriAccess /** @var array */ final protected const DOT_SEGMENTS = ['.' => 1, '..' => 1]; + final protected const COMPONENT_MASK = '*****'; + protected readonly Psr7UriInterface|UriInterface|null $origin; protected readonly ?string $nullValue; @@ -269,6 +273,41 @@ public function hasIdn(): bool return Converter::isIdn($this->uri->getHost()); } + /** + * Redact the password component and any query pairs whose key is submitted. + * + * All redacted values will be replaced by the 5-star mask. + * The return value MAY not be a valid URI + * + * @param string ...$names + */ + public function redactSensitiveParameters(string ...$names): string + { + $components = UriString::parse($this); + if ($components['pass'] !== null) { + $components['pass'] = self::COMPONENT_MASK; + } + + $currentQuery = $components['query']; + if ([] === $names || null === $currentQuery || !str_contains($currentQuery, '=')) { + return UriString::build($components); + } + + $names = array_map(Encoder::decodeAll(...), $names); + $pairs = []; + foreach (explode('&', $currentQuery) as $part) { + [$key, ] = explode('=', $part, 2) + [1 => null]; + $pairs[] = match (in_array(Encoder::decodeAll($key), $names, true)) { + true => $key.'='.self::COMPONENT_MASK, + false => $part, + }; + } + + $components['query'] = implode('&', $pairs); + + return UriString::build($components); + } + /** * Resolves a URI against a base URI using RFC3986 rules. * @@ -556,6 +595,10 @@ final protected static function formatHost(Psr7UriInterface|UriInterface $uri): $converted = null; } + if (false === filter_var($converted, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $converted = IPv6Converter::compress($host); + } + return match (true) { null !== $converted => $uri->withHost($converted), '' === $host, diff --git a/uri/BaseUriTest.php b/uri/BaseUriTest.php index f5b9ebb3..c5e527e7 100644 --- a/uri/BaseUriTest.php +++ b/uri/BaseUriTest.php @@ -360,6 +360,10 @@ public static function getOriginProvider(): array 'uri' => Uri::new('https://0:443/'), 'expectedOrigin' => 'https://0.0.0.0', ], + 'compressed ipv6' => [ + 'uri' => 'https://[1050:0000:0000:0000:0005:0000:300c:326b]:443/', + 'expectedOrigin' => 'https://[1050::5:0:300c:326b]', + ], ]; } @@ -624,4 +628,80 @@ public static function opaqueUriProvider(): iterable 'uri' => 'data:', ]; } + + #[Test] + #[DataProvider('redactUriUserInfoProvider')] + public function it_can_redact_user_info_password_component(string $uri, string $expected):void + { + self::assertSame($expected, BaseUri::from($uri)->redactSensitiveParameters()); + } + + public static function redactUriUserInfoProvider(): iterable + { + yield 'empty URI' => [ + 'uri' => '', + 'expected' => '', + ]; + + yield 'URI with user only' => [ + 'uri' => 'https://user@example.com', + 'expected' => 'https://user@example.com', + ]; + + yield 'URI with complete user info only' => [ + 'uri' => 'https://user:pass@example.com', + 'expected' => 'https://user:*****@example.com', + ]; + + yield 'URI without user info only' => [ + 'uri' => 'https://example.com', + 'expected' => 'https://example.com', + ]; + } + + #[Test] + #[DataProvider('redactUrQueryPairsProvider')] + public function it_can_redact_query_parameters(string $uri, array $pairs, string $expected):void + { + self::assertSame($expected, BaseUri::from($uri)->redactSensitiveParameters(...$pairs)); + } + + public static function redactUrQueryPairsProvider(): iterable + { + yield 'empty URI' => [ + 'uri' => '', + 'pairs' => ['toto', 'foobar'], + 'expected' => '', + ]; + + yield 'URI with complete user info only' => [ + 'uri' => 'https://user:pass@example.com', + 'pairs' => ['toto', 'foobar'], + 'expected' => 'https://user:*****@example.com', + ]; + + yield 'URI with query pair not matching' => [ + 'uri' => 'https://example.com?key=value', + 'pairs' => ['toto', 'foobar'], + 'expected' => 'https://example.com?key=value', + ]; + + yield 'URI with query with one query pair matching' => [ + 'uri' => 'https://example.com?key=value', + 'pairs' => ['key', 'foobar'], + 'expected' => 'https://example.com?key=*****', + ]; + + yield 'URI with query containing array like paraneters (1)' => [ + 'uri' => 'https://example.com?key[]=value&key[]=value&key=value', + 'pairs' => ['key', 'foobar'], + 'expected' => 'https://example.com?key%5B%5D=value&key%5B%5D=value&key=*****', + ]; + + yield 'URI with query containing array like paraneters (2)' => [ + 'uri' => 'https://example.com?key[]=value&key[]=value&key=value', + 'pairs' => ['key[]', 'foobar'], + 'expected' => 'https://example.com?key%5B%5D=*****&key%5B%5D=*****&key=value', + ]; + } } diff --git a/uri/CHANGELOG.md b/uri/CHANGELOG.md index f9ae9ebd..a32c7e1f 100644 --- a/uri/CHANGELOG.md +++ b/uri/CHANGELOG.md @@ -9,11 +9,13 @@ All Notable changes to `League\Uri` will be documented in this file - `Uri::getUsername` returns the encoded user component of the URI. - `Uri::getPassword` returns the encoded password component of the URI. - `BaseUri::isOpaque` tells whether a URI is opaque. +- `BaseUri::redactSensitiveParameters` to remove sensitive parameters from the URI. ### Fixed - Adding `SensitiveParameter` attribute in the `Uri` and the `BaseUri` class. - Improve PSR-7 `Http` class implementation. +- `BaseUri::from` will compress the IPv6 host to its compressed form if possible. ### Deprecated diff --git a/uri/Uri.php b/uri/Uri.php index 5be385f9..990363cf 100644 --- a/uri/Uri.php +++ b/uri/Uri.php @@ -23,7 +23,6 @@ use League\Uri\Idna\Converter as IdnConverter; use League\Uri\UriTemplate\TemplateCanNotBeExpanded; use Psr\Http\Message\UriInterface as Psr7UriInterface; -use SensitiveParameter; use Stringable; use function array_filter; @@ -212,7 +211,7 @@ final class Uri implements UriInterface private function __construct( ?string $scheme, ?string $user, - #[SensitiveParameter] ?string $pass, + ?string $pass, ?string $host, ?int $port, string $path, @@ -272,7 +271,7 @@ private function formatScheme(?string $scheme): ?string */ private function formatUserInfo( ?string $user, - #[SensitiveParameter] ?string $password + ?string $password ): ?string { return match (null) { $password => $user, @@ -381,7 +380,7 @@ private function formatPort(?int $port = null): ?int /** * Create a new instance from a string. */ - public static function new(#[SensitiveParameter] Stringable|string $uri = ''): self + public static function new(Stringable|string $uri = ''): self { $components = match (true) { $uri instanceof UriInterface => $uri->toComponents(), @@ -406,8 +405,8 @@ public static function new(#[SensitiveParameter] Stringable|string $uri = ''): s * The returned URI must be absolute. */ public static function fromBaseUri( - #[SensitiveParameter] Stringable|string $uri, - #[SensitiveParameter] Stringable|string|null $baseUri = null + Stringable|string $uri, + Stringable|string|null $baseUri = null ): self { $uri = self::new($uri); $baseUri = BaseUri::from($baseUri ?? $uri); @@ -442,7 +441,7 @@ public static function fromTemplate(UriTemplate|Stringable|string $template, ite * * @param InputComponentMap $components a hash representation of the URI similar to PHP parse_url function result */ - public static function fromComponents(#[SensitiveParameter] array $components = []): self + public static function fromComponents(array $components = []): self { $components += [ 'scheme' => null, 'user' => null, 'pass' => null, 'host' => null, @@ -604,7 +603,7 @@ public static function fromRfc8089(Stringable|string $uri): UriInterface /** * Create a new instance from the environment. */ - public static function fromServer(#[SensitiveParameter] array $server): self + public static function fromServer(array $server): self { $components = ['scheme' => self::fetchScheme($server)]; [$components['user'], $components['pass']] = self::fetchUserInfo($server); @@ -632,7 +631,7 @@ private static function fetchScheme(array $server): string * * @return non-empty-array{0: ?string, 1: ?string} */ - private static function fetchUserInfo(#[SensitiveParameter] array $server): array + private static function fetchUserInfo(array $server): array { $server += ['PHP_AUTH_USER' => null, 'PHP_AUTH_PW' => null, 'HTTP_AUTHORIZATION' => '']; $user = $server['PHP_AUTH_USER']; @@ -1086,7 +1085,7 @@ public function withScheme(Stringable|string|null $scheme): UriInterface * * @throws SyntaxError if the submitted data cannot be converted to string */ - private function filterString(#[SensitiveParameter] Stringable|string|null $str): ?string + private function filterString(Stringable|string|null $str): ?string { $str = match (true) { $str instanceof UriComponentInterface => $str->value(), @@ -1103,7 +1102,7 @@ private function filterString(#[SensitiveParameter] Stringable|string|null $str) public function withUserInfo( Stringable|string|null $user, - #[SensitiveParameter] Stringable|string|null $password = null + Stringable|string|null $password = null ): UriInterface { $userInfo = ('' !== $user) ? $this->formatUserInfo( Encoder::encodeUser($this->filterString($user)),