This commit is contained in:
Javier Casares 2026-08-24 13:41:08 +00:00
commit 476389b914
93 changed files with 1864 additions and 305 deletions

View file

@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 3.1.0 - 2026-08-24
### Added
- Add `Utils::redactUriForMessage()` and `Utils::redactUriStringForMessage()` for URI diagnostics
- Add support for PHP 8.6
### Changed
- Omit rejected header values and sensitive URI components from automatic exception messages
## 3.0.1 - 2026-08-24
### Fixed
- Prefix relative paths that begin with a colon segment with `./` instead of throwing
- Apply the `/.` prefix for authority-less `//` paths to percent-encoding normalizations as well
- Keep colon-leading first path segments when reading the paths of scheme-less non-native URIs
- Stop throwing when removing the default `file` host strands a `//` path, prefixing it with `/.`
## 3.0.0 - 2026-07-20
### Added

View file

@ -19,8 +19,8 @@ composer require guzzlehttp/psr7
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 3.0 | Latest | >=7.4,<8.6 |
| 2.13 | Maintenance | >=7.2.5,<8.6 |
| 3.1 | Latest | >=7.4,<8.7 |
| 2.13 | Maintenance | >=7.2.5,<8.7 |
| 1.9 | End of Life | >=5.4,<8.2 |
## Quick Start

View file

@ -78,7 +78,7 @@ trait MessageTrait
public function withHeader(string $name, $value): MessageInterface
{
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($value);
$value = $this->normalizeHeaderValue($name, $value);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
@ -97,7 +97,7 @@ trait MessageTrait
public function withAddedHeader(string $name, $value): MessageInterface
{
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($value);
$value = $this->normalizeHeaderValue($name, $value);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
@ -166,7 +166,7 @@ trait MessageTrait
$header = (string) $header;
$this->assertHeader($header);
$value = $this->normalizeHeaderValue($value);
$value = $this->normalizeHeaderValue($header, $value);
$normalized = Utils::asciiToLower($header);
if (isset($this->headerNames[$normalized])) {
$header = $this->headerNames[$normalized];
@ -183,17 +183,17 @@ trait MessageTrait
*
* @return string[]
*/
private function normalizeHeaderValue($value): array
private function normalizeHeaderValue(string $header, $value): array
{
if (is_array($value) && $value === []) {
throw new \InvalidArgumentException('Header value must be a non-empty array or string.');
}
if (!is_array($value)) {
return $this->trimAndValidateHeaderValues([$value]);
return $this->trimAndValidateHeaderValues($header, [$value]);
}
return $this->trimAndValidateHeaderValues($value);
return $this->trimAndValidateHeaderValues($header, $value);
}
/**
@ -210,9 +210,9 @@ trait MessageTrait
*
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*/
private function trimAndValidateHeaderValues(array $values): array
private function trimAndValidateHeaderValues(string $header, array $values): array
{
return array_map(function ($value): string {
return array_map(function ($value) use ($header): string {
if (!is_string($value)) {
throw new \InvalidArgumentException(sprintf(
'Header value must be a string or array of strings but %s provided.',
@ -221,7 +221,7 @@ trait MessageTrait
}
$trimmed = trim($value, " \t");
$this->assertValue($trimmed);
$this->assertValue($header, $trimmed);
return $trimmed;
}, array_values($values));
@ -254,7 +254,7 @@ trait MessageTrait
* obs-text = %x80-FF
* obs-fold = CRLF 1*( SP / HTAB )
*/
private function assertValue(string $value): void
private function assertValue(string $header, string $value): void
{
// The regular expression intentionally does not support the obs-fold
// production, because as per RFC 9112#5.2:
@ -269,7 +269,11 @@ trait MessageTrait
// obscure feature of HTTP/1.1 and thus not accepting folding is not
// likely to break any legitimate use case.
if (!Rfc9110::isFieldValue($value)) {
throw new \InvalidArgumentException(sprintf('Invalid header value: %s', DiagnosticValue::escape($value)));
$reason = strpbrk($value, "\r\n") !== false
? 'must not contain CR or LF characters'
: 'contains an invalid control character';
throw new \InvalidArgumentException(sprintf('Header "%s" %s.', DiagnosticValue::escape($header), $reason));
}
}
}

View file

@ -72,7 +72,7 @@ final class MultipartStream implements StreamInterface
$key = (string) $key;
self::validatePartHeaderName($key);
self::validatePartHeaderValue($value);
self::validatePartHeaderValue($key, $value);
$str .= "{$key}: {$value}\r\n";
}
@ -252,7 +252,7 @@ final class MultipartStream implements StreamInterface
throw new \InvalidArgumentException('Multipart part header value must be a string.');
}
self::validatePartHeaderValue($value);
self::validatePartHeaderValue($key, $value);
$normalized[$key] = $value;
}
@ -267,10 +267,14 @@ final class MultipartStream implements StreamInterface
}
}
private static function validatePartHeaderValue(string $value): void
private static function validatePartHeaderValue(string $name, string $value): void
{
if (!Rfc9110::isFieldValue($value)) {
throw new \InvalidArgumentException(sprintf('Invalid multipart part header value: %s', DiagnosticValue::escape($value)));
$reason = strpbrk($value, "\r\n") !== false
? 'must not contain CR or LF characters'
: 'contains an invalid control character';
throw new \InvalidArgumentException(sprintf('Multipart part header "%s" %s.', DiagnosticValue::escape($name), $reason));
}
}

View file

@ -150,7 +150,7 @@ class Request implements RequestInterface
$host .= ':'.$port;
}
$this->assertValue($host);
$this->assertValue('Host', $host);
return $host;
}

View file

@ -69,7 +69,7 @@ class Uri implements UriInterface, \JsonSerializable
if ($uri !== '') {
$parts = UriParser::parse($uri);
if ($parts === false) {
throw new MalformedUriException(\sprintf('Unable to parse URI: %s', DiagnosticValue::escape($uri)));
throw new MalformedUriException(\sprintf('Unable to parse URI: %s', Utils::redactUriStringForMessage($uri)));
}
try {
$this->applyParts($parts);
@ -411,7 +411,10 @@ class Uri implements UriInterface, \JsonSerializable
* components, including the leading slash the string form adds to a
* rootless path when an authority is present; for subclasses and other
* implementations the path is split from the string form per RFC 3986
* Appendix B, without validating or decoding any other component.
* Appendix B, without validating or decoding any other component. The
* scheme is only split off when the instance reports one, as a relative
* reference can begin with a segment containing a colon that the Appendix
* B expression would otherwise read as a scheme.
*
* @throws \RuntimeException If the path cannot be split from the string form.
*
@ -429,7 +432,10 @@ class Uri implements UriInterface, \JsonSerializable
return $uri->path;
}
$count = preg_match('%^(?:[^:/?#]+:)?(?://[^/?#]*)?([^?#]*)%', (string) $uri, $matches);
$pattern = $uri->getScheme() === ''
? '%^(?://[^/?#]*)?([^?#]*)%'
: '%^(?:[^:/?#]+:)?(?://[^/?#]*)?([^?#]*)%';
$count = preg_match($pattern, (string) $uri, $matches);
if ($count === false) {
throw new \RuntimeException('Unable to read the URI path: '.preg_last_error_msg());

View file

@ -86,7 +86,11 @@ final class UriNormalizer
* second format in the Uri class. See
* `GuzzleHttp\Psr7\Uri::composeComponents`.
*
* When removing the host leaves a URI without an authority whose path
* begins with `//`, the path is serialized with a `/.` prefix.
*
* Example: file://localhost/myfile file:///myfile
* Example: file://localhost//x → file:///.//x
*/
public const REMOVE_DEFAULT_HOST = 8;
@ -166,6 +170,13 @@ final class UriNormalizer
* uncommon in reality. So this potential normalization is implied in PSR-7
* as well.
*
* A path the URI cannot hold, such as a `//`-leading path without an
* authority or a relative-path reference whose first segment contains a
* colon, is prefixed with `/.` or `./` respectively instead of throwing, as
* `UriResolver::resolve()` does. The percent-encoding normalizations only
* do so where they rewrote the path. For example, decoding `a%41:` yields
* `./aA:`, since `aA:` would be an absolute URI with the scheme `aa`.
*
* @param UriInterface $uri The URI to normalize
* @param int $flags A bitmask of normalizations to apply, see constants
*
@ -188,6 +199,14 @@ final class UriNormalizer
}
if ($flags & self::REMOVE_DEFAULT_HOST && $uri->getScheme() === 'file' && $uri->getHost() === 'localhost') {
if ($uri->getUserInfo() === '' && $uri->getPort() === null) {
$path = Uri::rawPath($uri);
if (str_starts_with($path, '//')) {
// "/." keeps a "//" path unambiguous once the authority is gone
$uri = $uri->withPath('/.'.$path);
}
}
$uri = $uri->withHost('');
}
@ -260,8 +279,7 @@ final class UriNormalizer
$uri = self::withNormalizedUserInfo($uri, $regex, $callback);
$uri = self::withNormalizedHost($uri, $regex, $callback);
return $uri
->withPath(self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback))
->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback));
}
@ -284,12 +302,24 @@ final class UriNormalizer
$uri = self::withNormalizedUserInfo($uri, $regex, $callback);
$uri = self::withNormalizedHost($uri, $regex, $hostCallback);
return $uri
->withPath(self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback))
->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback));
}
/**
* Writes the given path only when it differs from the current one, guarded
* so the write cannot throw.
*/
private static function withGuardedPath(UriInterface $uri, string $path): UriInterface
{
if ($path === Uri::rawPath($uri)) {
return $uri;
}
return $uri->withPath(UriResolver::guardedPath($uri, $path));
}
/**
* @param callable(array): string $callback
*/

View file

@ -65,8 +65,8 @@ final class UriResolver
}
/**
* Returns the path, prefixed with "/." when it would otherwise start the
* URI's string form with an authority-like "//".
* Returns the path, prefixed with "/." or "./" when the URI could not
* otherwise hold it.
*
* A URI without an authority cannot hold a path beginning with "//" (RFC
* 3986 Section 3.3), but removeDotSegments() can produce one. The "/."
@ -76,28 +76,47 @@ final class UriResolver
* written, so the path cannot be mistaken for an authority and the prefix
* is not added.
*
* A relative-path reference cannot begin with a segment containing a colon
* (RFC 3986 Section 4.2), as it would be mistaken for a scheme name, but
* reference resolution and percent-encoding normalization can produce one.
* The "./" prefix the RFC prescribes resolves back to the same path.
*
* @see https://url.spec.whatwg.org/#url-serializing
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*
* @internal
*/
public static function guardedPath(UriInterface $uri, string $path): string
{
if (!str_starts_with($path, '//') || $uri->getAuthority() !== '') {
if ($uri->getAuthority() !== '') {
return $path;
}
if ($uri instanceof Uri && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https')) {
return $path;
if (str_starts_with($path, '//')) {
if ($uri instanceof Uri && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https')) {
return $path;
}
return '/.'.$path;
}
return '/.'.$path;
if ($uri->getScheme() === '' && str_contains(explode('/', $path, 2)[0], ':')) {
return './'.$path;
}
return $path;
}
/**
* Converts the relative URI into a new URI that is resolved against the
* base URI.
*
* When the resolved path is a relative-path reference whose first segment
* contains a colon, which would be mistaken for a scheme name (RFC 3986
* Section 4.2), it is prefixed with `./`, e.g. `./a:b`.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function resolve(UriInterface $base, UriInterface $rel): UriInterface
{

View file

@ -583,6 +583,56 @@ final class Utils
return \str_replace(\substr($authority, 0, $atPosition).'@', '***@', $subject);
}
/**
* Formats a URI for automatic diagnostics.
*
* The whole userinfo component is replaced by "***", and the query and
* fragment are removed. The scheme, host, port, and path are preserved.
* The returned string is diagnostic-escaped and the formatter never
* throws.
*/
public static function redactUriForMessage(
#[\SensitiveParameter]
UriInterface $uri
): string {
try {
$raw = (string) $uri;
} catch (\Throwable $e) {
return '[unavailable URI]';
}
try {
if ($uri->getUserInfo() !== '') {
$uri = $uri->withUserInfo('***');
}
return DiagnosticValue::escape((string) $uri->withQuery('')->withFragment(''));
} catch (\Throwable $e) {
return self::redactUriStringForMessage($raw);
}
}
/**
* Formats a raw URI for automatic diagnostics, including malformed input.
*
* The whole userinfo component is replaced by "***", and the query and
* fragment are removed. The scheme, host, port, and path are preserved
* where their boundaries can be determined safely. The returned string is
* diagnostic-escaped and the formatter never throws.
*/
public static function redactUriStringForMessage(
#[\SensitiveParameter]
string $uri
): string {
try {
$uri = self::redactUserInfoInString($uri, $uri);
return DiagnosticValue::escape(\substr($uri, 0, \strcspn($uri, '?#')));
} catch (\Throwable $e) {
return '[unavailable URI]';
}
}
/**
* Create a new stream based on the input type.
*