true, 'max_total_connections' => true, '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', 'gethostbyname failed', 'Unable to connect to', 'Connection refused', 'No connection could be made because the target machine actively refused it', 'connection attempt failed', 'connect() failed', 'Network is unreachable', 'No route to host', 'Host is unreachable', 'Host is down', 'Cannot connect to HTTPS server through proxy', 'Failed to enable crypto', ]; private const CONNECT_TIMEOUT_ERRORS = [ 'Connection timed out', 'Operation timed out', 'SSL: Handshake timed out', 'did not properly respond after a period of time', ]; private const NETWORK_ERRORS = [ 'SSL: Connection reset by peer', 'SSL: Broken pipe', '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 * handler never consults. */ private const DEFAULT_IDLE_TIMEOUT_MS = 60000; private array $lastHeaders = []; private ?float $lastDeadline = null; private ?\Throwable $onStatsException = null; private string $transportSharingMode; private bool $connectionCapsConfigured = false; private ?StreamTlsSessionCache $sessionCache = null; /** * Accepts an associative array of options: * * - max_host_connections: Optional positive integer or null. A non-null * value marks the handler as incompatible with enabled response * streaming; the number is not used for stream-handler admission. * - max_total_connections: Optional positive integer or null. A non-null * value marks the handler as incompatible with enabled response * streaming; the number is not used for stream-handler admission. * - transport_sharing: Optional transport sharing mode. * * The stream handler cannot cap streamed connections, so a configured cap * marker rejects enabled response streaming ("stream" => true). Accepted * transfers are buffered and hold at most one connection per in-flight * call, but overlapping buffered calls are not collectively limited. * * @param array{max_host_connections?: mixed, max_total_connections?: mixed, transport_sharing?: mixed} $options Array of options to use with the handler */ public function __construct(array $options = []) { foreach ($options as $name => $_) { if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) { throw new InvalidArgumentException(\sprintf('Invalid StreamHandler constructor option "%s".', Psr7\DiagnosticValue::escape((string) $name))); } } $this->transportSharingMode = CurlShareHandleState::normalizeMode( $options['transport_sharing'] ?? null, 'transport_sharing' ); foreach (['max_host_connections', 'max_total_connections'] as $capOption) { $value = $options[$capOption] ?? null; if ($value === null) { continue; } if (!\is_int($value) || $value < 1) { throw new InvalidArgumentException(\sprintf('%s must be a positive integer.', $capOption)); } $this->connectionCapsConfigured = true; } } /** * Sends an HTTP request. * * @param RequestInterface $request Request to send. * @param array $options Request transfer options. * * @return PromiseInterface */ public function __invoke( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] array $options ): PromiseInterface { $this->onStatsException = null; // Sleep if there is a delay specified. if (isset($options['delay'])) { \usleep((int) ($options['delay'] * 1000)); } $multiplex = $options['multiplex'] ?? null; // Multiplexing::NONE is trivially satisfied: the stream handler sends // one HTTP/1.x request per connection and never multiplexes. if (null !== $multiplex && !\in_array($multiplex, [Multiplexing::NONE, Multiplexing::EAGER, Multiplexing::WAIT, Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { throw new InvalidArgumentException(\sprintf( 'The "multiplex" option must be null or a GuzzleHttp\\Multiplexing::* constant; received %s.', \get_debug_type($multiplex) )); } if (\in_array($multiplex, [Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) { throw new RequestException('The stream handler cannot guarantee a multiplexed protocol; required multiplexing needs a cURL handler.', $request); } $protocolVersion = $request->getProtocolVersion(); if ('' === $protocolVersion) { throw new RequestException('HTTP protocol version must not be empty.', $request); } if (1 !== \preg_match('/^\d+(?:\.\d+)?$/D', $protocolVersion)) { throw new RequestException('HTTP protocol version must be a valid HTTP version number.', $request); } if ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) { throw new RequestException(sprintf('HTTP/%s is not supported by the stream handler.', $protocolVersion), $request); } if (isset($options['on_stats']) && !\is_callable($options['on_stats'])) { throw new InvalidArgumentException('on_stats must be callable'); } $startTime = isset($options['on_stats']) ? Clock::now() : null; self::rejectUnsupportedRequestOptions($request, $options); $this->rejectStreamingWithConnectionCaps($options); $this->assertTransportSharingSupported(); // The stream wrapper sends HEAD request bodies, unlike cURL's NOBODY // path, so validate their framing normally. $framing = RequestFraming::analyze($request->withoutHeader('Expect')); $request = $framing->request; try { self::assertRequestUriSupported($request, $options); $body = $framing->materialize(); if ($framing->contentLength === null && ($body !== '' || \in_array($request->getMethod(), ['PUT', 'POST'], true))) { $request = $request->withHeader('Content-Length', (string) \strlen($body)); } return $this->createResponse( $request, $options, $this->createStream($request, $options, $body), $startTime ); } catch (\InvalidArgumentException $e) { throw $e; } catch (\Exception $e) { if ($this->isOnStatsException($e)) { throw $e; } if (!$e instanceof TransferException) { $message = $e->getMessage(); $e = self::createStreamFailureException($message, $request, $e, []); } $this->invokeStats($options, $request, $startTime, null, $e); /** @var PromiseInterface */ return P\Create::rejectionFor($e); } } private function isOnStatsException(\Throwable $e): bool { if ($this->onStatsException !== $e) { return false; } $this->onStatsException = null; return true; } private static function isConnectTimeoutError(string $message): bool { foreach (self::CONNECT_TIMEOUT_ERRORS as $timeoutError) { if (Psr7\Utils::caselessContains($message, $timeoutError)) { return true; } } return false; } private static function isConnectionError(string $message): bool { foreach (self::CONNECTION_ERRORS as $connectionError) { if (Psr7\Utils::caselessContains($message, $connectionError)) { return true; } } return false; } private static function isSendError(string $message): bool { // A failed write ("Send of N bytes failed ...") implies an established connection. return Psr7\Utils::caselessContains($message, 'bytes failed with errno='); } private static function isNetworkError(string $message): bool { foreach (self::NETWORK_ERRORS as $networkError) { if (Psr7\Utils::caselessContains($message, $networkError)) { return true; } } 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, #[\SensitiveParameter] RequestInterface $request, ?float $startTime, #[\SensitiveParameter] ?ResponseInterface $response = null, #[\SensitiveParameter] ?\Throwable $error = null ): void { if (isset($options['on_stats'])) { $stats = new TransferStats($request, $response, Clock::now() - $startTime, $error, []); try { ($options['on_stats'])($stats); } catch (\Throwable $e) { $this->onStatsException = $e; throw $e; } } } /** * @param resource $stream * * @return PromiseInterface */ private function createResponse( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] array $options, $stream, ?float $startTime ): PromiseInterface { $hdrs = $this->lastHeaders; $this->lastHeaders = []; $deadline = $this->lastDeadline; $this->lastDeadline = null; try { [$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($hdrs); } catch (\Throwable $e) { return $this->rejectResponseCreation($options, $request, $startTime, $e); } $canHaveBody = HeaderProcessor::responseCanHaveBody($request->getMethod(), $status); $framingFailure = null; $declaredLength = null; try { $declaredLength = HeaderProcessor::validateResponseFraming($request->getMethod(), $status, $headers); if (empty($options['stream'])) { HeaderProcessor::assertContentLengthWithinPlatformLimit($declaredLength); } } catch (\RuntimeException $e) { $framingFailure = $e; } if ($framingFailure === null) { try { $headers = $this->decodeChunkedResponse($headers, $stream, $canHaveBody); } catch (\RuntimeException $e) { $framingFailure = $e; } } $streamFactory = self::requireStreamFactory($options[RequestOptions::STREAM_FACTORY] ?? new Psr7\HttpFactory()); $responseFactory = self::requireResponseFactory($options[RequestOptions::RESPONSE_FACTORY] ?? new Psr7\HttpFactory()); // Wrap the transport resource with the configured stream factory, and // decorate buffered transfers with the deadline source before optional // content decoding layers an InflateStream on top, so every read that // pulls from the transport observes the deadline. $resource = $stream; $stream = $streamFactory->createStreamFromResource($stream); if ($framingFailure === null && $deadline !== null && empty($options['stream'])) { $stream = self::createDeadlineSource($stream, $resource, $deadline, $options); } $encodedBody = null; if ($framingFailure === null) { [$stream, $headers, $encodedBody] = self::checkDecode($options, $headers, $stream, $declaredLength); } $sink = $framingFailure !== null || !$canHaveBody ? $streamFactory->createStream('') : $this->createSink($stream, $options); try { $response = $responseFactory->createResponse($status, $reason ?? '')->withProtocolVersion($ver); foreach ($headers as $name => $value) { $response = $response->withAddedHeader((string) $name, $value); } $response = $response->withBody($sink); } catch (\Throwable $e) { return $this->rejectResponseCreation($options, $request, $startTime, $e); } if ($framingFailure !== null) { try { $stream->close(); } catch (\Exception $e) { // Best-effort release; a failing transport close must not // mask the framing rejection. } $reason = $framingFailure instanceof \OverflowException ? new ResponseException($framingFailure->getMessage(), $request, $response, $framingFailure) : new ResponseTransferException($framingFailure->getMessage(), $request, $response, $framingFailure); $this->invokeStats($options, $request, $startTime, $response, $reason); /** @var PromiseInterface */ return P\Create::rejectionFor($reason); } // The header phase inside fopen() can only bound the time between // packets, so a header block that trickled in past the deadline is // rejected here, once it is complete. The transport is closed so a // streamed response cannot hold the connection open through the // rejection. if ($deadline !== null && Clock::now() >= $deadline) { try { $stream->close(); } catch (\Exception $e) { // Best-effort release; a failing transport close must not // mask the timeout rejection. } $reason = new ResponseTimeoutException('Timed out while receiving the response headers', $request, $response); $this->invokeStats($options, $request, $startTime, $response, $reason); /** @var PromiseInterface */ return P\Create::rejectionFor($reason); } if (isset($options['on_headers'])) { try { $options['on_headers']($response, $request); } catch (\Throwable $e) { $reason = new ResponseException('An error was encountered during the on_headers event', $request, $response, $e); $this->invokeStats($options, $request, $startTime, $response, $reason); /** @var PromiseInterface */ return P\Create::rejectionFor($reason); } } if (!$canHaveBody) { // RFC 9110 / RFC 9112 section 6.3: octets after the header section // of a HEAD, 1xx, 204, 304, or CONNECT-2xx response are not part // of this response's body. Never read them, and release the // transport as soon as the headers are complete. try { $stream->close(); } catch (\Exception $e) { // Best-effort release; a failing transport close must not fail // a fully received no-content response. } } elseif ($sink !== $stream) { try { $this->drain($request, $response, $stream, $sink, $declaredLength, $encodedBody); } catch (ResponseException $e) { $this->invokeStats($options, $request, $startTime, $response, $e); /** @var PromiseInterface */ return P\Create::rejectionFor($e); } } $this->invokeStats($options, $request, $startTime, $response, null); /** @var PromiseInterface */ return P\Create::promiseFor($response); } /** * @return PromiseInterface */ private function rejectResponseCreation( #[\SensitiveParameter] array $options, #[\SensitiveParameter] RequestInterface $request, ?float $startTime, #[\SensitiveParameter] \Throwable $previous ): PromiseInterface { $reason = new RequestException( 'An error was encountered while creating the response', $request, 0, $previous ); $this->invokeStats($options, $request, $startTime, null, $reason); /** @var PromiseInterface */ return P\Create::rejectionFor($reason); } private function createSink( StreamInterface $stream, #[\SensitiveParameter] array $options ): StreamInterface { if (!empty($options['stream'])) { return $stream; } $streamFactory = self::requireStreamFactory($options[RequestOptions::STREAM_FACTORY] ?? new Psr7\HttpFactory()); $hasSink = isset($options['sink']); $sink = $hasSink ? $options['sink'] : Psr7\Utils::tryFopen('php://temp', 'r+'); if ($hasSink && \is_resource($sink)) { return self::streamForResourceSink(Psr7\Utils::streamFor($sink)); } if (\is_string($sink)) { return new Psr7\LazyOpenStream($sink, 'w+'); } if (!\is_resource($sink)) { return Psr7\Utils::streamFor($sink); } return $streamFactory->createStreamFromResource($sink); } /** * Decorates a caller-owned sink stream so that closing the response body * detaches Guzzle's wrapper without closing the original PHP resource. */ private static function streamForResourceSink(StreamInterface $stream): StreamInterface { return Psr7\FnStream::decorate($stream, [ 'close' => static function () use ($stream): void { $stream->detach(); }, ]); } /** * @param mixed $factory */ private static function requireStreamFactory($factory): StreamFactoryInterface { if (!$factory instanceof StreamFactoryInterface) { throw new InvalidArgumentException(\sprintf( '%s must be an instance of %s', RequestOptions::STREAM_FACTORY, StreamFactoryInterface::class )); } return $factory; } /** * @param mixed $factory */ private static function requireResponseFactory($factory): ResponseFactoryInterface { if (!$factory instanceof ResponseFactoryInterface) { throw new InvalidArgumentException(\sprintf( '%s must be an instance of %s', RequestOptions::RESPONSE_FACTORY, ResponseFactoryInterface::class )); } return $factory; } /** * Applies the HTTP wrapper's normal chunked-response handling. * * @param array $headers * @param resource $stream * @param bool $decodeBody Whether to attach the dechunk filter * * @return array * * @throws \RuntimeException when the dechunk filter cannot be attached */ private function decodeChunkedResponse(array $headers, $stream, bool $decodeBody): array { $decodedHeaders = $headers; $decode = false; foreach ($headers as $name => $values) { if (!Psr7\Utils::caselessEquals((string) $name, 'Transfer-Encoding')) { continue; } $remaining = []; foreach ($values as $value) { // Match PHP's legacy auto_decode prefix check so moving the // filter does not change which responses are dechunked. if (Psr7\Utils::caselessEquals(\substr($value, 0, 7), 'chunked')) { $decode = true; } else { $remaining[] = $value; } } if ($remaining === []) { unset($decodedHeaders[$name]); } else { $decodedHeaders[$name] = $remaining; } } if (!$decode) { return $headers; } if ($decodeBody) { $this->createResource(static function () use ($stream) { return \stream_filter_append($stream, 'dechunk', \STREAM_FILTER_READ); }); } return $decodedHeaders; } /** * @return array{0: StreamInterface, 1: array, 2: ?EncodedBodyStream} */ private static function checkDecode( #[\SensitiveParameter] array $options, array $headers, StreamInterface $stream, ?string $declaredLength ): array { $encodedBody = null; // Automatically decode responses when instructed. if (isset($options['decode_content']) && $options['decode_content'] !== false) { $normalizedKeys = Utils::normalizeHeaderKeys($headers); if (isset($normalizedKeys['content-encoding'])) { $encoding = $headers[$normalizedKeys['content-encoding']]; if ($encoding[0] === 'gzip' || $encoding[0] === 'deflate') { if (empty($options['stream']) && $declaredLength !== null && $declaredLength !== '0') { $encodedBody = new EncodedBodyStream($stream, $declaredLength); $stream = $encodedBody; } $stream = new Psr7\InflateStream($stream); $headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']]; // Remove content-encoding header unset($headers[$normalizedKeys['content-encoding']]); // The decoded length cannot be known without inflating the // stream, so keep the original length for inspection and // drop the now-unknown Content-Length header. $encodedContentLength = HeaderProcessor::removeHeader('Content-Length', $headers); if ($encodedContentLength !== []) { $headers['x-encoded-content-length'] = $encodedContentLength; } } } } return [$stream, $headers, $encodedBody]; } /** * Drains the source stream into the "sink" client option. * * @throws \RuntimeException when the sink option is invalid. */ private function drain( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] ResponseInterface $response, StreamInterface $source, StreamInterface $sink, ?string $declaredResponseBodyLength, ?EncodedBodyStream $encodedBody = null ): StreamInterface { try { // Buffered paths reject unrepresentable lengths before draining. $declaredLength = HeaderProcessor::contentLengthToInt($declaredResponseBodyLength); $declaredLength = $declaredLength !== null && $declaredLength > 0 ? $declaredLength : null; $copyLimit = $encodedBody === null ? $declaredLength ?? -1 : -1; try { $target = $this->createResponseSink($request, $response, $sink); // If a content-length header is provided, then stop reading once // that number of bytes has been read. This can prevent infinitely // reading from a stream when dealing with servers that do not // honor Connection: Close headers. $copied = Psr7\Utils::copyToStream($source, $target, $copyLimit); } catch (ResponseException $e) { throw $e; } catch (TimeoutException $e) { throw new ResponseTimeoutException( 'Timed out while transferring the response body', $request, $response, $e ); } catch (\OverflowException $e) { throw new ResponseException($e->getMessage(), $request, $response, $e); } catch (\Exception $e) { // Any other response-body transfer failure surfaces as a // ResponseTransferException carrying the response. throw new ResponseTransferException( $e->getMessage() !== '' ? $e->getMessage() : 'Failed while transferring the response body', $request, $response, $e ); } $receivedLength = $encodedBody !== null ? $encodedBody->getBytesRead() : $copied; if ($declaredLength !== null && $receivedLength < $declaredLength) { throw new ResponseTransferException( 'Response body ended before the declared Content-Length was reached', $request, $response ); } try { if ($sink->isSeekable()) { $sink->rewind(); } } catch (\Exception $e) { throw new ResponseException( $e->getMessage() !== '' ? $e->getMessage() : 'Failed to rewind the response body', $request, $response, $e ); } return $sink; } finally { try { $source->close(); } catch (\Exception $e) { // Best-effort cleanup after the response body has been received. } } } private function createResponseSink( RequestInterface $request, ResponseInterface $response, StreamInterface $sink ): StreamInterface { return Psr7\FnStream::decorate($sink, [ 'close' => static function (): void { }, 'write' => static function (string $data) use ($request, $response, $sink): int { try { $written = $sink->write($data); } catch (TimeoutException $e) { throw new ResponseException( 'Timed out while writing the response body', $request, $response, $e ); } catch (\Exception $e) { throw new ResponseException( $e->getMessage() !== '' ? $e->getMessage() : 'Failed to write the response body', $request, $response, $e ); } if ($written <= 0) { throw new ResponseException('Unable to write to stream', $request, $response); } return $written; }, 'getMetadata' => static function (?string $key = null) use ($sink) { // Force timed_out to false so Utils::writeAll() can't reclassify a sink-write // failure as a transport timeout. Sink write failures are ResponseException; // source-read timeouts are ResponseTimeoutException. if ($key === 'timed_out') { return false; } return $sink->getMetadata($key); }, ]); } /** * Decorates the transport stream so each buffered-body read observes the * remaining wall-clock budget of the "timeout" request option, bounded * by the "read_timeout" idle timeout when that is shorter. The transport * is switched to non-blocking mode because filtered reads (chunked and * compressed responses) otherwise block until a full buffer arrives, * which would keep the deadline from being enforced between small pieces * of data. * * @param resource $resource */ private static function createDeadlineSource( StreamInterface $stream, $resource, float $deadline, #[\SensitiveParameter] array $options ): StreamInterface { $idleTimeout = isset($options['read_timeout']) ? Timeout::toMilliseconds($options['read_timeout'], 'read_timeout') : self::DEFAULT_IDLE_TIMEOUT_MS; \stream_set_blocking($resource, false); return new DeadlineSourceStream($stream, $deadline, $idleTimeout > 0 ? $idleTimeout / 1000 : null); } /** * Create a resource and check to ensure it was created successfully * * @param callable(): (resource|false) $callback Callable that returns a stream resource, or false when resource creation fails. * * @return resource * * @throws \RuntimeException when the callback returns false or resource creation emits an error. */ 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[] = [ 'message' => $msg, 'file' => $file, 'line' => $line, ]; return true; }); try { $resource = $callback(); } finally { \restore_error_handler(); } if (!$resource) { $details = []; foreach ($errors as $err) { foreach ($err as $key => $value) { $rendered = $key === 'message' && $diagnosticUri !== null ? UriDiagnostic::redactInMessage((string) $value, $diagnosticUri) : Psr7\DiagnosticValue::escape((string) $value); $details[] = \sprintf('[%s] %s', $key, $rendered); } } $message = 'Error creating resource'; if ($details !== []) { $message .= ': '.\implode('; ', $details); } throw new \RuntimeException($message); } return $resource; } /** * @return resource */ private function createStream( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] 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' && !$request->hasHeader('Connection') ) { $request = $request->withHeader('Connection', 'close'); } // Ensure SSL is verified by default if (!isset($options['verify'])) { $options['verify'] = true; } $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'); } $idleTimeout = isset($options['read_timeout']) ? Timeout::toMilliseconds($options['read_timeout'], 'read_timeout') : self::DEFAULT_IDLE_TIMEOUT_MS; $timeout = isset($options['timeout']) ? Timeout::toMilliseconds($options['timeout'], 'timeout') : 0; self::assertTlsVersionRangeForOptions($request, $options); $this->applyHandlerOptions($request, $context, $options, $params); // Resolve the proxy unconditionally (option first, then environment), // so an env-configured proxy applies even when no proxy option is set. $this->applyProxy($request, $context, $options['proxy'] ?? null); if (isset($options['stream_context'])) { $streamContext = $options['stream_context']; if (!\is_array($streamContext)) { throw new InvalidArgumentException('stream_context must be an array'); } 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. if ($timeout > 0 && ($idleTimeout <= 0 || $timeout < $idleTimeout)) { $context['http']['timeout'] = $timeout / 1000; } elseif ($idleTimeout > 0) { $context['http']['timeout'] = $idleTimeout / 1000; } else { $context['http']['timeout'] = -1; } $streamErrorCodes = []; $captureStreamErrors = true; $this->addStructuredStreamErrorHandler($context, $streamErrorCodes, $captureStreamErrors); $uri = $this->resolveHost($request, $options); $contextResource = $this->createResource( static function () use ($context, $params) { return \stream_context_create($context, $params); } ); 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 // configuration cannot leak into a From header; the wrapper // cannot omit the header, so a configured ini sends it empty. $iniFrom = \function_exists('ini_set') ? \ini_set('from', '') : false; try { $resource = @\fopen((string) $uri, 'r', false, $contextResource); } finally { if ($iniFrom !== false) { \ini_set('from', $iniFrom); } } // PHP 8.5 deprecates the local $http_response_header variable. if (function_exists('http_get_last_response_headers')) { $http_response_header = \http_get_last_response_headers(); } $this->lastHeaders = $http_response_header ?? []; if (false === $resource) { return false; } // Arm reads with the idle timeout, replacing the context // value the deadline may have tightened; -1 disables it. if ($idleTimeout > 0) { $sec = \intdiv($idleTimeout, 1000); $usec = ($idleTimeout % 1000) * 1000; \stream_set_timeout($resource, $sec, $usec); } else { \stream_set_timeout($resource, -1); } 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( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] array $options ): void { $uri = $request->getUri(); $scheme = $uri->getScheme(); if ($scheme === '') { throw new RequestException( 'URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request ); } if (!\in_array($scheme, ['http', 'https'], true)) { throw new RequestException(\sprintf("The scheme '%s' is not supported.", Psr7\DiagnosticValue::escape($scheme)), $request); } $protocols = Utils::normalizeProtocols($options['protocols'] ?? ['http', 'https']); if (!\in_array($scheme, $protocols, true)) { throw new RequestException(\sprintf('The scheme "%s" is not allowed by the protocols request option.', Psr7\DiagnosticValue::escape($scheme)), $request); } if ($uri->getHost() === '') { throw new RequestException( 'URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request ); } HostValidator::assertRequestHost($request); } private function applyHandlerOptions( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] array &$context, #[\SensitiveParameter] array $options, array &$params ): void { foreach ($options as $key => $value) { if ($key === 'crypto_method') { $this->applyCryptoMethodOption($context, $value); } elseif ($key === 'crypto_method_max') { $this->applyCryptoMethodMaxOption($context, $value); } elseif ($key === 'verify') { $this->applyVerifyOption($context, $value); } elseif ($key === 'cert') { $this->applyCertOption($context, $value); } elseif ($key === 'cert_type') { $this->applyCertTypeOption($value); } elseif ($key === 'ssl_key') { $this->applySslKeyOption($context, $value); } elseif ($key === 'ssl_key_type') { $this->applySslKeyTypeOption($value); } elseif ($key === 'progress') { $this->applyProgressOption($value, $params); } elseif ($key === 'debug') { $this->applyDebugOption($request, $value, $params); } } } private function resolveHost( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] array $options ): UriInterface { $uri = $request->getUri(); $host = $uri->getHost(); // Fold a numeric IPv4 spelling to the dotted quad libcurl connects // to, rather than leaving it to the platform resolver: macOS reads // the zero-padded 0177 as decimal 177 where glibc, musl and FreeBSD // read octal 127. The Host header is serialized from the request and // stays as written; the TLS peer name follows the same fold. $canonicalHost = self::canonicalConnectionHost($host); if ($canonicalHost !== $host) { $uri = $uri->withHost($canonicalHost); $host = $canonicalHost; } $hostForIpCheck = \str_starts_with($host, '[') && \str_ends_with($host, ']') ? \substr($host, 1, -1) : $host; if (isset($options['force_ip_resolve']) && !\filter_var($hostForIpCheck, \FILTER_VALIDATE_IP)) { if ('v4' === $options['force_ip_resolve']) { $records = \dns_get_record($uri->getHost(), \DNS_A); if (false === $records || !isset($records[0]['ip'])) { throw new ConnectException(\sprintf("Could not resolve IPv4 address for host '%s'", Psr7\DiagnosticValue::escape($uri->getHost())), $request); } return $uri->withHost($records[0]['ip']); } if ('v6' === $options['force_ip_resolve']) { $records = \dns_get_record($uri->getHost(), \DNS_AAAA); if (false === $records || !isset($records[0]['ipv6'])) { throw new ConnectException(\sprintf("Could not resolve IPv6 address for host '%s'", Psr7\DiagnosticValue::escape($uri->getHost())), $request); } return $uri->withHost('['.$records[0]['ipv6'].']'); } } return $uri; } /** * Returns a numeric IPv4 spelling folded to the dotted quad libcurl's * ipv4_normalize() produces, and every other host unchanged. */ private static function canonicalConnectionHost(string $host): string { $binary = HostIdentity::numericIpv4ToBinary($host); if ($binary === null) { return $host; } return (string) \inet_ntop($binary); } private function addDefaultTlsMinimum(RequestInterface $request, array &$context): void { if ('https' !== $request->getUri()->getScheme() || !isset($context['ssl']) || !\is_array($context['ssl'])) { return; } if ( \array_key_exists('crypto_method', $context['ssl']) || \array_key_exists('min_proto_version', $context['ssl']) ) { return; } $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, string $body ): array { $headers = ''; foreach ($request->getHeaders() as $name => $value) { // The first-class Proxy-Authorization field never enters the // stream context header block: PHP streams have no proxy-only // header channel, so applyProxy() rejects the field when a proxy // is selected and it is omitted on direct and bypassed routes. if (Psr7\Utils::caselessEquals((string) $name, 'Proxy-Authorization')) { continue; } foreach ($value as $val) { $headers .= "$name: $val\r\n"; } } $context = [ 'http' => [ 'auto_decode' => false, 'method' => $request->getMethod(), 'header' => $headers, 'protocol_version' => $request->getProtocolVersion(), 'ignore_errors' => true, 'follow_location' => 0, ], 'ssl' => [ 'peer_name' => self::canonicalConnectionHost($request->getUri()->getHost()), ], ]; // An empty context user_agent stops the HTTP stream wrapper from // appending a User-Agent header from the user_agent ini setting, so a // request without the header sends none, like the cURL handlers. if (!$request->hasHeader('User-Agent')) { $context['http']['user_agent'] = ''; } if ('' !== $body) { $context['http']['content'] = $body; // Prevent the HTTP handler from adding a Content-Type header. if (!$request->hasHeader('Content-Type')) { $context['http']['header'] .= "Content-Type:\r\n"; } } $context['http']['header'] = \rtrim($context['http']['header'], "\r\n"); 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, #[\SensitiveParameter] array $options ): void { if ( \array_key_exists('curl', $options) && $options['curl'] !== null && $options['curl'] !== [] ) { throw new InvalidArgumentException('Passing the "curl" request option to the stream handler is not supported because the stream handler ignores cURL options.'); } if (\array_key_exists('expect', $options) && $options['expect'] !== false && $request->hasHeader('Expect')) { throw new InvalidArgumentException('Passing the "expect" request option to the stream handler is not supported when it adds an Expect header because the stream handler does not support Expect: 100-Continue.'); } if (isset($options['on_trailers'])) { throw new InvalidArgumentException('Passing the "on_trailers" request option to the stream handler is not supported because the stream handler cannot observe trailers.'); } } private function rejectStreamingWithConnectionCaps( #[\SensitiveParameter] array $options ): void { if ($this->connectionCapsConfigured && !empty($options['stream'])) { throw new InvalidArgumentException('Enabling the "stream" request option on a stream handler configured with the "max_host_connections" or "max_total_connections" option is not supported because streamed connections cannot be capped.'); } } private static function rejectConflictingStreamContextOptions( #[\SensitiveParameter] array $streamContext ): void { $conflictingOptions = self::conflictingStreamContextOptions(); foreach ($streamContext as $wrapper => $contextOptions) { if (!\is_string($wrapper) || !isset($conflictingOptions[$wrapper]) || !\is_array($contextOptions)) { continue; } foreach ($contextOptions as $option => $_) { if (!\is_string($option) || !\array_key_exists($option, $conflictingOptions[$wrapper])) { continue; } $replacement = $conflictingOptions[$wrapper][$option]; throw new InvalidArgumentException(\sprintf( 'Passing stream_context.%s.%s in the "stream_context" request option is not supported because it conflicts with Guzzle-managed request handling. Use %s instead.', Psr7\DiagnosticValue::escape($wrapper), Psr7\DiagnosticValue::escape($option), $replacement )); } } } private static function rejectUnsupportedStreamContextOptions( #[\SensitiveParameter] array $streamContext ): void { $unsupportedOptions = self::unsupportedStreamContextOptions($streamContext); if ($unsupportedOptions === []) { return; } throw new InvalidArgumentException(\sprintf( 'Passing PHP stream context options outside the built-in stream handler allow-list to the "stream_context" request option is not supported. Unsupported option%s: %s.', \count($unsupportedOptions) === 1 ? '' : 's', \implode(', ', $unsupportedOptions) )); } /** * @return string[] */ private static function unsupportedStreamContextOptions(array $streamContext): array { $supportedOptions = self::supportedStreamContextOptions(); $conflictingOptions = self::conflictingStreamContextOptions(); $unsupportedOptions = []; foreach ($streamContext as $wrapper => $contextOptions) { if (!\is_string($wrapper) || !isset($supportedOptions[$wrapper])) { if (\is_array($contextOptions)) { foreach ($contextOptions as $option => $_) { if (\is_string($wrapper) && \is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) { continue; } $unsupportedOptions[] = \sprintf('stream_context.%s.%s', Psr7\DiagnosticValue::escape((string) $wrapper), Psr7\DiagnosticValue::escape((string) $option)); } } else { $unsupportedOptions[] = \sprintf('stream_context.%s', Psr7\DiagnosticValue::escape((string) $wrapper)); } continue; } if (!\is_array($contextOptions)) { $unsupportedOptions[] = \sprintf('stream_context.%s', Psr7\DiagnosticValue::escape($wrapper)); continue; } foreach ($contextOptions as $option => $_) { if (\is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) { continue; } if (!\is_string($option) || !\array_key_exists($option, $supportedOptions[$wrapper])) { $unsupportedOptions[] = \sprintf('stream_context.%s.%s', Psr7\DiagnosticValue::escape($wrapper), Psr7\DiagnosticValue::escape((string) $option)); } } } return $unsupportedOptions; } /** * @return array> */ private static function supportedStreamContextOptions(): array { return [ 'http' => [ 'request_fulluri' => true, ], 'socket' => [ 'bindto' => true, 'tcp_nodelay' => true, ], 'ssl' => [ 'SNI_enabled' => true, 'capture_peer_cert' => true, 'capture_peer_cert_chain' => true, 'ciphers' => true, 'disable_compression' => true, 'no_ticket' => true, 'peer_fingerprint' => true, 'security_level' => true, 'verify_depth' => true, ], ]; } /** * @return array> */ private static function conflictingStreamContextOptions(): array { return [ 'http' => [ 'auto_decode' => 'Guzzle response framing and decoding', 'content' => 'the request body', 'follow_location' => 'the "allow_redirects" request option', 'header' => 'the request headers', 'max_redirects' => 'the "allow_redirects" request option', 'method' => 'the request method', 'protocol_version' => 'the request protocol version', 'proxy' => 'the "proxy" request option', 'timeout' => 'the "timeout" and "read_timeout" request options', ], 'ssl' => [ 'allow_self_signed' => 'the "verify" request option', 'cafile' => 'the "verify" request option', 'capath' => 'the "verify" request option', 'crypto_method' => 'the "crypto_method" request option', 'local_cert' => 'the "cert" request option', 'local_pk' => 'the "ssl_key" request option', 'max_proto_version' => 'the "crypto_method_max" request option', 'min_proto_version' => 'the "crypto_method" request option', 'passphrase' => 'the "cert" or "ssl_key" request option', 'peer_name' => 'the request URI', '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', ], ]; } /** * @param mixed $value as passed via Request transfer options. * * @return array{0: string, 1: string|null} */ private static function normalizeTlsFileOption( string $option, #[\SensitiveParameter] $value ): array { $passphrase = null; if (\is_array($value)) { if (!isset($value[0]) || !\is_string($value[0])) { throw new InvalidArgumentException(\sprintf('Invalid %s request option', $option)); } if (isset($value[1])) { if (!\is_string($value[1])) { throw new InvalidArgumentException(\sprintf('Invalid %s request option', $option)); } $passphrase = $value[1]; } $value = $value[0]; } if (!\is_string($value)) { throw new InvalidArgumentException(\sprintf('Invalid %s request option', $option)); } return [$value, $passphrase]; } private static function setTlsPassphrase( #[\SensitiveParameter] array &$options, #[\SensitiveParameter] ?string $passphrase, string $option ): void { if ($passphrase === null) { return; } if (isset($options['ssl']['passphrase']) && $options['ssl']['passphrase'] !== $passphrase) { throw new InvalidArgumentException(\sprintf('Cannot use different passphrases for cert and ssl_key with the stream handler; %s conflicts with an existing TLS passphrase.', $option)); } $options['ssl']['passphrase'] = $passphrase; } /** * @param mixed $value as passed via Request transfer options. */ private static function assertStreamTlsType(string $option, $value): void { if (!\is_string($value) || $value === '') { throw new InvalidArgumentException(\sprintf('%s must be a non-empty string', $option)); } if (Psr7\Utils::asciiToUpper($value) !== 'PEM') { throw new InvalidArgumentException(\sprintf('The stream handler only supports "PEM" for the %s request option.', $option)); } } /** * @param mixed $value as passed via Request transfer options. */ private function applyProxy( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] array &$context, #[\SensitiveParameter] $value ): void { $proxy = ProxyEnv::resolveProxySelection($request->getUri(), $value); $proxyUri = $proxy->getProxy(); if ($proxyUri === null) { return; } // Validate the whole proxy authority up front (ProxyOptions leans on // Psr7\Rfc3986), so a malformed proxy fails the same way on every // handler. PHP's HTTP stream wrapper can only carry HTTP over a // TCP-family socket, so reject any scheme it can never execute instead // of letting PHP fail with a misleading "unable to find the socket // transport" error. The supported set is a scheme-less value, http, or // a TCP-family transport (tcp://, ssl://, tls://, tlsv1.*) the build has. $scheme = ProxyOptions::proxyScheme($proxyUri); if ($scheme === 'https') { throw new InvalidArgumentException('HTTPS proxies are not supported by the stream handler.'); } if (\in_array($scheme, ['socks4', 'socks4a', 'socks5', 'socks5h'], true)) { throw new InvalidArgumentException('SOCKS proxies are not supported by the stream handler.'); } // Only http or a TCP-family raw transport (tcp, ssl, tls, tlsv1.*) can // carry HTTP through the stream wrapper; udp/unix/udg/ftp/ws and typos // cannot on any build, so reject them as a caller error rather than // install an unusable proxy. if ($scheme !== 'http' && !self::isRawTransportName($scheme)) { throw new InvalidArgumentException(\sprintf('The "%s" proxy scheme is not supported by the stream handler.', Psr7\DiagnosticValue::escape($scheme))); } // A recognized SSL/TLS transport may still be absent from this build // (tls:// without OpenSSL, tlsv1.3:// on OpenSSL < 1.1.1); that is // build-specific, so it is a RequestException, distinct from the caller // error above. http maps to tcp and tcp is always registered, so only // the SSL/TLS family reaches the stream_get_transports() lookup. if (!\in_array($scheme, ['http', 'tcp'], true) && !\in_array($scheme, \stream_get_transports(), true)) { throw new RequestException(\sprintf('The "%s" proxy transport is not available in this PHP build.', Psr7\DiagnosticValue::escape($scheme)), $request); } $parsed = $this->parseProxy($proxyUri, $scheme); // PHP streams do not expose separate proxy and origin header lists; // the wrapper's CONNECT handling copies one textual match out of a // generic user-header buffer and forwards the rest to the tunneled // origin, so arbitrary first-class Proxy-Authorization values cannot // be routed safely. Fail closed before any stream is created. if ($request->hasHeader('Proxy-Authorization')) { throw new InvalidArgumentException('Proxy-Authorization request headers are not supported through the stream handler; configure credentials in the proxy URI or use a cURL handler.'); } $context['http']['proxy'] = $parsed['proxy']; if ($parsed['auth']) { if (!isset($context['http']['header'])) { $context['http']['header'] = ''; } $context['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}"; } } /** * Whether the scheme is a TCP-family transport name that can carry HTTP * for a proxy (tcp, ssl, tls, tlsv1.*), regardless of build support. */ private static function isRawTransportName(string $scheme): bool { return \in_array($scheme, ['tcp', 'ssl', 'tls'], true) || \preg_match('/^tlsv\d+(?:\.\d+)?$/D', $scheme) === 1; } /** * Parses the given proxy URL to make it compatible with the format PHP's * stream context expects. */ private function parseProxy(string $url, string $scheme): array { // applyProxy() has already validated the scheme, so only an http // proxy needs translating to the tcp:// form the wrapper expects; a // port-less proxy defaults to 1080 to match libcurl's HTTP default. if ($scheme !== 'http') { return [ 'proxy' => $url, 'auth' => null, ]; } $parsed = \parse_url(\strpos($url, '://') === false ? 'http://'.$url : $url); if (\is_array($parsed) && isset($parsed['host'])) { $port = $parsed['port'] ?? 1080; $user = $parsed['user'] ?? ''; $pass = $parsed['pass'] ?? ''; $auth = ($user !== '' || $pass !== '') ? 'Basic '.\base64_encode("{$user}:{$pass}") : null; return [ 'proxy' => "tcp://{$parsed['host']}:{$port}", 'auth' => $auth, ]; } return [ 'proxy' => $url, 'auth' => null, ]; } /** * @param mixed $value as passed via Request transfer options. */ private function applyCryptoMethodOption( #[\SensitiveParameter] array &$context, $value ): void { $context['ssl']['min_proto_version'] = TlsVersion::streamProtocolVersion('crypto_method', $value); } /** * @param mixed $value as passed via Request transfer options. */ private function applyCryptoMethodMaxOption( #[\SensitiveParameter] array &$context, $value ): void { $context['ssl']['max_proto_version'] = TlsVersion::streamProtocolVersion('crypto_method_max', $value); } private static function assertTlsVersionRangeForOptions( #[\SensitiveParameter] RequestInterface $request, #[\SensitiveParameter] array $options ): void { if (!isset($options['crypto_method_max'])) { return; } $cryptoMethod = $options['crypto_method'] ?? null; if ($cryptoMethod === null && 'https' === $request->getUri()->getScheme()) { $cryptoMethod = \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; } TlsVersion::assertRange($cryptoMethod, $options['crypto_method_max']); } /** * @param mixed $value as passed via Request transfer options. */ private function applyVerifyOption( #[\SensitiveParameter] array &$context, $value ): void { if ($value === false) { $context['ssl']['verify_peer'] = false; $context['ssl']['verify_peer_name'] = false; return; } if (\is_string($value)) { $context['ssl']['cafile'] = $value; if (!\file_exists($value)) { throw new \RuntimeException(\sprintf('SSL CA bundle not found: %s', Psr7\DiagnosticValue::escape($value))); } } elseif ($value !== true) { throw new InvalidArgumentException('Invalid verify request option'); } $context['ssl']['verify_peer'] = true; $context['ssl']['verify_peer_name'] = true; $context['ssl']['allow_self_signed'] = false; } /** * @param mixed $value as passed via Request transfer options. */ private function applyCertOption( #[\SensitiveParameter] array &$context, #[\SensitiveParameter] $value ): void { [$value, $passphrase] = self::normalizeTlsFileOption('cert', $value); if (!\file_exists($value)) { throw new \RuntimeException(\sprintf('SSL certificate not found: %s', Psr7\DiagnosticValue::escape($value))); } self::setTlsPassphrase($context, $passphrase, 'cert'); $context['ssl']['local_cert'] = $value; } /** * @param mixed $value as passed via Request transfer options. */ private function applyCertTypeOption($value): void { self::assertStreamTlsType('cert_type', $value); } /** * @param mixed $value as passed via Request transfer options. */ private function applySslKeyOption( #[\SensitiveParameter] array &$context, #[\SensitiveParameter] $value ): void { [$value, $passphrase] = self::normalizeTlsFileOption('ssl_key', $value); if (!\file_exists($value)) { throw new \RuntimeException(\sprintf('SSL private key not found: %s', Psr7\DiagnosticValue::escape($value))); } self::setTlsPassphrase($context, $passphrase, 'ssl_key'); $context['ssl']['local_pk'] = $value; } /** * @param mixed $value as passed via Request transfer options. */ private function applySslKeyTypeOption($value): void { self::assertStreamTlsType('ssl_key_type', $value); } /** * @param mixed $value as passed via Request transfer options. */ private function applyProgressOption($value, array &$params): void { if (!\is_callable($value)) { throw new InvalidArgumentException('progress client option must be callable'); } self::addNotification( $params, static function ($code, $a, $b, $c, $transferred, $total) use ($value): void { if ($code == \STREAM_NOTIFY_PROGRESS) { // The upload progress cannot be determined. Use 0 for cURL compatibility: // https://curl.se/libcurl/c/CURLOPT_PROGRESSFUNCTION.html $value( TransferByteCounter::progressValueToInt($total), TransferByteCounter::progressValueToInt($transferred), 0, 0 ); } } ); } /** * @param mixed $value as passed via Request transfer options. */ private function applyDebugOption( #[\SensitiveParameter] RequestInterface $request, $value, array &$params ): void { if ($value === false) { return; } static $map = [ \STREAM_NOTIFY_CONNECT => 'CONNECT', \STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED', \STREAM_NOTIFY_AUTH_RESULT => 'AUTH_RESULT', \STREAM_NOTIFY_MIME_TYPE_IS => 'MIME_TYPE_IS', \STREAM_NOTIFY_FILE_SIZE_IS => 'FILE_SIZE_IS', \STREAM_NOTIFY_REDIRECTED => 'REDIRECTED', \STREAM_NOTIFY_PROGRESS => 'PROGRESS', \STREAM_NOTIFY_FAILURE => 'FAILURE', \STREAM_NOTIFY_COMPLETED => 'COMPLETED', \STREAM_NOTIFY_RESOLVE => 'RESOLVE', ]; static $args = ['severity', 'message', 'message_code', 'bytes_transferred', 'bytes_max']; $value = Utils::debugResource($value); $ident = $request->getMethod().' '.$request->getUri()->withFragment(''); self::addNotification( $params, static function (int $code, ...$passed) use ($ident, $value, $map, $args): void { \fprintf($value, '<%s> [%s] ', $ident, $map[$code]); foreach (\array_filter($passed) as $i => $v) { \fwrite($value, $args[$i].': "'.$v.'" '); } \fwrite($value, "\n"); } ); } private static function addNotification(array &$params, callable $notify): void { // Wrap the existing function if needed. if (!isset($params['notification'])) { $params['notification'] = $notify; } else { $params['notification'] = self::callArray([ $params['notification'], $notify, ]); } } private static function callArray(array $functions): callable { return static function (...$args) use ($functions): void { foreach ($functions as $fn) { $fn(...$args); } }; } }