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

@ -3,6 +3,32 @@
Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version.
## 8.1.0 - Upcoming
### Added
- Add PHP 8.6+ stream TLS session sharing, while persistent sharing remains cURL-only
- Add support for PHP 8.6
- Add in-transfer resends of seekable streamed uploads when PHP exposes `CURLOPT_SEEKFUNCTION`
### Changed
- Adjusted `guzzlehttp/promises` version constraint to `^3.0.2`
- Adjusted `guzzlehttp/psr7` version constraint to `^3.1`
- Classify stream handler transport failures using PHP 8.6+ structured stream error codes
- Hide URI credentials, queries, and fragments in automatic exception messages
- Match `no_proxy` rules against IPv4 hosts written in the shorthand a transport reads as an address
- Hold the cURL easy handle out of the reuse pool until a silent retry has been dispatched
- Treat a deferred resolved with a pending retry promise as progress when waiting on cURL transfers
## 8.0.3 - 2026-08-24
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^3.0.1`
## 8.0.2 - 2026-08-05
### Changed

View file

@ -30,8 +30,8 @@ composer require guzzlehttp/guzzle
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 8.0 | Latest | >=7.4,<8.6 |
| 7.15 | Maintenance | >=7.2.5,<8.6 |
| 8.1 | Latest | >=7.4,<8.7 |
| 7.15 | Maintenance | >=7.2.5,<8.7 |
| 6.5 | End of Life | >=5.5,<8.0 |
## Quick Start

View file

@ -199,8 +199,10 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
throw new InvalidArgumentException('handler must be a callable');
} elseif ($handlerOptions !== []) {
throw new InvalidArgumentException('The "max_host_connections" and "max_total_connections" client options require Guzzle to create the default handler. Configure the options on the CurlMultiHandler constructor for numeric enforcement, or on the StreamHandler constructor to reject enabled response streaming, when providing a custom handler.');
} elseif (\in_array($transportSharingMode, [TransportSharing::HANDLER_REQUIRE, TransportSharing::PERSISTENT_REQUIRE], true)) {
throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom cURL handler.');
} elseif ($transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler, CurlMultiHandler, or StreamHandler when providing a custom handler.');
} elseif ($transportSharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom handler.');
}
$factory = new HttpFactory();

View file

@ -60,14 +60,14 @@ class RequestException extends TransferException implements RequestExceptionInte
$label = 'Unsuccessful request';
}
$uri = \GuzzleHttp\Psr7\Utils::redactUserInfo($request->getUri());
$uri = \GuzzleHttp\Psr7\Utils::redactUriForMessage($request->getUri());
// Client error: `GET /` resulted in a `404 Not Found` response: <html> ... (truncated)
$message = \sprintf(
'%s: `%s %s` resulted in a `%s %s` response',
$label,
DiagnosticValue::escape($request->getMethod()),
DiagnosticValue::escape($uri->__toString()),
$uri,
$response->getStatusCode(),
DiagnosticValue::escape($response->getReasonPhrase())
);

View file

@ -110,6 +110,26 @@ final class CurlFactory implements CurlFactoryInterface
*/
private const CURL_READFUNC_ABORT = 0x10000000;
/**
* libcurl's CURL_SEEKFUNC_OK value.
*/
private const CURL_SEEKFUNC_OK = 0;
/**
* libcurl's CURL_SEEKFUNC_FAIL value.
*/
private const CURL_SEEKFUNC_FAIL = 1;
/**
* libcurl's CURL_SEEKFUNC_CANTSEEK value.
*/
private const CURL_SEEKFUNC_CANTSEEK = 2;
/**
* Maximum number of times libcurl may replay a streamed request body.
*/
private const CURL_SEEK_MAX_REPLAYS = 3;
/**
* libcurl's CURLE_SEND_FAIL_REWIND value.
*/
@ -372,6 +392,7 @@ final class CurlFactory implements CurlFactoryInterface
* @param array<int|string, mixed> $conf
*/
private function applyCurlOptions(
#[\SensitiveParameter]
$handle,
#[\SensitiveParameter]
array $conf
@ -712,6 +733,7 @@ final class CurlFactory implements CurlFactoryInterface
self::addConflictingCurlOption($options, 'CURLOPT_UPLOAD', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_POSTFIELDS', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_READFUNCTION', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_SEEKFUNCTION', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_READDATA', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_INFILE', 'the request body');
self::addConflictingCurlOption($options, 'CURLOPT_INFILESIZE', 'the request body');
@ -952,8 +974,10 @@ final class CurlFactory implements CurlFactoryInterface
/**
* @param resource|\CurlHandle $handle
*/
private function discardHandle($handle): void
{
private function discardHandle(
#[\SensitiveParameter]
$handle
): void {
$failure = null;
try {
@ -980,8 +1004,10 @@ final class CurlFactory implements CurlFactoryInterface
/**
* @param resource|\CurlHandle $handle
*/
private function clearEasyHandleCallbacks($handle): void
{
private function clearEasyHandleCallbacks(
#[\SensitiveParameter]
$handle
): void {
curl_setopt($handle, \CURLOPT_HEADERFUNCTION, null);
curl_setopt($handle, \CURLOPT_READFUNCTION, null);
curl_setopt($handle, \CURLOPT_WRITEFUNCTION, null);
@ -991,6 +1017,10 @@ final class CurlFactory implements CurlFactoryInterface
curl_setopt($handle, (int) \constant('CURLOPT_PREREQFUNCTION'), null);
}
if (\defined('CURLOPT_SEEKFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_SEEKFUNCTION'), null);
}
if (\defined('CURLOPT_XFERINFOFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_XFERINFOFUNCTION'), null);
}
@ -1136,22 +1166,40 @@ final class CurlFactory implements CurlFactoryInterface
): PromiseInterface {
// Get error information and release the handle to the factory.
$ctx = self::createErrorContext($easy);
if (self::shouldRetryFailedRewind($easy)) {
// Release after dispatching the retry so the replacement
// transfer cannot reuse this native handle ID.
try {
if ($onStats !== null && $stats !== null) {
$onStats($stats);
}
return self::retryFailedRewind($handler, $easy, $ctx);
} finally {
$factory->release($easy);
}
}
$factory->release($easy);
if ($onStats !== null && $stats !== null) {
$onStats($stats);
}
if (self::shouldRetryFailedRewind($easy)) {
return self::retryFailedRewind($handler, $easy, $ctx);
}
if (self::isChallengeRewindFailure($easy)) {
$ctx['error'] = 'The server issued an authentication challenge '
.'after the request body had already been sent, and the body '
.'could not be rewound to resend it. The request was not '
.'retried because a retry replays the same challenge. See '
.'https://bugs.php.net/bug.php?id=47204 for more information.';
if (self::isResponseRewindFailure($easy)) {
$status = $easy->response !== null ? $easy->response->getStatusCode() : 0;
$ctx['error'] = $status === 401 || $status === 407
? 'The server issued an authentication challenge '
.'after the request body had already been sent, and the body '
.'could not be rewound to resend it. The request was not '
.'retried because a retry replays the same challenge. See '
.'https://bugs.php.net/bug.php?id=47204 for more information.'
: 'The server responded after the request body had already '
.'been sent, and the body could not be rewound for '
.'libcurl\'s automatic retry. The request was not retried '
.'because a retry replays the same response. See '
.'https://bugs.php.net/bug.php?id=47204 for more information.';
}
return self::createRejection($easy, $ctx);
@ -1167,6 +1215,8 @@ final class CurlFactory implements CurlFactoryInterface
private static function hasLocalFailure(EasyHandle $easy): bool
{
return $easy->bodyReadTimeoutException !== null
|| $easy->bodyRewindTimeoutException !== null
|| $easy->bodyRewindException !== null
|| $easy->bodyReadException !== null
|| $easy->responseHeaderException !== null
|| $easy->sinkWriteTimeoutException !== null
@ -1181,10 +1231,10 @@ final class CurlFactory implements CurlFactoryInterface
return false;
}
if (self::isChallengeRewindFailure($easy)) {
// Re-issuing the identical request replays the same challenge and
if (self::isResponseRewindFailure($easy)) {
// Re-issuing the identical request replays the same response and
// the same in-transfer rewind, so retrying is futile and the
// challenge response is surfaced instead.
// received response is surfaced instead.
return false;
}
@ -1192,9 +1242,10 @@ final class CurlFactory implements CurlFactoryInterface
//
// - errno === CURLE_SEND_FAIL_REWIND (65): libcurl needed to rewind an
// already-partially-sent upload to resend it on a reused connection
// that died before any response arrived, but could not, because PHP
// registers no seek callback for a streamed request body. See
// https://bugs.php.net/bug.php?id=47204.
// that died before any response arrived, but could not. PHP builds
// without CURLOPT_SEEKFUNCTION register no seek callback for a
// streamed request body (https://bugs.php.net/bug.php?id=47204);
// builds with it can still report 65 when the body is not seekable.
//
// - errno === 0: libcurl reported success yet no usable response
// reached us. This is the legacy curl_multi silent-failure variant of
@ -1207,13 +1258,15 @@ final class CurlFactory implements CurlFactoryInterface
/**
* Whether the transfer failed because libcurl could not rewind the
* request body to resend it in reply to a challenge response, such as a
* 401 or 407 during multi-pass authentication. libcurl's only other
* rewind triggers are followed redirects, which the built-in handlers
* never enable, and reused connections that died, which cannot have
* produced a response.
* request body to resend it in reply to a response, such as a 401 or
* 407 during multi-pass authentication, or a 417 that makes libcurl
* retry an Expect: 100-continue upload without the expectation. Rewinds
* for replays that follow no response, such as a reused connection that
* died or a refused HTTP/2 stream, retry through
* shouldRetryFailedRewind() instead. Followed redirects also rewind,
* but the built-in handlers never enable them.
*/
private static function isChallengeRewindFailure(EasyHandle $easy): bool
private static function isResponseRewindFailure(EasyHandle $easy): bool
{
return $easy->errno === self::CURLE_SEND_FAIL_REWIND
&& $easy->response !== null
@ -1283,6 +1336,23 @@ final class CurlFactory implements CurlFactoryInterface
);
}
if ($easy->bodyRewindTimeoutException) {
// Rewinding the request body stalled, which is a caller-stream failure.
return self::createRequestOrResponseRejection(
$easy,
'Timed out while rewinding the request body',
$easy->bodyRewindTimeoutException
);
}
if ($easy->bodyRewindException) {
$message = $easy->bodyRewindException->getMessage() !== ''
? $easy->bodyRewindException->getMessage()
: 'Failed to rewind the request body';
return self::createRequestOrResponseRejection($easy, $message, $easy->bodyRewindException);
}
if ($easy->bodyReadException) {
$message = $easy->bodyReadException->getMessage() !== ''
? $easy->bodyReadException->getMessage()
@ -1340,9 +1410,9 @@ final class CurlFactory implements CurlFactoryInterface
);
if ('' !== $sanitizedError) {
$redactedUriString = Psr7\DiagnosticValue::escape(Psr7\Utils::redactUserInfo($uri)->__toString());
if ($redactedUriString !== '' && false === \strpos($sanitizedError, $redactedUriString)) {
$message .= \sprintf(' for %s', $redactedUriString);
$safeUri = Psr7\Utils::redactUriForMessage($uri);
if ($safeUri !== '' && false === \strpos($sanitizedError, $safeUri)) {
$message .= \sprintf(' for %s', $safeUri);
}
}
@ -1440,15 +1510,7 @@ final class CurlFactory implements CurlFactoryInterface
$error = self::redactProxyUserInfo($error, $proxy);
$baseUri = $uri->withQuery('')->withFragment('');
$baseUriString = $baseUri->__toString();
if ('' !== $baseUriString) {
$redactedUriString = Psr7\Utils::redactUserInfo($baseUri)->__toString();
$error = str_replace($baseUriString, $redactedUriString, $error);
}
return Psr7\DiagnosticValue::escape($error);
return UriDiagnostic::redactInMessage($error, $uri);
}
private static function redactProxyUserInfo(
@ -2242,6 +2304,9 @@ final class CurlFactory implements CurlFactoryInterface
$conf[\CURLOPT_FILE],
$conf[\CURLOPT_INFILE]
);
if (\defined('CURLOPT_SEEKFUNCTION')) {
unset($conf[(int) \constant('CURLOPT_SEEKFUNCTION')]);
}
if (\trim($easy->request->getHeaderLine('Content-Length'), " \n\r\t\0\x0B") !== '0') {
$this->removeHeader('Content-Length', $conf);
}
@ -2301,7 +2366,13 @@ final class CurlFactory implements CurlFactoryInterface
* @return int|string
*/
$remaining = $contentLength;
$conf[\CURLOPT_READFUNCTION] = static function ($ch, $fd, int $length) use ($easy, $body, &$remaining) {
$conf[\CURLOPT_READFUNCTION] = static function (
#[\SensitiveParameter]
$ch,
#[\SensitiveParameter]
$fd,
int $length
) use ($easy, $body, &$remaining) {
if ($remaining === 0) {
return '';
}
@ -2339,6 +2410,56 @@ final class CurlFactory implements CurlFactoryInterface
return $data;
};
if (\defined('CURLOPT_SEEKFUNCTION')) {
// Give libcurl a seek callback so it can reposition the
// streamed upload itself when a transfer must resend the body
// (multi-pass auth such as NTLM/Negotiate, or a reused
// connection that died) instead of failing with
// CURLE_SEND_FAIL_REWIND. See
// https://bugs.php.net/bug.php?id=47204. A failed seek is
// recorded so the rejection carries the caller's exception
// and the failed-rewind retry does not fire.
$successfulSeeks = 0;
$conf[(int) \constant('CURLOPT_SEEKFUNCTION')] = static function (
#[\SensitiveParameter]
$ch,
int $offset,
int $origin
) use ($easy, $body, $contentLength, &$remaining, &$successfulSeeks): int {
if ($origin !== \SEEK_SET) {
return self::CURL_SEEKFUNC_CANTSEEK;
}
try {
if (!$body->isSeekable()) {
return self::CURL_SEEKFUNC_CANTSEEK;
}
if ($offset < 0 || ($contentLength !== null && $offset > $contentLength)) {
throw new \RuntimeException('Request body seek offset is outside the declared Content-Length');
}
if ($successfulSeeks >= self::CURL_SEEK_MAX_REPLAYS) {
throw new \RuntimeException(\sprintf('Request body cannot be replayed more than %d times', self::CURL_SEEK_MAX_REPLAYS));
}
$body->seek($offset, \SEEK_SET);
} catch (TimeoutException $e) {
$easy->bodyRewindTimeoutException = $e;
return self::CURL_SEEKFUNC_FAIL;
} catch (\Throwable $e) {
$easy->bodyRewindException = $e;
return self::CURL_SEEKFUNC_FAIL;
}
$remaining = $contentLength === null ? null : $contentLength - $offset;
++$successfulSeeks;
return self::CURL_SEEKFUNC_OK;
};
}
}
// If the Expect header is not present, prevent curl from adding it
@ -2692,11 +2813,12 @@ final class CurlFactory implements CurlFactoryInterface
/** @var (callable(int, int, int, int): mixed)|null $progress */
$conf[\CURLOPT_NOPROGRESS] = false;
$progressCallback = static function ($resource, $downloadSize, $downloaded, $uploadSize, $uploaded) use ($easy, $progress): int {
// Abort the transfer when the request body read failed (the
// cross-version abort path, since older PHP ignores the read
// callback's return). progressAborted is left unset so the
// failure is classified from the stored request-body exception.
if ($easy->bodyReadTimeoutException !== null || $easy->bodyReadException !== null) {
// Abort the transfer when the request body read or rewind
// failed (the cross-version abort path, since older PHP
// ignores the read callback's return). progressAborted is left
// unset so the failure is classified from the stored
// request-body exception.
if ($easy->bodyReadTimeoutException !== null || $easy->bodyRewindTimeoutException !== null || $easy->bodyRewindException !== null || $easy->bodyReadException !== null) {
return 1;
}

View file

@ -319,7 +319,7 @@ final class CurlMultiHandler
// Never null: assigned below before any wait can invoke this.
/** @var Promise<ResponseInterface, mixed> $promise */
if (!P\Is::pending($promise)) {
if ($easy->deferredSettled || !P\Is::pending($promise)) {
return;
}
@ -1328,8 +1328,10 @@ final class CurlMultiHandler
/**
* @param resource|\CurlHandle $handle
*/
private function clearEasyHandleCallbacks($handle): void
{
private function clearEasyHandleCallbacks(
#[\SensitiveParameter]
$handle
): void {
curl_setopt($handle, \CURLOPT_HEADERFUNCTION, null);
curl_setopt($handle, \CURLOPT_READFUNCTION, null);
curl_setopt($handle, \CURLOPT_WRITEFUNCTION, null);
@ -1339,6 +1341,10 @@ final class CurlMultiHandler
curl_setopt($handle, (int) \constant('CURLOPT_PREREQFUNCTION'), null);
}
if (\defined('CURLOPT_SEEKFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_SEEKFUNCTION'), null);
}
if (\defined('CURLOPT_XFERINFOFUNCTION')) {
curl_setopt($handle, (int) \constant('CURLOPT_XFERINFOFUNCTION'), null);
}
@ -1347,8 +1353,11 @@ final class CurlMultiHandler
/**
* @param resource|\CurlHandle $handle
*/
private function removeHandleFromMulti(int $id, $handle): void
{
private function removeHandleFromMulti(
int $id,
#[\SensitiveParameter]
$handle
): void {
// Removing a still-running transfer performs a final progress update
// that can run a user progress callback, so removal is guarded like
// native execution.
@ -1530,6 +1539,7 @@ final class CurlMultiHandler
$result = CurlFactory::finish($this, $entry['easy'], $this->factory);
} catch (\Throwable $e) {
if (P\Is::pending($entry['deferred'])) {
$entry['easy']->deferredSettled = true;
$entry['deferred']->reject($e);
}
@ -1537,6 +1547,7 @@ final class CurlMultiHandler
}
if (P\Is::pending($entry['deferred'])) {
$entry['easy']->deferredSettled = true;
$entry['deferred']->resolve($result);
}
}

View file

@ -106,6 +106,24 @@ final class CurlShareHandleState
));
}
/**
* Whether cURL can share both the DNS and SSL session cache state that
* required handler-lifetime sharing needs, checked without configuring a
* share handle so default handler selection can fall back to the stream
* handler instead.
*/
public static function supportsHandlerRequireShare(): bool
{
return CurlVersion::supportsHandlerSharing()
&& CurlVersion::supportsSslSessionSharing()
&& \function_exists('curl_share_init')
&& \function_exists('curl_share_setopt')
&& \defined('CURLOPT_SHARE')
&& \defined('CURLSHOPT_SHARE')
&& \defined('CURL_LOCK_DATA_DNS')
&& \defined('CURL_LOCK_DATA_SSL_SESSION');
}
private static function createHandlerShareOrNull(string $mode): ?self
{
try {

View file

@ -52,6 +52,12 @@ final class EasyHandle
*/
public bool $usesPipewait = false;
/**
* @var bool Whether processMessages() settled the deferred promise, which
* a rewind retry settles with a still-pending promise
*/
public bool $deferredSettled = false;
/**
* @var ResponseInterface|null Received response (if any)
*/
@ -108,6 +114,16 @@ final class EasyHandle
*/
public ?TimeoutException $bodyReadTimeoutException = null;
/**
* @var TimeoutException|null Exception during request body rewind timeout.
*/
public ?TimeoutException $bodyRewindTimeoutException = null;
/**
* @var \Throwable|null Exception during request body rewind.
*/
public ?\Throwable $bodyRewindException = null;
/**
* @var \Throwable|null Exception during request body read.
*/

View file

@ -26,6 +26,7 @@ use GuzzleHttp\RequestOptions;
use GuzzleHttp\TransferStats;
use GuzzleHttp\TransportSharing;
use GuzzleHttp\Utils;
use Openssl\Session;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
@ -46,6 +47,12 @@ final class StreamHandler
'transport_sharing' => true,
];
/**
* Peers tracked by the TLS session cache, matching libcurl's share handle
* TLS session cache (25 peers, up to 2 sessions each).
*/
private const TLS_SESSION_CACHE_MAX_KEYS = 25;
private const CONNECTION_ERRORS = [
'php_network_getaddresses:',
'getaddrinfo',
@ -76,6 +83,21 @@ final class StreamHandler
'unexpected eof while reading',
];
// PHP's HTTP wrapper can collapse wrapper-level errors to OpenFailed.
// These codes classify only errors delivered directly to the context
// handler. NetworkRecvFailed is omitted because PHP currently emits it for
// handshake-time certificate policy failures.
private const STRUCTURED_NETWORK_ERROR_CODES = [
'NetworkSendFailed',
];
// SslNotSupported can be emitted directly by a transport that cannot
// enable crypto. Standard ext-openssl proxy failures are normally
// collapsed to OpenFailed and continue to use message classification.
private const STRUCTURED_CONNECTION_ERROR_CODES = [
'SslNotSupported',
];
/**
* Default idle timeout in milliseconds when the "read_timeout" option is
* not set. Matches PHP's default_socket_timeout default, which the
@ -93,6 +115,8 @@ final class StreamHandler
private bool $connectionCapsConfigured = false;
private ?StreamTlsSessionCache $sessionCache = null;
/**
* Accepts an associative array of options:
*
@ -225,19 +249,7 @@ final class StreamHandler
if (!$e instanceof TransferException) {
$message = $e->getMessage();
if (self::isSendError($message)) {
$e = self::isConnectTimeoutError($message)
? new NetworkTimeoutException($message, $request, $e)
: new NetworkException($message, $request, $e);
} elseif (self::isConnectTimeoutError($message)) {
$e = new ConnectTimeoutException($message, $request, $e);
} elseif (self::isConnectionError($message)) {
$e = new ConnectException($message, $request, $e);
} elseif (self::isNetworkError($message)) {
$e = new NetworkException($message, $request, $e);
} else {
$e = new RequestException($message, $request, 0, $e);
}
$e = self::createStreamFailureException($message, $request, $e, []);
}
$this->invokeStats($options, $request, $startTime, null, $e);
@ -296,6 +308,56 @@ final class StreamHandler
return false;
}
/**
* @param string[] $streamErrorCodes
*/
private static function createStreamFailureException(
#[\SensitiveParameter]
string $message,
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
\Exception $previous,
array $streamErrorCodes
): TransferException {
if (self::isSendError($message) || self::hasStreamErrorCode($streamErrorCodes, self::STRUCTURED_NETWORK_ERROR_CODES)) {
return self::isConnectTimeoutError($message)
? new NetworkTimeoutException($message, $request, $previous)
: new NetworkException($message, $request, $previous);
}
if (self::isConnectTimeoutError($message)) {
return new ConnectTimeoutException($message, $request, $previous);
}
if (self::hasStreamErrorCode($streamErrorCodes, self::STRUCTURED_CONNECTION_ERROR_CODES) || self::isConnectionError($message)) {
return new ConnectException($message, $request, $previous);
}
if (self::isNetworkError($message)) {
return new NetworkException($message, $request, $previous);
}
return new RequestException($message, $request, 0, $previous);
}
/**
* @param string[] $streamErrorCodes
* @param string[] $codes
*/
private static function hasStreamErrorCode(array $streamErrorCodes, array $codes): bool
{
foreach ($streamErrorCodes as $streamErrorCode) {
foreach ($codes as $code) {
if ($streamErrorCode === $code) {
return true;
}
}
}
return false;
}
private function invokeStats(
#[\SensitiveParameter]
array $options,
@ -829,8 +891,11 @@ final class StreamHandler
*
* @throws \RuntimeException when the callback returns false or resource creation emits an error.
*/
private function createResource(callable $callback)
{
private function createResource(
callable $callback,
#[\SensitiveParameter]
?UriInterface $diagnosticUri = null
) {
$errors = [];
\set_error_handler(static function (int $_, string $msg, string $file, int $line) use (&$errors): bool {
$errors[] = [
@ -852,7 +917,10 @@ final class StreamHandler
$details = [];
foreach ($errors as $err) {
foreach ($err as $key => $value) {
$details[] = \sprintf('[%s] %s', $key, Psr7\DiagnosticValue::escape((string) $value));
$rendered = $key === 'message' && $diagnosticUri !== null
? UriDiagnostic::redactInMessage((string) $value, $diagnosticUri)
: Psr7\DiagnosticValue::escape((string) $value);
$details[] = \sprintf('[%s] %s', $key, $rendered);
}
}
@ -877,6 +945,10 @@ final class StreamHandler
array $options,
string $body
) {
// Report a stream-open failure against the request passed here, not the
// Connection: close clone built for the wire.
$callerRequest = $request;
// HTTP/1.1 streams using the PHP stream wrapper require a
// Connection: close header
if ($request->getProtocolVersion() === '1.1'
@ -892,6 +964,7 @@ final class StreamHandler
$params = [];
$context = $this->getDefaultContext($request, $body);
$customSslContext = [];
if (isset($options['on_headers']) && !\is_callable($options['on_headers'])) {
throw new InvalidArgumentException('on_headers must be callable');
@ -921,10 +994,17 @@ final class StreamHandler
self::rejectConflictingStreamContextOptions($streamContext);
self::rejectUnsupportedStreamContextOptions($streamContext);
$context = \array_replace_recursive($context, $streamContext);
$sslContext = $streamContext['ssl'] ?? null;
if (\is_array($sslContext)) {
$customSslContext = $sslContext;
}
}
$this->addDefaultTlsMinimum($request, $context);
$commitTlsSessions = $this->applyTlsSessionResumption($request, $context, $customSslContext);
// The context timeout governs connecting and the header-phase packet
// gaps: the idle timeout, tightened to the deadline when that is
// lower; -1 disables it so default_socket_timeout is never consulted.
@ -936,6 +1016,10 @@ final class StreamHandler
$context['http']['timeout'] = -1;
}
$streamErrorCodes = [];
$captureStreamErrors = true;
$this->addStructuredStreamErrorHandler($context, $streamErrorCodes, $captureStreamErrors);
$uri = $this->resolveHost($request, $options);
$contextResource = $this->createResource(
@ -944,8 +1028,8 @@ final class StreamHandler
}
);
return $this->createResource(
function () use ($uri, $contextResource, $idleTimeout, $timeout) {
try {
$resource = $this->createResource(function () use ($uri, $contextResource, $idleTimeout, $timeout) {
$this->lastDeadline = $timeout > 0 ? Clock::now() + $timeout / 1000 : null;
// Blank the from ini setting for the transfer so ambient
@ -982,8 +1066,69 @@ final class StreamHandler
}
return $resource;
}, $uri);
} catch (TransferException $e) {
// Notification callbacks run during fopen(); an exception a
// callback throws is already a fully-formed transfer failure for
// its own request, so it passes through unchanged instead of
// being reclassified against this request.
throw $e;
} catch (\RuntimeException $e) {
throw self::createStreamFailureException($e->getMessage(), $callerRequest, $e, $streamErrorCodes);
} finally {
$captureStreamErrors = false;
}
if ($commitTlsSessions !== null) {
$commitTlsSessions();
}
return $resource;
}
/**
* @param string[] $streamErrorCodes
*/
private function addStructuredStreamErrorHandler(
#[\SensitiveParameter]
array &$context,
array &$streamErrorCodes,
bool &$captureStreamErrors
): void {
if (!self::supportsStructuredStreamErrors()) {
return;
}
if (!isset($context['stream']) || !\is_array($context['stream'])) {
$context['stream'] = [];
}
$context['stream']['error_mode'] = \StreamErrorMode::Error;
$context['stream']['error_store'] = \StreamErrorStore::None;
/** @param \StreamError[] $errors */
$context['stream']['error_handler'] = static function (
#[\SensitiveParameter]
array $errors
) use (&$streamErrorCodes, &$captureStreamErrors): void {
if (!$captureStreamErrors) {
return;
}
);
foreach ($errors as $error) {
$name = $error->code->name;
if (!\in_array($name, $streamErrorCodes, true)) {
$streamErrorCodes[] = $name;
}
}
};
}
private static function supportsStructuredStreamErrors(): bool
{
// Any PHP 8.6 build qualifies, including pre-release and nightly
// builds; the class check keeps unstable builds that do not carry
// the final API fail-closed.
return \PHP_VERSION_ID >= 80600 && \class_exists(\StreamError::class, false);
}
private static function assertRequestUriSupported(
@ -1128,6 +1273,160 @@ final class StreamHandler
$context['ssl']['min_proto_version'] = \STREAM_CRYPTO_PROTO_TLSv1_2;
}
/**
* Wires PHP 8.6+ TLS session resumption into the SSL context. Preferred
* sharing modes fall back to no sharing when a request cannot safely use
* Guzzle-managed sessions; HANDLER_REQUIRE fails loudly instead.
*
* New sessions are held temporarily and committed only after the HTTPS
* stream opens successfully. If PHP rejects peer verification or another
* stream-open step fails, any session reported during that attempt is
* discarded.
*
* @param array $customSslContext User-supplied stream_context['ssl']
* values.
*/
private function applyTlsSessionResumption(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array &$context,
#[\SensitiveParameter]
array $customSslContext = []
): ?\Closure {
if (!$this->transportSharingRequested()) {
return null;
}
$uri = $request->getUri();
if ('https' !== $uri->getScheme()) {
$this->failRequiredTlsSharingForRequest($request, 'handler-lifetime TLS session sharing only applies to HTTPS requests.');
return null;
}
$host = $uri->getHost();
// PHP opens a proxy transport with the full stream context, so a TLS
// proxy handshake would consume origin-keyed session state; only a
// tcp:// proxy keeps TLS on the origin leg alone.
$proxy = $context['http']['proxy'] ?? null;
if (\is_string($proxy) && ProxyOptions::proxyScheme($proxy) !== 'tcp') {
$this->failRequiredTlsSharingForRequest($request, 'the proxy uses a TLS stream transport, which would mix proxy and origin TLS session state.');
return null;
}
if (!isset($context['ssl']) || !\is_array($context['ssl'])) {
$this->failRequiredTlsSharingForConfiguration('the final stream SSL context is not an array.');
return null;
}
$unsupported = StreamTlsSessionCache::unsupportedContextReason($context['ssl'], $customSslContext);
if ($unsupported !== null) {
$this->failRequiredTlsSharingForConfiguration($unsupported);
return null;
}
$cache = $this->sessionCache();
if ($cache === null) {
$this->failRequiredTlsSharingForConfiguration('PHP 8.6+ with the OpenSSL session API is required.');
return null;
}
$key = StreamTlsSessionCache::peerKey(self::canonicalConnectionHost($host), $uri->getPort() ?? 443, $context['ssl']);
$credentials = StreamTlsSessionCache::credentialFingerprint($context['ssl']);
$session = $cache->find($key, $credentials);
if ($session !== null) {
$context['ssl']['session_data'] = $session;
}
// Sessions captured before the stream opens are staged so handshakes
// that fail PHP's peer verification policy are never cached.
$accepted = false;
$staged = [];
$context['ssl']['session_new_cb'] = static function (
#[\SensitiveParameter]
$stream,
#[\SensitiveParameter]
Session $session
) use ($cache, $key, $credentials, &$accepted, &$staged): void {
if ($accepted) {
$cache->store($key, $credentials, $session);
return;
}
// A server can stream session tickets while withholding response
// headers; keep only as many staged sessions as the cache retains.
if (\count($staged) >= StreamTlsSessionCache::MAX_SESSIONS_PER_KEY) {
\array_shift($staged);
}
$staged[] = $session;
};
return static function () use ($cache, $key, $credentials, &$accepted, &$staged): void {
$accepted = true;
foreach ($staged as $session) {
$cache->store($key, $credentials, $session);
}
$staged = [];
};
}
private function transportSharingRequested(): bool
{
return $this->transportSharingMode !== TransportSharing::NONE
&& $this->transportSharingMode !== TransportSharing::PERSISTENT_REQUIRE;
}
private function transportSharingRequired(): bool
{
return $this->transportSharingMode === TransportSharing::HANDLER_REQUIRE;
}
private function failRequiredTlsSharingForRequest(
#[\SensitiveParameter]
RequestInterface $request,
string $reason
): void {
if ($this->transportSharingRequired()) {
throw new RequestException('The "transport_sharing" option requires stream handler TLS session sharing, but '.$reason, $request);
}
}
private function failRequiredTlsSharingForConfiguration(string $reason): void
{
if ($this->transportSharingRequired()) {
throw new InvalidArgumentException('The "transport_sharing" option requires stream handler TLS session sharing, but '.$reason);
}
}
/**
* Returns this handler's TLS session cache, or null when the configured
* sharing mode shares nothing or the OpenSSL session API is unavailable.
* Persistent (process-wide) sharing is a cURL-only feature, so
* PERSISTENT_PREFER degrades to this per-handler cache while
* PERSISTENT_REQUIRE is rejected before sharing applies.
*/
private function sessionCache(): ?StreamTlsSessionCache
{
if (!$this->transportSharingRequested() || !StreamTlsSessionCache::isSupported()) {
return null;
}
return $this->sessionCache ?? ($this->sessionCache = new StreamTlsSessionCache(self::TLS_SESSION_CACHE_MAX_KEYS));
}
private function getDefaultContext(
#[\SensitiveParameter]
RequestInterface $request,
@ -1182,6 +1481,20 @@ final class StreamHandler
return $context;
}
private function assertTransportSharingSupported(): void
{
// The stream handler cannot pool live connections or share state across
// handler instances; persistent sharing is cURL-only.
if ($this->transportSharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" option requires persistent transport sharing, which is only available through cURL share handles. The stream handler can only share handler-lifetime TLS sessions.');
}
// Handler-scoped sharing needs the PHP 8.6+ OpenSSL session API.
if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE && !StreamTlsSessionCache::isSupported()) {
throw new InvalidArgumentException('The "transport_sharing" option requires handler-lifetime transport sharing, but the stream handler only supports it through TLS session resumption on PHP 8.6+ with the OpenSSL session API.');
}
}
private static function rejectUnsupportedRequestOptions(
#[\SensitiveParameter]
RequestInterface $request,
@ -1361,20 +1674,14 @@ final class StreamHandler
'verify_peer' => 'the "verify" request option',
'verify_peer_name' => 'the "verify" request option',
],
'stream' => [
'error_handler' => 'Guzzle stream error handling',
'error_mode' => 'Guzzle stream error handling',
'error_store' => 'Guzzle stream error handling',
],
];
}
private function assertTransportSharingSupported(): void
{
if ($this->transportSharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" option requires persistent transport sharing, which is only available through cURL share handles.');
}
if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" option requires transport sharing, but the stream handler does not support it.');
}
}
/**
* @param mixed $value as passed via Request transfer options.
*

View file

@ -0,0 +1,583 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\HostIdentity;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7;
use Openssl\Session;
/**
* Per-handler in-memory TLS session resumption cache for the stream handler.
*
* Backed by the OpenSSL TLS session API added in PHP 8.6. A session is only
* ever replayed for an identical TLS identity: the non-secret identity is
* encoded in the lookup key and secret material is matched in constant time.
* TLS resumption reuses the original certificate-chain verification result
* instead of building and verifying the chain again. Guzzle includes ambient
* trust configuration paths in the cache identity, but cannot detect
* trust-store contents rewritten at an unchanged path. Recreate the handler
* or disable transport sharing when trust changes must take effect
* immediately.
*
* New sessions are held temporarily and committed only after the HTTPS stream
* opens successfully. If PHP rejects peer verification or another stream-open
* step fails, any session reported during that attempt is discarded.
*
* @internal
*/
final class StreamTlsSessionCache
{
use NonSerializableTrait;
/**
* Sessions retained per peer. libcurl uses 2 to buffer single-use TLS 1.3
* tickets; the synchronous stream handler does not need more.
*/
public const MAX_SESSIONS_PER_KEY = 2;
/**
* TLS 1.3 tickets should not live longer than RFC 8446 allows. All
* pre-TLS-1.3 sessions use libcurl's tighter one-day cap.
*/
private const MAX_TLS13_LIFETIME = 604800;
private const MAX_PRE_TLS13_LIFETIME = 86400;
private const USER_MANAGED_SESSION_OPTIONS = [
'session_cache' => true,
'session_cache_size' => true,
'session_data' => true,
'session_get_cb' => true,
'session_id_context' => true,
'session_new_cb' => true,
'session_remove_cb' => true,
'session_stream' => true,
'session_timeout' => true,
];
private const USER_MANAGED_PSK_OPTIONS = [
'psk_client_cb' => true,
'psk_server_cb' => true,
];
/**
* TLS 1.3 early data (0-RTT) options are deliberately never shared: an
* injected cached session would let PHP send the replayable early data
* payload ahead of the HTTP request.
*/
private const USER_MANAGED_EARLY_DATA_OPTIONS = [
'early_data' => true,
'early_data_cb' => true,
'max_early_data' => true,
];
/**
* File/path-bearing SSL context options are deliberately not shared: their
* contents can change outside this handler.
*/
private const PATH_OPTIONS = [
'SNI_server_certs' => true,
'cafile' => true,
'capath' => true,
'dh_param' => true,
'local_cert' => true,
'local_pk' => true,
];
private const CERT_CAPTURE_OPTIONS = [
'capture_peer_cert' => true,
'capture_peer_cert_chain' => true,
];
/**
* Scalar custom SSL context options that are known to be self-contained
* and safe to include in the TLS session cache identity.
*/
private const CUSTOM_SCALAR_KEYABLE_OPTIONS = [
'SNI_enabled' => true,
'allow_self_signed' => true,
'alpn_protocols' => true,
'ciphers' => true,
'crypto_method' => true,
'disable_compression' => true,
'max_proto_version' => true,
'min_proto_version' => true,
'peer_name' => true,
'security_level' => true,
'verify_depth' => true,
'verify_peer' => true,
'verify_peer_name' => true,
];
private const INVALID_PEER_FINGERPRINT_REASON = 'the SSL context option "peer_fingerprint" must be a string or a non-empty, flat array with string algorithm names and string fingerprints.';
private int $maxKeys;
/**
* @var array<string, list<array{session: Session, credentials: string, expiresAt: int, singleUse: bool}>>
*/
private array $sessions = [];
public function __construct(int $maxKeys)
{
if ($maxKeys < 1) {
throw new InvalidArgumentException('maxKeys must be a positive integer.');
}
$this->maxKeys = $maxKeys;
}
public static function isSupported(): bool
{
// Any PHP 8.6 build qualifies, including pre-release and nightly
// builds; the class check keeps unstable builds that do not carry
// the final API fail-closed.
return \PHP_VERSION_ID >= 80600 && \class_exists(Session::class, false);
}
/**
* @param array $ssl The assembled 'ssl' stream context array.
* @param array $customSsl The user-supplied stream_context['ssl'] array.
*/
public static function unsupportedContextReason(array $ssl, array $customSsl = []): ?string
{
foreach ($customSsl as $key => $value) {
if (
!isset(self::CUSTOM_SCALAR_KEYABLE_OPTIONS[$key])
&& !isset(self::PATH_OPTIONS[$key])
&& !isset(self::USER_MANAGED_SESSION_OPTIONS[$key])
&& !isset(self::USER_MANAGED_PSK_OPTIONS[$key])
&& !isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key])
&& !isset(self::CERT_CAPTURE_OPTIONS[$key])
&& $key !== 'no_ticket'
&& $key !== 'peer_fingerprint'
) {
return \sprintf('the custom SSL context option "%s" is not known to be safe for TLS session sharing.', Psr7\DiagnosticValue::escape((string) $key));
}
}
foreach ($ssl as $key => $value) {
if (isset(self::PATH_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" uses file or path state that cannot be safely shared.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::USER_MANAGED_SESSION_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" is user-managed TLS session state.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::USER_MANAGED_PSK_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" is user-managed TLS PSK state.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key])) {
return \sprintf('the SSL context option "%s" is user-managed TLS early data state.', Psr7\DiagnosticValue::escape((string) $key));
}
if (isset(self::CERT_CAPTURE_OPTIONS[$key]) && $value) {
return \sprintf('the SSL context option "%s" requires a fresh peer certificate handshake.', Psr7\DiagnosticValue::escape((string) $key));
}
if ($key === 'no_ticket' && $value) {
return 'the SSL context option "no_ticket" disables TLS ticket sharing.';
}
if ($key === 'peer_fingerprint') {
if (!self::isPeerFingerprint($value)) {
return self::INVALID_PEER_FINGERPRINT_REASON;
}
continue;
}
if (!self::isCanonicalScalar($value)) {
return \sprintf('the SSL context option "%s" cannot be safely included in the TLS session cache identity.', Psr7\DiagnosticValue::escape((string) $key));
}
}
return null;
}
/**
* Builds the non-secret lookup key: host/port plus every TLS parameter that
* defines the verification/identity context. Keyed on the connection host
* a transport reads, with numeric IPv4 spellings folded to one dotted
* quad; names are never resolved. Secret material is excluded.
*
* @param array $ssl The assembled 'ssl' stream context array.
*/
public static function peerKey(
string $host,
?int $port,
#[\SensitiveParameter]
array $ssl
): string {
$identity = [
'schema' => 'guzzle-stream-tls-session-v1',
'runtime' => self::runtimeIdentity(),
'peer' => [
'host' => self::canonicalHost($host),
'port' => $port,
],
'ssl' => self::canonicalPeerSslContext($ssl),
];
return \hash('sha256', \serialize($identity));
}
/**
* Builds the secret-aware credential fingerprint matched in constant time,
* keeping the passphrase out of the loggable peer key.
*
* Every credential-bearing context is currently rejected by
* unsupportedContextReason() before sharing, so fingerprints can only
* diverge if the allow-lists are ever widened; the constant-time match is
* retained as defense in depth for that case.
*
* @param array $ssl The assembled 'ssl' stream context array.
*/
public static function credentialFingerprint(array $ssl): string
{
$material = [
'local_cert' => isset($ssl['local_cert']) && \is_string($ssl['local_cert']) ? self::pathIdentity($ssl['local_cert']) : null,
'local_pk' => isset($ssl['local_pk']) && \is_string($ssl['local_pk']) ? self::pathIdentity($ssl['local_pk']) : null,
'passphrase' => isset($ssl['passphrase']) && \is_string($ssl['passphrase']) ? $ssl['passphrase'] : null,
];
return \hash('sha256', \serialize($material));
}
public function find(string $key, string $credentials): ?Session
{
if (!isset($this->sessions[$key])) {
return null;
}
$now = \time();
$entries = $this->sessions[$key];
$found = null;
foreach ($entries as $i => $entry) {
if ($entry['expiresAt'] <= $now) {
unset($entries[$i]);
continue;
}
if (!\hash_equals($entry['credentials'], $credentials)) {
continue;
}
if (!$entry['session']->isResumable()) {
unset($entries[$i]);
continue;
}
$found = $entry['session'];
// TLS 1.3 tickets are single-use; consume on take.
if ($entry['singleUse']) {
unset($entries[$i]);
}
break;
}
if ($entries === []) {
unset($this->sessions[$key]);
} else {
$this->sessions[$key] = \array_values($entries);
if ($found !== null) {
$this->touch($key);
}
}
return $found;
}
public function store(
string $key,
string $credentials,
#[\SensitiveParameter]
Session $session
): void {
if (!$session->isResumable()) {
return;
}
$expiresAt = self::expiry($session);
if ($expiresAt === null) {
return;
}
$list = $this->sessions[$key] ?? [];
$list[] = [
'session' => $session,
'credentials' => $credentials,
'expiresAt' => $expiresAt,
'singleUse' => self::isTls13($session),
];
if (\count($list) > self::MAX_SESSIONS_PER_KEY) {
$list = \array_slice($list, -self::MAX_SESSIONS_PER_KEY);
}
$this->sessions[$key] = $list;
$this->touch($key);
$this->evictExcessKeys();
}
private function touch(string $key): void
{
if (!isset($this->sessions[$key])) {
return;
}
$value = $this->sessions[$key];
unset($this->sessions[$key]);
$this->sessions[$key] = $value;
}
private function evictExcessKeys(): void
{
while (\count($this->sessions) > $this->maxKeys) {
\array_shift($this->sessions);
}
}
private static function isTls13(
#[\SensitiveParameter]
Session $session
): bool {
$protocol = $session->getProtocol();
return $protocol !== null && Psr7\Utils::caselessContains($protocol, '1.3');
}
/**
* Returns the absolute expiry timestamp, or null when the session must
* not be cached.
*/
private static function expiry(
#[\SensitiveParameter]
Session $session
): ?int {
return self::expiryFromLifetimes(
self::isTls13($session),
$session->hasTicket(),
$session->getTicketLifetimeHint(),
$session->getTimeout(),
$session->getCreatedAt(),
\time()
);
}
/**
* Decides the absolute expiry timestamp from a session's scalar lifetime
* attributes, or null when the session must not be cached.
*
* A non-positive session timeout is never cached. TLS 1.3 resumption
* requires a New Session Ticket, so a TLS 1.3 session without a ticket
* or without a positive RFC 8446 ticket lifetime is not cached, and the
* ticket lifetime bounds the expiry together with the session timeout
* and the seven-day cap. Pre-TLS-1.3 tickets treat a zero or missing
* lifetime hint as unspecified per RFC 5077, so only a positive hint
* tightens the session timeout and the one-day cap. An expiry that has
* already passed is not cached.
*/
private static function expiryFromLifetimes(bool $isTls13, bool $hasTicket, ?int $ticketLifetimeHint, int $timeout, int $createdAt, int $now): ?int
{
if ($timeout <= 0) {
return null;
}
$lifetime = \min($timeout, $isTls13 ? self::MAX_TLS13_LIFETIME : self::MAX_PRE_TLS13_LIFETIME);
if ($isTls13) {
if (!$hasTicket || $ticketLifetimeHint === null || $ticketLifetimeHint <= 0) {
return null;
}
$lifetime = \min($lifetime, $ticketLifetimeHint);
} elseif ($hasTicket && $ticketLifetimeHint !== null && $ticketLifetimeHint > 0) {
$lifetime = \min($lifetime, $ticketLifetimeHint);
}
$expiresAt = $createdAt + $lifetime;
return $expiresAt > $now ? $expiresAt : null;
}
private static function runtimeIdentity(): array
{
return [
'php' => \PHP_VERSION_ID,
'openssl' => \defined('OPENSSL_VERSION_NUMBER') ? \OPENSSL_VERSION_NUMBER : null,
'trust' => [
'openssl.cafile' => (string) \ini_get('openssl.cafile'),
'openssl.capath' => (string) \ini_get('openssl.capath'),
'SSL_CERT_FILE' => (string) \getenv('SSL_CERT_FILE'),
'SSL_CERT_DIR' => (string) \getenv('SSL_CERT_DIR'),
'cwd' => \getcwd(),
],
];
}
/**
* Returns the host identity hashed into the cache key. Valid bracketed
* IPv6 literals are canonicalized to their RFC 5952 form so equivalent
* spellings of one address share a single session entry; other valid
* hosts, such as reg-names and IPvFuture literals, fall back to ASCII
* case folding. Text that is not a valid RFC 3986 host, such as
* zone-bearing or malformed bracketed literals, must never become a
* usable cache key, so it is rejected.
*/
private static function canonicalHost(string $host): string
{
if (!Psr7\Rfc3986::isValidHost($host)) {
throw new InvalidArgumentException('Hosts used in the TLS session cache identity must be valid RFC 3986 hosts.');
}
return HostIdentity::canonicalHost($host);
}
private static function canonicalPeerSslContext(
#[\SensitiveParameter]
array $ssl
): array {
$context = [];
foreach ($ssl as $key => $value) {
if (isset(self::USER_MANAGED_SESSION_OPTIONS[$key]) || isset(self::USER_MANAGED_PSK_OPTIONS[$key]) || isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key]) || $key === 'passphrase') {
continue;
}
if ($key === 'peer_name' && \is_string($value)) {
$context[$key] = ['string', self::canonicalHost($value)];
continue;
}
if ($key === 'peer_fingerprint') {
$context[$key] = self::canonicalPeerFingerprint($value);
continue;
}
if (\in_array($key, ['cafile', 'capath', 'local_cert', 'local_pk'], true) && \is_string($value)) {
$context[$key] = self::pathIdentity($value);
continue;
}
$context[$key] = self::canonicalScalar($value);
}
\ksort($context);
return $context;
}
private static function pathIdentity(string $path): array
{
$realPath = \realpath($path);
return [
'path',
$realPath !== false ? $realPath : $path,
];
}
/**
* @param mixed $value
*/
private static function isCanonicalScalar($value): bool
{
return $value === null
|| \is_bool($value)
|| \is_int($value)
|| \is_float($value)
|| \is_string($value);
}
/**
* @param mixed $value
*/
private static function isPeerFingerprint($value): bool
{
if (\is_string($value)) {
return true;
}
if (!\is_array($value) || $value === []) {
return false;
}
foreach ($value as $algorithm => $fingerprint) {
if (!\is_string($algorithm) || !\is_string($fingerprint)) {
return false;
}
}
return true;
}
/**
* @param mixed $value
*
* @return array{0: string, 1: mixed}
*/
private static function canonicalScalar($value): array
{
if ($value === null) {
return ['null', null];
}
if (\is_bool($value)) {
return ['bool', $value];
}
if (\is_int($value)) {
return ['int', $value];
}
if (\is_float($value)) {
return ['float', \bin2hex(\pack('E', $value))];
}
if (\is_string($value)) {
return ['string', $value];
}
throw new InvalidArgumentException('SSL context values used in the TLS session cache identity must be scalar or null.');
}
/**
* @param mixed $value
*
* @return array{0: string, 1: mixed}
*/
private static function canonicalPeerFingerprint($value): array
{
if (!self::isPeerFingerprint($value)) {
throw new InvalidArgumentException(self::INVALID_PEER_FINGERPRINT_REASON);
}
if (\is_string($value)) {
return ['string', $value];
}
if (!\is_array($value)) {
throw new InvalidArgumentException(self::INVALID_PEER_FINGERPRINT_REASON);
}
$items = [];
foreach ($value as $algorithm => $fingerprint) {
$items[$algorithm] = ['string', $fingerprint];
}
\ksort($items, \SORT_STRING);
return ['map', $items];
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Psr7;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
final class UriDiagnostic
{
private function __construct()
{
}
public static function redactInMessage(
#[\SensitiveParameter]
string $message,
#[\SensitiveParameter]
UriInterface $uri
): string {
$message = Psr7\Utils::redactUserInfoInString($message, (string) $uri);
$query = $uri->getQuery();
if ($query !== '') {
$message = \str_replace('?'.$query, '', $message);
}
$fragment = $uri->getFragment();
if ($fragment !== '') {
$message = \str_replace('#'.$fragment, '', $message);
}
return Psr7\DiagnosticValue::escape($message);
}
}

View file

@ -240,7 +240,9 @@ final class ProxyOptions
*
* This method will strip a port from the host if it is present. Domain
* patterns are matched case-insensitively. Exact IP literal patterns are
* matched by their normalized binary address.
* matched by their normalized binary address, and a host or pattern
* written in the inet_aton() shorthand a transport reads as an address,
* such as 127.1 or 0x7f000001, is matched as that address.
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
@ -388,7 +390,7 @@ final class ProxyOptions
$host = $address;
}
$packedIp = self::packIpAddress($host);
$packedIp = self::packHostAddress($host);
if ($packedIp !== false) {
return [
'type' => 'ip',
@ -442,7 +444,7 @@ final class ProxyOptions
return $port === null ? null : [$host, $port];
}
if (self::packIpAddress($area) !== false) {
if (self::packHostAddress($area) !== false) {
return [$area, null];
}
@ -494,6 +496,9 @@ final class ProxyOptions
$network = \substr($network, 1, -1);
}
// A bare rule denotes a host identity, but a CIDR rule uses network
// configuration syntax, so the inet_aton() shorthand is not read as
// a network: 127/8 would name 0.0.0.127/8 rather than 127.0.0.0/8.
$network = self::packIpAddress($network);
if ($network === false) {
return null;
@ -576,11 +581,27 @@ final class ProxyOptions
*/
private static function packIpAddress(string $ip)
{
if (!\filter_var($ip, \FILTER_VALIDATE_IP)) {
return false;
if (\filter_var($ip, \FILTER_VALIDATE_IP)) {
return \inet_pton($ip);
}
return \inet_pton($ip);
return false;
}
/**
* @return string|false
*/
private static function packHostAddress(string $host)
{
$packed = self::packIpAddress($host);
if ($packed !== false) {
return $packed;
}
// A transport reads the inet_aton() shorthand as an address too, so a
// rule and a request host that name one address have to match
// whichever spelling each of them happens to use.
return HostIdentity::numericIpv4ToBinary($host) ?? false;
}
private static function ipMatchesPrefix(string $address, string $network, int $prefix): bool

View file

@ -351,12 +351,12 @@ class RedirectMiddleware
$resolvedUri = $uriFactory->createUri((string) $resolvedUri);
}
} catch (\InvalidArgumentException $e) {
throw new BadResponseException(\sprintf('Redirect URI, %s, is invalid: %s', Psr7\DiagnosticValue::escape($location), $e->getMessage()), $request, $response, $e);
throw new BadResponseException(\sprintf('Redirect URI, %s, is invalid.', Psr7\Utils::redactUriStringForMessage($location)), $request, $response, $e);
}
// Ensure that the redirect URI is allowed based on the protocols.
if (!\in_array($resolvedUri->getScheme(), $protocols, true)) {
throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', Psr7\DiagnosticValue::escape((string) $resolvedUri), \implode(', ', $protocols)), $request, $response);
throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', Psr7\Utils::redactUriForMessage($resolvedUri), \implode(', ', $protocols)), $request, $response);
}
return $resolvedUri;

View file

@ -12,7 +12,7 @@ use Psr\Http\Message\UriInterface;
/**
* This class contains a list of built-in Guzzle request options.
*
* @see https://github.com/guzzle/guzzle/blob/8.0/docs/request-options.md
* @see https://github.com/guzzle/guzzle/blob/8.1/docs/request-options.md
*/
final class RequestOptions
{
@ -383,11 +383,12 @@ final class RequestOptions
* as a comma- or whitespace-delimited string, array of strings, or null to
* specify hosts, host-and-port pairs, IP literals, IP CIDR rules, or
* wildcard rules that should not be proxied. Domain rules are matched
* case-insensitively. Exact IP literals are normalized before matching.
* CIDR rules match IP literals only and are not port-specific. Custom
* handlers can use ProxyOptions::resolve() to apply Guzzle-compatible proxy
* selection; the built-in handlers' environment-variable fallback is not
* part of that helper.
* case-insensitively. Exact IP literals are normalized before matching,
* including the inet_aton() shorthand spellings a transport reads as an
* address. CIDR rules match IP literals only and are not port-specific.
* Custom handlers can use ProxyOptions::resolve() to apply
* Guzzle-compatible proxy selection; the built-in handlers'
* environment-variable fallback is not part of that helper.
*/
public const PROXY = 'proxy';

View file

@ -11,6 +11,7 @@ use GuzzleHttp\Handler\CurlShareHandleState;
use GuzzleHttp\Handler\CurlVersion;
use GuzzleHttp\Handler\Proxy;
use GuzzleHttp\Handler\StreamHandler;
use GuzzleHttp\Handler\StreamTlsSessionCache;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -88,8 +89,19 @@ final class Utils
$handler = self::createCurlHandler($sharingMode, $handlerOptions);
// Handler-scoped required sharing can also be satisfied by the stream
// handler's TLS session resumption (PHP 8.6+); persistent required
// sharing is cURL-only.
$streamCanShareSessions = (bool) \ini_get('allow_url_fopen') && StreamTlsSessionCache::isSupported();
if ($sharingRequired && $handler === null) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and a supported libcurl version with SSL support.');
if ($sharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new \RuntimeException('Required persistent transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and a supported libcurl version with SSL support.');
}
if (!$streamCanShareSessions) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension (curl_exec()/curl_multi_exec()) with a supported libcurl version and SSL support, or PHP 8.6+ with the OpenSSL TLS session API and the allow_url_fopen ini setting.');
}
}
if (\ini_get('allow_url_fopen')) {
@ -131,6 +143,13 @@ final class Utils
return null;
}
if ($sharingMode === TransportSharing::HANDLER_REQUIRE && !CurlShareHandleState::supportsHandlerRequireShare()) {
// Required handler sharing can also be satisfied by the stream
// handler's TLS session resumption, so a cURL install that cannot
// share is skipped instead of failing handler selection.
return null;
}
$connectionCapOptions = self::connectionCapOptions($handlerOptions);
if ($connectionCapOptions !== [] && !\function_exists('curl_multi_exec')) {
return null;