From 89329bd97980134a0f9d150e94b19f648c760730 Mon Sep 17 00:00:00 2001 From: pizzaminded Date: Tue, 1 Sep 2020 20:53:07 +0200 Subject: [PATCH] [HttpClient] Allow to provide additional curl options to CurlHttpClient --- src/Symfony/Component/HttpClient/CHANGELOG.md | 1 + .../Component/HttpClient/CurlHttpClient.php | 112 +++++++++++++++++- .../HttpClient/Tests/CurlHttpClientTest.php | 46 +++++++ 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index b652df1d94..8a45eb70c9 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * added `StreamableInterface` to ease turning responses into PHP streams * added `MockResponse::getRequestMethod()` and `getRequestUrl()` to allow inspecting which request has been sent * added `EventSourceHttpClient` a Server-Sent events stream implementing the [EventSource specification](https://www.w3.org/TR/eventsource/#eventsource) + * added option "extra.curl" to allow setting additional curl options in `CurlHttpClient` 5.1.0 ----- diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index b7f814ed14..68bbfdc679 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -40,6 +40,9 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, private $defaultOptions = self::OPTIONS_DEFAULTS + [ '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 + 'extra' => [ + 'curl' => [], // A list of extra curl options indexed by their corresponding CURLOPT_* + ], ]; /** @@ -274,6 +277,11 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, $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]); @@ -297,11 +305,8 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, foreach ($curlopts as $opt => $value) { if (null !== $value && !curl_setopt($ch, $opt, $value) && \CURLOPT_CERTINFO !== $opt) { - $constants = array_filter(get_defined_constants(), static function ($v, $k) use ($opt) { - return $v === $opt && 'C' === $k[0] && (0 === strpos($k, 'CURLOPT_') || 0 === strpos($k, 'CURLINFO_')); - }, \ARRAY_FILTER_USE_BOTH); - - throw new TransportException(sprintf('Curl option "%s" is not supported.', key($constants) ?? $opt)); + $constantName = $this->findConstantName($opt); + throw new TransportException(sprintf('Curl option "%s" is not supported.', $constantName ?? $opt)); } } @@ -487,4 +492,101 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, return implode('', self::resolveUrl($location, $url)); }; } + + private function findConstantName($opt): ?string + { + $constants = array_filter(get_defined_constants(), static function ($v, $k) use ($opt) { + return $v === $opt && 'C' === $k[0] && (0 === strpos($k, 'CURLOPT_') || 0 === strpos($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_INFILE => 'body', + \CURLOPT_READFUNCTION => 'body', + \CURLOPT_INFILESIZE => 'body', + \CURLOPT_POSTFIELDS => 'body', + \CURLOPT_UPLOAD => 'body', + \CURLOPT_PINNEDPUBLICKEY => 'peer_fingerprint', + \CURLOPT_UNIX_SOCKET_PATH => 'bindto', + \CURLOPT_INTERFACE => 'bindto', + \CURLOPT_TIMEOUT_MS => 'max_duration', + \CURLOPT_TIMEOUT => 'max_duration', + \CURLOPT_MAXREDIRS => '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', + ]; + + $curloptsToCheck = [ + \CURLOPT_PRIVATE, + \CURLOPT_HEADERFUNCTION, + \CURLOPT_WRITEFUNCTION, + \CURLOPT_VERBOSE, + \CURLOPT_STDERR, + \CURLOPT_RETURNTRANSFER, + \CURLOPT_URL, + \CURLOPT_FOLLOWLOCATION, + \CURLOPT_HEADER, + \CURLOPT_CONNECTTIMEOUT, + \CURLOPT_CONNECTTIMEOUT_MS, + \CURLOPT_HEADEROPT, + \CURLOPT_HTTP_VERSION, + \CURLOPT_PORT, + \CURLOPT_DNS_USE_GLOBAL_CACHE, + \CURLOPT_PROTOCOLS, + \CURLOPT_REDIR_PROTOCOLS, + \CURLOPT_COOKIEFILE, + \CURLINFO_REDIRECT_COUNT, + ]; + + $methodOpts = [ + \CURLOPT_POST, + \CURLOPT_PUT, + \CURLOPT_CUSTOMREQUEST, + \CURLOPT_HTTPGET, + \CURLOPT_NOBODY, + ]; + + 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, $methodOpts)) { + throw new InvalidArgumentException('The HTTP method cannot be overridden using "extra.curl".'); + } + + if (\in_array($opt, $curloptsToCheck)) { + $constName = $this->findConstantName($opt) ?? $opt; + throw new InvalidArgumentException(sprintf('Cannot set "%s" with "extra.curl".', $constName)); + } + } + } } diff --git a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php index e0a12a49a5..0a79a33c3c 100644 --- a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpClient\Tests; use Symfony\Component\HttpClient\CurlHttpClient; +use Symfony\Component\HttpClient\Exception\InvalidArgumentException; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -42,4 +43,49 @@ class CurlHttpClientTest extends HttpClientTestCase parent::testTimeoutIsNotAFatalError(); } + + public function testOverridingRefererUsingCurlOptions() + { + $httpClient = $this->getHttpClient(__FUNCTION__); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set "CURLOPT_REFERER" with "extra.curl", use option "headers" instead.'); + + $httpClient->request('GET', 'http://localhost:8057/', [ + 'extra' => [ + 'curl' => [ + \CURLOPT_REFERER => 'Banana', + ], + ], + ]); + } + + public function testOverridingHttpMethodUsingCurlOptions() + { + $httpClient = $this->getHttpClient(__FUNCTION__); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The HTTP method cannot be overridden using "extra.curl".'); + + $httpClient->request('POST', 'http://localhost:8057/', [ + 'extra' => [ + 'curl' => [ + \CURLOPT_HTTPGET => true, + ], + ], + ]); + } + + public function testOverridingInternalAttributesUsingCurlOptions() + { + $httpClient = $this->getHttpClient(__FUNCTION__); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set "CURLOPT_PRIVATE" with "extra.curl".'); + + $httpClient->request('POST', 'http://localhost:8057/', [ + 'extra' => [ + 'curl' => [ + \CURLOPT_PRIVATE => 'overriden private', + ], + ], + ]); + } }