From ee5769670cec7fa3f98649f851c076225f43e1eb Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 11 Apr 2022 13:29:45 +0200 Subject: [PATCH] [HttpClient] Fix sending content-length when streaming the body --- .../Component/HttpClient/CurlHttpClient.php | 25 +++++++++++-------- .../Component/HttpClient/HttpClientTrait.php | 2 +- .../Component/HttpClient/NativeHttpClient.php | 6 ++--- .../HttpClient/Response/CurlResponse.php | 9 ++++--- .../HttpClient/Test/HttpClientTestCase.php | 7 +++++- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index 3b63addec8865..5889975a3d4e9 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -202,14 +202,7 @@ public function request(string $method, string $url, array $options = []): Respo $options['headers'][] = 'Accept-Encoding: gzip'; // Expose only one encoding, some servers mess up when more are provided } - $hasContentLength = isset($options['normalized_headers']['content-length'][0]); - - foreach ($options['headers'] as $i => $header) { - if ($hasContentLength && 0 === stripos($header, 'Content-Length:')) { - // Let curl handle Content-Length headers - unset($options['headers'][$i]); - continue; - } + foreach ($options['headers'] as $header) { if (':' === $header[-2] && \strlen($header) - 2 === strpos($header, ': ')) { // curl requires a special syntax to send empty headers $curlopts[\CURLOPT_HTTPHEADER][] = substr_replace($header, ';', -2); @@ -236,7 +229,7 @@ public function request(string $method, string $url, array $options = []): Respo }; } - if ($hasContentLength) { + if (isset($options['normalized_headers']['content-length'][0])) { $curlopts[\CURLOPT_INFILESIZE] = substr($options['normalized_headers']['content-length'][0], \strlen('Content-Length: ')); } elseif (!isset($options['normalized_headers']['transfer-encoding'])) { $curlopts[\CURLOPT_HTTPHEADER][] = 'Transfer-Encoding: chunked'; // Enable chunked request bodies @@ -249,7 +242,7 @@ public function request(string $method, string $url, array $options = []): Respo $curlopts[\CURLOPT_HTTPHEADER][] = 'Content-Type: application/x-www-form-urlencoded'; } } - } elseif ('' !== $body || 'POST' === $method || $hasContentLength) { + } elseif ('' !== $body || 'POST' === $method) { $curlopts[\CURLOPT_POSTFIELDS] = $body; } @@ -406,16 +399,26 @@ private static function createRedirectResolver(array $options, string $host): \C } } - return static function ($ch, string $location) use ($redirectHeaders) { + return static function ($ch, string $location, bool $noContent) use (&$redirectHeaders) { try { $location = self::parseUrl($location); } catch (InvalidArgumentException $e) { return null; } + if ($noContent && $redirectHeaders) { + $filterContentHeaders = static function ($h) { + return 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 && $host = parse_url('http:'.$location['authority'], \PHP_URL_HOST)) { $requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth']; curl_setopt($ch, \CURLOPT_HTTPHEADER, $requestHeaders); + } elseif ($noContent && $redirectHeaders) { + curl_setopt($ch, \CURLOPT_HTTPHEADER, $redirectHeaders['with_auth']); } $url = self::parseUrl(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)); diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index e616ca1f9c01b..9fceef2fd6443 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -92,7 +92,7 @@ private static function prepareRequest(?string $method, ?string $url, array $opt && (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16) && ('' !== $h || '' !== $options['body']) ) { - if (isset($options['normalized_headers']['transfer-encoding'])) { + if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) { unset($options['normalized_headers']['transfer-encoding']); $options['body'] = self::dechunk($options['body']); } diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index f52d93d5eb7e0..13d13760470c6 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -85,7 +85,7 @@ public function request(string $method, string $url, array $options = []): Respo $options['body'] = self::getBodyAsString($options['body']); - if (isset($options['normalized_headers']['transfer-encoding'])) { + if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) { unset($options['normalized_headers']['transfer-encoding']); $options['headers'] = array_merge(...array_values($options['normalized_headers'])); $options['body'] = self::dechunk($options['body']); @@ -397,7 +397,7 @@ private static function createRedirectResolver(array $options, string $host, ?ar } } - return static function (NativeClientState $multi, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string { + return static function (NativeClientState $multi, ?string $location, $context) use (&$redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string { if (null === $location || $info['http_code'] < 300 || 400 <= $info['http_code']) { $info['redirect_url'] = null; @@ -431,7 +431,7 @@ private static function createRedirectResolver(array $options, string $host, ?ar $info['http_method'] = $options['method'] = 'HEAD' === $options['method'] ? 'HEAD' : 'GET'; $options['content'] = ''; $filterContentHeaders = static function ($h) { - return 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:'); + return 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:') && 0 !== stripos($h, 'Transfer-Encoding:'); }; $options['header'] = array_filter($options['header'], $filterContentHeaders); $redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], $filterContentHeaders); diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index e065c4aa17f0e..2fc42c0b66229 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -361,9 +361,7 @@ private static function parseHeaderLine($ch, string $data, array &$info, array & if (curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) { curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false); } elseif (303 === $info['http_code'] || ('POST' === $info['http_method'] && \in_array($info['http_code'], [301, 302], true))) { - $info['http_method'] = 'HEAD' === $info['http_method'] ? 'HEAD' : 'GET'; curl_setopt($ch, \CURLOPT_POSTFIELDS, ''); - curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $info['http_method']); } } @@ -382,7 +380,12 @@ private static function parseHeaderLine($ch, string $data, array &$info, array & $info['redirect_url'] = null; if (300 <= $statusCode && $statusCode < 400 && null !== $location) { - if (null === $info['redirect_url'] = $resolveRedirect($ch, $location)) { + if ($noContent = 303 === $statusCode || ('POST' === $info['http_method'] && \in_array($statusCode, [301, 302], true))) { + $info['http_method'] = 'HEAD' === $info['http_method'] ? 'HEAD' : 'GET'; + curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $info['http_method']); + } + + if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent)) { $options['max_redirects'] = curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT); curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, \CURLOPT_MAXREDIRS, $options['max_redirects']); diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index ddc5324492c86..bce7a85c13299 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -332,11 +332,16 @@ public function test304() $this->assertSame('', $response->getContent(false)); } - public function testRedirects() + /** + * @testWith [[]] + * [["Content-Length: 7"]] + */ + public function testRedirects(array $headers = []) { $client = $this->getHttpClient(__FUNCTION__); $response = $client->request('POST', 'http://localhost:8057/301', [ 'auth_basic' => 'foo:bar', + 'headers' => $headers, 'body' => function () { yield 'foo=bar'; },