2026-09-22 16:43:47 +08:00
<? php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient ;
use Psr\Log\LoggerAwareInterface ;
use Psr\Log\LoggerInterface ;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException ;
use Symfony\Component\HttpClient\Exception\TransportException ;
use Symfony\Component\HttpClient\Internal\CurlClientState ;
use Symfony\Component\HttpClient\Internal\PushedResponse ;
use Symfony\Component\HttpClient\Response\CurlResponse ;
use Symfony\Component\HttpClient\Response\ResponseStream ;
use Symfony\Contracts\HttpClient\HttpClientInterface ;
use Symfony\Contracts\HttpClient\ResponseInterface ;
use Symfony\Contracts\HttpClient\ResponseStreamInterface ;
use Symfony\Contracts\Service\ResetInterface ;
/**
* A performant implementation of the HttpClientInterface contracts based on the curl extension.
*
* This provides fully concurrent HTTP requests, with transparent
* HTTP/2 push when a curl version that supports it is installed.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class CurlHttpClient implements HttpClientInterface , LoggerAwareInterface , ResetInterface
{
use HttpClientTrait ;
public const OPTIONS_DEFAULTS = HttpClientInterface :: OPTIONS_DEFAULTS + [
'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT ,
];
private array $defaultOptions = self :: OPTIONS_DEFAULTS + [
2026-10-16 18:13:09 +08:00
'auth_ntlm' => null , // array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling NTLM auth
2026-09-22 16:43:47 +08:00
'extra' => [
2026-10-16 18:13:09 +08:00
'curl' => [], // A list of extra curl options indexed by their corresponding CURLOPT_*
2026-09-22 16:43:47 +08:00
],
];
private static array $emptyDefaults = self :: OPTIONS_DEFAULTS + [ 'auth_ntlm' => null ];
private ? LoggerInterface $logger = null ;
/**
* An internal object to share state between the client and its responses.
*/
private CurlClientState $multi ;
/**
* @param array $defaultOptions Default request's options
* @param int $maxHostConnections The maximum number of connections to a single host
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function __construct ( array $defaultOptions = [], int $maxHostConnections = 6 , int $maxPendingPushes = 0 )
{
if ( ! \extension_loaded ( 'curl' )) {
throw new \LogicException ( 'You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.' );
}
$this -> defaultOptions [ 'buffer' ] ??= self :: shouldBuffer ( ... );
if ( $defaultOptions ) {
[, $this -> defaultOptions ] = self :: prepareRequest ( null , null , $defaultOptions , $this -> defaultOptions );
}
$this -> multi = new CurlClientState ( $maxHostConnections , $maxPendingPushes );
}
public function setLogger ( LoggerInterface $logger ) : void
{
$this -> logger = $this -> multi -> logger = $logger ;
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function request ( string $method , string $url , array $options = []) : ResponseInterface
{
[ $url , $options ] = self :: prepareRequest ( $method , $url , $options , $this -> defaultOptions );
$scheme = $url [ 'scheme' ];
$authority = $url [ 'authority' ];
$host = parse_url ( $authority , \PHP_URL_HOST );
$port = parse_url ( $authority , \PHP_URL_PORT ) ?: ( 'http:' === $scheme ? 80 : 443 );
$proxy = self :: getProxyUrl ( $options [ 'proxy' ], $url );
$noProxy = $options [ 'no_proxy' ] ?? $_SERVER [ 'no_proxy' ] ?? $_SERVER [ 'NO_PROXY' ] ?? '' ;
self :: checkHttpsProxySupport ( $proxy , $url , $noProxy );
$url = implode ( '' , $url );
if ( ! isset ( $options [ 'normalized_headers' ][ 'user-agent' ])) {
$options [ 'headers' ][] = 'User-Agent: Symfony HttpClient (Curl)' ;
}
$curlopts = [
\CURLOPT_URL => $url ,
\CURLOPT_TCP_NODELAY => true ,
\CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS ,
\CURLOPT_REDIR_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS ,
\CURLOPT_FOLLOWLOCATION => true ,
\CURLOPT_MAXREDIRS => max ( 0 , $options [ 'max_redirects' ]),
\CURLOPT_COOKIEFILE => '' , // Keep track of cookies during redirects
\CURLOPT_TIMEOUT => 0 ,
// Always set, so that curl doesn't resolve the proxy from its own environment
\CURLOPT_PROXY => $proxy ?? '' ,
\CURLOPT_NOPROXY => $noProxy ,
\CURLOPT_SSL_VERIFYPEER => $options [ 'verify_peer' ],
\CURLOPT_SSL_VERIFYHOST => $options [ 'verify_host' ] ? 2 : 0 ,
\CURLOPT_CAINFO => $options [ 'cafile' ],
\CURLOPT_CAPATH => $options [ 'capath' ],
\CURLOPT_SSL_CIPHER_LIST => $options [ 'ciphers' ],
\CURLOPT_SSLCERT => $options [ 'local_cert' ],
\CURLOPT_SSLKEY => $options [ 'local_pk' ],
\CURLOPT_KEYPASSWD => $options [ 'passphrase' ],
\CURLOPT_CERTINFO => $options [ 'capture_peer_cert_chain' ],
\CURLOPT_SSLVERSION => match ( $options [ 'crypto_method' ]) {
\STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT => \CURL_SSLVERSION_TLSv1_3 ,
\STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT => \CURL_SSLVERSION_TLSv1_2 ,
\STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT => \CURL_SSLVERSION_TLSv1_1 ,
\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT => \CURL_SSLVERSION_TLSv1_0 ,
},
];
if ( 1.0 === ( float ) $options [ 'http_version' ]) {
$curlopts [ \CURLOPT_HTTP_VERSION ] = \CURL_HTTP_VERSION_1_0 ;
} elseif ( 1.1 === ( float ) $options [ 'http_version' ]) {
$curlopts [ \CURLOPT_HTTP_VERSION ] = \CURL_HTTP_VERSION_1_1 ;
2026-10-16 18:13:09 +08:00
} elseif ( 3.0 === ( float ) $options [ 'http_version' ] && 'https:' === $scheme && \defined ( 'CURL_HTTP_VERSION_3ONLY' ) && ( \CURL_VERSION_HTTP3 & CurlClientState :: $curlVersion [ 'features' ]) && ! self :: willUseProxy ( $proxy , $curlopts [ \CURLOPT_NOPROXY ], $host )) {
// CURL_HTTP_VERSION_3 falls back to older versions since curl 7.88, which added CURL_HTTP_VERSION_3ONLY
$curlopts [ \CURLOPT_HTTP_VERSION ] = \CURL_HTTP_VERSION_3 ;
2026-09-22 16:43:47 +08:00
} elseif ( \defined ( 'CURL_VERSION_HTTP2' ) && ( \CURL_VERSION_HTTP2 & CurlClientState :: $curlVersion [ 'features' ]) && ( 'https:' === $scheme || 2.0 === ( float ) $options [ 'http_version' ])) {
$curlopts [ \CURLOPT_HTTP_VERSION ] = \CURL_HTTP_VERSION_2_0 ;
}
$ntlmOriginKey = null ;
if ( isset ( $options [ 'auth_ntlm' ])) {
$curlopts [ \CURLOPT_HTTPAUTH ] = \CURLAUTH_NTLM ;
$curlopts [ \CURLOPT_HTTP_VERSION ] = \CURL_HTTP_VERSION_1_1 ;
if ( \is_array ( $options [ 'auth_ntlm' ])) {
$count = \count ( $options [ 'auth_ntlm' ]);
if ( $count <= 0 || $count > 2 ) {
throw new InvalidArgumentException ( \sprintf ( 'Option "auth_ntlm" must contain 1 or 2 elements, %d given.' , $count ));
}
$options [ 'auth_ntlm' ] = implode ( ':' , $options [ 'auth_ntlm' ]);
}
if ( ! \is_string ( $options [ 'auth_ntlm' ])) {
throw new InvalidArgumentException ( \sprintf ( 'Option "auth_ntlm" must be a string or an array, "%s" given.' , get_debug_type ( $options [ 'auth_ntlm' ])));
}
$curlopts [ \CURLOPT_USERPWD ] = $options [ 'auth_ntlm' ];
$ntlmOriginKey = CurlClientState :: originKey ( $scheme , $host , $port );
if ( isset ( $this -> multi -> ntlmRequiresFreshConnection [ $ntlmOriginKey ])) {
$curlopts [ \CURLOPT_FRESH_CONNECT ] = true ;
$curlopts [ \CURLOPT_FORBID_REUSE ] = true ;
}
}
if ( ! \ZEND_THREAD_SAFE ) {
$curlopts [ \CURLOPT_DNS_USE_GLOBAL_CACHE ] = false ;
}
if ( \defined ( 'CURLOPT_HEADEROPT' ) && \defined ( 'CURLHEADER_SEPARATE' )) {
$curlopts [ \CURLOPT_HEADEROPT ] = \CURLHEADER_SEPARATE ;
}
// curl's resolve feature varies by host:port but ours varies by host only, let's handle this with our own DNS map
if ( isset ( $this -> multi -> dnsCache -> hostnames [ $host ])) {
$options [ 'resolve' ] += [ $host => $this -> multi -> dnsCache -> hostnames [ $host ]];
}
if ( $options [ 'resolve' ] || $this -> multi -> dnsCache -> evictions ) {
// First reset any old DNS cache entries then add the new ones
$resolve = $this -> multi -> dnsCache -> evictions ;
$this -> multi -> dnsCache -> evictions = [];
if ( $resolve && 0x072A00 > CurlClientState :: $curlVersion [ 'version_number' ]) {
// DNS cache removals require curl 7.42 or higher
$this -> multi -> reset ();
}
foreach ( $options [ 'resolve' ] as $resolveHost => $ip ) {
$resolve [] = null === $ip ? "- $resolveHost : $port " : " $resolveHost : $port : $ip " ;
$this -> multi -> dnsCache -> hostnames [ $resolveHost ] = $ip ;
$this -> multi -> dnsCache -> removals [ "- $resolveHost : $port " ] = "- $resolveHost : $port " ;
}
$curlopts [ \CURLOPT_RESOLVE ] = $resolve ;
}
$curlopts [ \CURLOPT_CUSTOMREQUEST ] = $method ;
if ( 'POST' === $method ) {
// Use CURLOPT_POST to have browser-like POST-to-GET redirects for 301, 302 and 303
$curlopts [ \CURLOPT_POST ] = true ;
} elseif ( 'HEAD' === $method ) {
$curlopts [ \CURLOPT_NOBODY ] = true ;
}
if ( '\\' !== \DIRECTORY_SEPARATOR && $options [ 'timeout' ] < 1 ) {
$curlopts [ \CURLOPT_NOSIGNAL ] = true ;
}
if ( \extension_loaded ( 'zlib' ) && ! isset ( $options [ 'normalized_headers' ][ 'accept-encoding' ])) {
$options [ 'headers' ][] = 'Accept-Encoding: gzip' ; // Expose only one encoding, some servers mess up when more are provided
}
$body = $options [ 'body' ];
foreach ( $options [ 'headers' ] as $i => $header ) {
if ( \is_string ( $body ) && '' !== $body && 0 === stripos ( $header , 'Content-Length: ' )) {
// Let curl handle Content-Length headers
unset ( $options [ 'headers' ][ $i ]);
continue ;
}
if ( ':' === $header [ - 2 ] && \strlen ( $header ) - 2 === strpos ( $header , ': ' )) {
// curl requires a special syntax to send empty headers
$curlopts [ \CURLOPT_HTTPHEADER ][] = substr_replace ( $header , ';' , - 2 );
} else {
$curlopts [ \CURLOPT_HTTPHEADER ][] = $header ;
}
}
// Prevent curl from sending its default Accept and Expect headers
foreach ([ 'accept' , 'expect' ] as $header ) {
if ( ! isset ( $options [ 'normalized_headers' ][ $header ][ 0 ])) {
$curlopts [ \CURLOPT_HTTPHEADER ][] = $header . ':' ;
}
}
if ( ! \is_string ( $body )) {
if ( isset ( $options [ 'auth_ntlm' ])) {
$curlopts [ \CURLOPT_FORBID_REUSE ] = true ; // Reusing NTLM connections requires seeking capability, which only string bodies support
}
if ( \is_resource ( $body )) {
$curlopts [ \CURLOPT_READDATA ] = $body ;
} else {
$curlopts [ \CURLOPT_READFUNCTION ] = static function ( $ch , $fd , $length ) use ( $body ) {
static $eof = false ;
static $buffer = '' ;
return self :: readRequestBody ( $length , $body , $buffer , $eof );
};
}
if ( isset ( $options [ 'normalized_headers' ][ 'content-length' ][ 0 ])) {
$curlopts [ \CURLOPT_INFILESIZE ] = ( int ) substr ( $options [ 'normalized_headers' ][ 'content-length' ][ 0 ], \strlen ( 'Content-Length: ' ));
}
if ( ! isset ( $options [ 'normalized_headers' ][ 'transfer-encoding' ])) {
$curlopts [ \CURLOPT_HTTPHEADER ][] = 'Transfer-Encoding:' . ( isset ( $curlopts [ \CURLOPT_INFILESIZE ]) ? '' : ' chunked' );
}
if ( 'POST' !== $method ) {
$curlopts [ \CURLOPT_UPLOAD ] = true ;
if ( ! isset ( $options [ 'normalized_headers' ][ 'content-type' ]) && 0 !== ( $curlopts [ \CURLOPT_INFILESIZE ] ?? null )) {
$curlopts [ \CURLOPT_HTTPHEADER ][] = 'Content-Type: application/x-www-form-urlencoded' ;
}
}
} elseif ( '' !== $body || 'POST' === $method ) {
$curlopts [ \CURLOPT_POSTFIELDS ] = $body ;
}
if ( $options [ 'peer_fingerprint' ]) {
if ( ! isset ( $options [ 'peer_fingerprint' ][ 'pin-sha256' ])) {
throw new TransportException ( __CLASS__ . ' supports only "pin-sha256" fingerprints.' );
}
$curlopts [ \CURLOPT_PINNEDPUBLICKEY ] = 'sha256//' . implode ( ';sha256//' , $options [ 'peer_fingerprint' ][ 'pin-sha256' ]);
}
if ( $options [ 'bindto' ]) {
if ( file_exists ( $options [ 'bindto' ])) {
$curlopts [ \CURLOPT_UNIX_SOCKET_PATH ] = $options [ 'bindto' ];
} elseif ( ! str_starts_with ( $options [ 'bindto' ], 'if!' ) && preg_match ( '/^(.*):(\d+)$/' , $options [ 'bindto' ], $matches )) {
$curlopts [ \CURLOPT_INTERFACE ] = trim ( $matches [ 1 ], '[]' );
$curlopts [ \CURLOPT_LOCALPORT ] = $matches [ 2 ];
} else {
$curlopts [ \CURLOPT_INTERFACE ] = $options [ 'bindto' ];
}
}
if ( 0 < $options [ 'max_duration' ]) {
$curlopts [ \CURLOPT_TIMEOUT_MS ] = 1000 * $options [ 'max_duration' ];
}
if ( ! empty ( $options [ 'extra' ][ 'curl' ]) && \is_array ( $options [ 'extra' ][ 'curl' ])) {
$this -> validateExtraCurlOptions ( $options [ 'extra' ][ 'curl' ]);
$curlopts += $options [ 'extra' ][ 'curl' ];
}
if ( $pushedResponse = $this -> multi -> pushedResponses [ $url ] ?? null ) {
unset ( $this -> multi -> pushedResponses [ $url ]);
if ( self :: acceptPushForRequest ( $method , $options , $pushedResponse )) {
$this -> logger ?-> debug ( \sprintf ( 'Accepting pushed response: "%s %s"' , $method , $url ));
// Reinitialize the pushed response with request's options
$ch = $pushedResponse -> handle ;
$pushedResponse = $pushedResponse -> response ;
$pushedResponse -> __construct ( $this -> multi , $url , $options , $this -> logger );
} else {
$this -> logger ?-> debug ( \sprintf ( 'Rejecting pushed response: "%s"' , $url ));
$pushedResponse = null ;
}
}
if ( ! $pushedResponse ) {
$ch = curl_init ();
$this -> logger ?-> info ( \sprintf ( 'Request: "%s %s"' , $method , $url ));
2026-10-16 18:13:09 +08:00
$curlopts += [ \CURLOPT_SHARE => $this -> multi -> share ];
2026-09-22 16:43:47 +08:00
}
foreach ( $curlopts as $opt => $value ) {
if ( \PHP_INT_SIZE === 8 && \defined ( 'CURLOPT_INFILESIZE_LARGE' ) && \CURLOPT_INFILESIZE === $opt && $value >= 1 << 31 ) {
$opt = \CURLOPT_INFILESIZE_LARGE ;
}
if ( null !== $value && ! curl_setopt ( $ch , $opt , $value ) && \CURLOPT_CERTINFO !== $opt && ( ! \defined ( 'CURLOPT_HEADEROPT' ) || \CURLOPT_HEADEROPT !== $opt )) {
$constantName = $this -> findConstantName ( $opt );
throw new TransportException ( \sprintf ( 'Curl option "%s" is not supported.' , $constantName ?? $opt ));
}
}
return $pushedResponse ?? new CurlResponse ( $this -> multi , $ch , $options , $this -> logger , $method , self :: createRedirectResolver ( $options , $scheme , $authority , $noProxy ), CurlClientState :: $curlVersion [ 'version_number' ], $url , $ntlmOriginKey );
}
public function stream ( ResponseInterface | iterable $responses , ? float $timeout = null ) : ResponseStreamInterface
{
if ( $responses instanceof CurlResponse ) {
$responses = [ $responses ];
}
if ( $this -> multi -> handle instanceof \CurlMultiHandle ) {
$active = 0 ;
while ( \CURLM_CALL_MULTI_PERFORM === curl_multi_exec ( $this -> multi -> handle , $active )) {
}
}
return new ResponseStream ( CurlResponse :: stream ( $responses , $timeout ));
}
public function reset () : void
{
$this -> multi -> reset ();
}
/**
* Accepts pushed responses only if their headers related to authentication match the request.
*/
private static function acceptPushForRequest ( string $method , array $options , PushedResponse $pushedResponse ) : bool
{
if ( '' !== $options [ 'body' ] || $method !== $pushedResponse -> requestHeaders [ ':method' ][ 0 ]) {
return false ;
}
foreach ([ 'proxy' , 'no_proxy' , 'bindto' , 'local_cert' , 'local_pk' ] as $k ) {
if ( $options [ $k ] !== $pushedResponse -> parentOptions [ $k ]) {
return false ;
}
}
foreach ([ 'authorization' , 'cookie' , 'range' , 'proxy-authorization' ] as $k ) {
$normalizedHeaders = $options [ 'normalized_headers' ][ $k ] ?? [];
foreach ( $normalizedHeaders as $i => $v ) {
$normalizedHeaders [ $i ] = substr ( $v , \strlen ( $k ) + 2 );
}
if (( $pushedResponse -> requestHeaders [ $k ] ?? []) !== $normalizedHeaders ) {
return false ;
}
}
$statusCode = $pushedResponse -> response -> getInfo ( 'http_code' ) ?: 200 ;
return $statusCode < 300 || 400 <= $statusCode ;
}
/**
* Wraps the request's body callback to allow it to return strings longer than curl requested.
*/
private static function readRequestBody ( int $length , \Closure $body , string & $buffer , bool & $eof ) : string
{
if ( ! $eof && \strlen ( $buffer ) < $length ) {
if ( ! \is_string ( $data = $body ( $length ))) {
throw new TransportException ( \sprintf ( 'The return value of the "body" option callback must be a string, "%s" returned.' , get_debug_type ( $data )));
}
$buffer .= $data ;
$eof = '' === $data ;
}
$data = substr ( $buffer , 0 , $length );
$buffer = substr ( $buffer , $length );
return $data ;
}
/**
* Resolves relative URLs on redirects and deals with authentication headers.
*
* Work around CVE-2018-1000007: Authorization and Cookie headers should not follow redirects - fixed in Curl 7.64
*/
private static function createRedirectResolver ( array $options , string $scheme , string $authority , string $noProxy ) : \Closure
{
$redirectHeaders = [];
if ( 0 < $options [ 'max_redirects' ]) {
$redirectHeaders [ 'scheme' ] = $scheme ;
$redirectHeaders [ 'authority' ] = $authority ;
$redirectHeaders [ 'with_auth' ] = $redirectHeaders [ 'no_auth' ] = array_filter ( $options [ 'headers' ], static fn ( $h ) => 0 !== stripos ( $h , 'Host:' ));
if ( isset ( $options [ 'normalized_headers' ][ 'authorization' ][ 0 ]) || isset ( $options [ 'normalized_headers' ][ 'cookie' ][ 0 ])) {
$redirectHeaders [ 'no_auth' ] = array_filter ( $redirectHeaders [ 'no_auth' ], static fn ( $h ) => 0 !== stripos ( $h , 'Authorization:' ) && 0 !== stripos ( $h , 'Cookie:' ));
}
}
return static function ( $ch , string $location , bool $noContent ) use ( & $redirectHeaders , $options , $noProxy ) {
try {
$location = self :: parseUrl ( $location );
$url = self :: parseUrl ( curl_getinfo ( $ch , \CURLINFO_EFFECTIVE_URL ));
$url = self :: resolveUrl ( $location , $url );
} catch ( InvalidArgumentException ) {
return null ;
}
if ( $noContent && $redirectHeaders ) {
$filterContentHeaders = static fn ( $h ) => 0 !== stripos ( $h , 'Content-Length:' ) && 0 !== stripos ( $h , 'Content-Type:' ) && 0 !== stripos ( $h , 'Transfer-Encoding:' );
$redirectHeaders [ 'no_auth' ] = array_filter ( $redirectHeaders [ 'no_auth' ], $filterContentHeaders );
$redirectHeaders [ 'with_auth' ] = array_filter ( $redirectHeaders [ 'with_auth' ], $filterContentHeaders );
}
if ( $redirectHeaders && isset ( $location [ 'authority' ])) {
// Authorization and Cookie headers MUST NOT follow except for the initial scheme and authority
$requestHeaders = $url [ 'scheme' ] === $redirectHeaders [ 'scheme' ] && $location [ 'authority' ] === $redirectHeaders [ 'authority' ] ? $redirectHeaders [ 'with_auth' ] : $redirectHeaders [ 'no_auth' ];
curl_setopt ( $ch , \CURLOPT_HTTPHEADER , $requestHeaders );
} elseif ( $noContent && $redirectHeaders ) {
curl_setopt ( $ch , \CURLOPT_HTTPHEADER , $redirectHeaders [ 'with_auth' ]);
}
$proxy = self :: getProxyUrl ( $options [ 'proxy' ], $url );
self :: checkHttpsProxySupport ( $proxy , $url , $noProxy );
curl_setopt ( $ch , \CURLOPT_PROXY , $proxy ?? '' );
if ( \defined ( 'CURL_HTTP_VERSION_3' ) && \CURL_HTTP_VERSION_3 === curl_getinfo ( $ch , \CURLINFO_HTTP_VERSION ) && self :: willUseProxy ( $proxy , $noProxy , parse_url ( $url [ 'authority' ], \PHP_URL_HOST ))) {
curl_setopt ( $ch , \CURLOPT_HTTP_VERSION , \defined ( 'CURL_HTTP_VERSION_2_0' ) ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_1_1 );
}
return implode ( '' , $url );
};
}
/**
* Rejects "https://" proxies that curl cannot connect to over TLS.
*
* Curl older than 7.50.2 connects to them in cleartext instead, leaking the proxy
* credentials and the CONNECT metadata on the wire.
*/
private static function checkHttpsProxySupport ( ? string $proxy , array $url , string $noProxy ) : void
{
if ( null === $proxy || 0 !== stripos ( $proxy , 'https://' )) {
return ;
}
if ( CurlClientState :: $curlVersion [ 'features' ] & ( \defined ( 'CURL_VERSION_HTTPS_PROXY' ) ? \CURL_VERSION_HTTPS_PROXY : 1 << 21 )) {
return ;
}
$host = parse_url ( $url [ 'authority' ], \PHP_URL_HOST );
// Matching "no_proxy" should follow the behavior of curl
foreach ( preg_split ( '/[\s,]+/' , $noProxy , - 1 , \PREG_SPLIT_NO_EMPTY ) as $rule ) {
if ( '*' === $rule || $host === $rule || str_ends_with ( $host , '.' . ltrim ( $rule , '.' ))) {
return ;
}
}
throw new TransportException ( 'Cannot use an "https://" proxy: the installed curl does not support HTTPS proxies and could connect to it in cleartext; curl 7.52 or higher is required.' );
}
private function findConstantName ( int $opt ) : ? string
{
$constants = array_filter ( get_defined_constants (), static fn ( $v , $k ) => $v === $opt && 'C' === $k [ 0 ] && ( str_starts_with ( $k , 'CURLOPT_' ) || str_starts_with ( $k , 'CURLINFO_' )), \ARRAY_FILTER_USE_BOTH );
return key ( $constants );
}
/**
* Prevents overriding options that are set internally throughout the request.
*/
private function validateExtraCurlOptions ( array $options ) : void
{
$curloptsToConfig = [
// options used in CurlHttpClient
\CURLOPT_HTTPAUTH => 'auth_ntlm' ,
\CURLOPT_USERPWD => 'auth_ntlm' ,
\CURLOPT_RESOLVE => 'resolve' ,
\CURLOPT_NOSIGNAL => 'timeout' ,
\CURLOPT_HTTPHEADER => 'headers' ,
\CURLOPT_READDATA => 'body' ,
\CURLOPT_READFUNCTION => 'body' ,
\CURLOPT_INFILESIZE => 'body' ,
\CURLOPT_POSTFIELDS => 'body' ,
\CURLOPT_UPLOAD => 'body' ,
\CURLOPT_INTERFACE => 'bindto' ,
\CURLOPT_TIMEOUT_MS => 'max_duration' ,
\CURLOPT_TIMEOUT => 'max_duration' ,
\CURLOPT_MAXREDIRS => 'max_redirects' ,
\CURLOPT_POSTREDIR => 'max_redirects' ,
\CURLOPT_PROXY => 'proxy' ,
\CURLOPT_NOPROXY => 'no_proxy' ,
\CURLOPT_SSL_VERIFYPEER => 'verify_peer' ,
\CURLOPT_SSL_VERIFYHOST => 'verify_host' ,
\CURLOPT_CAINFO => 'cafile' ,
\CURLOPT_CAPATH => 'capath' ,
\CURLOPT_SSL_CIPHER_LIST => 'ciphers' ,
\CURLOPT_SSLCERT => 'local_cert' ,
\CURLOPT_SSLKEY => 'local_pk' ,
\CURLOPT_KEYPASSWD => 'passphrase' ,
\CURLOPT_CERTINFO => 'capture_peer_cert_chain' ,
\CURLOPT_USERAGENT => 'normalized_headers' ,
\CURLOPT_REFERER => 'headers' ,
// options used in CurlResponse
\CURLOPT_NOPROGRESS => 'on_progress' ,
\CURLOPT_PROGRESSFUNCTION => 'on_progress' ,
];
if ( \defined ( 'CURLOPT_UNIX_SOCKET_PATH' )) {
$curloptsToConfig [ \CURLOPT_UNIX_SOCKET_PATH ] = 'bindto' ;
}
if ( \defined ( 'CURLOPT_PINNEDPUBLICKEY' )) {
$curloptsToConfig [ \CURLOPT_PINNEDPUBLICKEY ] = 'peer_fingerprint' ;
}
$curloptsToCheck = [
\CURLOPT_PRIVATE ,
\CURLOPT_HEADERFUNCTION ,
\CURLOPT_WRITEFUNCTION ,
\CURLOPT_VERBOSE ,
\CURLOPT_STDERR ,
\CURLOPT_RETURNTRANSFER ,
\CURLOPT_URL ,
\CURLOPT_FOLLOWLOCATION ,
\CURLOPT_HEADER ,
2026-10-16 18:13:09 +08:00
\CURLOPT_CONNECTTIMEOUT ,
\CURLOPT_CONNECTTIMEOUT_MS ,
2026-09-22 16:43:47 +08:00
\CURLOPT_HTTP_VERSION ,
\CURLOPT_PORT ,
\CURLOPT_DNS_USE_GLOBAL_CACHE ,
\CURLOPT_PROTOCOLS ,
\CURLOPT_REDIR_PROTOCOLS ,
\CURLOPT_COOKIEFILE ,
\CURLINFO_REDIRECT_COUNT ,
];
if ( \defined ( 'CURLOPT_HTTP09_ALLOWED' )) {
$curloptsToCheck [] = \CURLOPT_HTTP09_ALLOWED ;
}
if ( \defined ( 'CURLOPT_HEADEROPT' )) {
$curloptsToCheck [] = \CURLOPT_HEADEROPT ;
}
foreach ( $options as $opt => $optValue ) {
if ( isset ( $curloptsToConfig [ $opt ])) {
$constName = $this -> findConstantName ( $opt ) ?? $opt ;
throw new InvalidArgumentException ( \sprintf ( 'Cannot set "%s" with "extra.curl", use option "%s" instead.' , $constName , $curloptsToConfig [ $opt ]));
}
if ( \in_array ( $opt , [ \CURLOPT_POST , \CURLOPT_PUT , \CURLOPT_CUSTOMREQUEST , \CURLOPT_HTTPGET , \CURLOPT_NOBODY ], true )) {
throw new InvalidArgumentException ( 'The HTTP method cannot be overridden using "extra.curl".' );
}
if ( \in_array ( $opt , $curloptsToCheck , true )) {
$constName = $this -> findConstantName ( $opt ) ?? $opt ;
throw new InvalidArgumentException ( \sprintf ( 'Cannot set "%s" with "extra.curl".' , $constName ));
}
}
}
private static function willUseProxy ( ? string $proxy , string $noProxy , string $host ) : bool
{
if ( null === $proxy ) {
return false ;
}
if ( '' === $noProxy ) {
return true ;
}
foreach ( preg_split ( '/[\s,]+/' , $noProxy ) as $rule ) {
$dotRule = '.' . ltrim ( $rule , '.' );
if ( '*' === $rule || $host === $rule || str_ends_with ( $host , $dotRule )) {
return false ;
}
}
return true ;
}
}