diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 5df355d58b84..5e8c5847ca02 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -383,22 +383,8 @@ public function send(string $method, string $url) // Set the string we want to break our response from $breakString = "\r\n\r\n"; - if (isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false) { - $output = $this->handleRedirectHeaders($output, $breakString); - } - - while (str_starts_with($output, 'HTTP/1.1 100 Continue')) { - $output = substr($output, strpos($output, $breakString) + 4); - } - - if (preg_match('/HTTP\/\d\.\d 200 Connection established/i', $output)) { - $output = substr($output, strpos($output, $breakString) + 4); - } - - // If request and response have Digest - if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest' && str_contains($output, 'WWW-Authenticate: Digest')) { - $output = substr($output, strpos($output, $breakString) + 4); - } + // Remove all intermediate responses + $output = $this->removeIntermediateResponses($output, $breakString); // Split out our headers and body $break = strpos($output, $breakString); @@ -718,29 +704,71 @@ protected function sendRequest(array $curlOptions = []): string return $output; } - private function handleRedirectHeaders(string $output, string $breakString): string + private function removeIntermediateResponses(string $output, string $breakString): string { - // Strip out multiple redirect header sections - while (preg_match('/^HTTP\/\d(?:\.\d)? 3\d\d/', $output)) { - $breakStringPos = strpos($output, $breakString); - $redirectHeaderSection = substr($output, 0, $breakStringPos); - $redirectHeaders = explode("\n", $redirectHeaderSection); - $locationHeaderFound = false; - - foreach ($redirectHeaders as $header) { - if (str_starts_with(strtolower($header), 'location:')) { - $locationHeaderFound = true; - break; + while (true) { + // Check if we should remove the current response + if ($this->shouldRemoveCurrentResponse($output, $breakString)) { + $breakStringPos = strpos($output, $breakString); + if ($breakStringPos !== false) { + $output = substr($output, $breakStringPos + 4); + + continue; } } - if ($locationHeaderFound) { - $output = substr($output, $breakStringPos + 4); - } else { - break; - } + // No more intermediate responses to remove + break; } return $output; } + + /** + * Check if the current response (at the beginning of output) should be removed. + */ + private function shouldRemoveCurrentResponse(string $output, string $breakString): bool + { + // HTTP/x.x 1xx responses (Continue, Processing, etc.) + if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+1\d\d\s/', $output)) { + return true; + } + + // HTTP/x.x 200 Connection established (proxy responses) + if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+200\s+Connection\s+established/i', $output)) { + return true; + } + + // HTTP/x.x 3xx responses (redirects) - only if redirects are allowed + $allowRedirects = isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false; + if ($allowRedirects && preg_match('/^HTTP\/\d+(?:\.\d+)?\s+3\d\d\s/', $output)) { + // Check if there's a Location header + $breakStringPos = strpos($output, $breakString); + if ($breakStringPos !== false) { + $headerSection = substr($output, 0, $breakStringPos); + $headers = explode("\n", $headerSection); + + foreach ($headers as $header) { + if (str_starts_with(strtolower($header), 'location:')) { + return true; // Found location header, this is a redirect to remove + } + } + } + } + + // Digest auth challenges - only remove if there's another response after + if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest') { + $breakStringPos = strpos($output, $breakString); + if ($breakStringPos !== false) { + $headerSection = substr($output, 0, $breakStringPos); + if (str_contains($headerSection, 'WWW-Authenticate: Digest')) { + $nextBreakPos = strpos($output, $breakString, $breakStringPos + 4); + + return $nextBreakPos !== false; // Only remove if there's another response + } + } + } + + return false; + } } diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index e72a1d37d17c..6d1b328c349b 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -1381,4 +1381,34 @@ public function testNotRemoveMultipleRedirectHeaderSectionsWithoutLocationHeader $this->assertSame($testBody, $response->getBody()); } + + public function testProxyAndContinueResponses(): void + { + $testBody = '{"Id":"83589c7e-bd86-4101-8d93-3f2e7954e48e"}'; + + $output = "HTTP/1.1 200 Connection established\r\n\r\nHTTP/1.1 100 Continue +Connection: keep-alive\r\n\r\nHTTP/1.1 202 Accepted +Vary: Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding +x-content-type-options: nosniff +x-xss-protection: 1; mode=block +Cache-Control: no-cache, no-store, max-age=0, must-revalidate +Pragma: no-cache +Expires: 0 +strict-transport-security: max-age=31536000 ; includeSubDomains +x-frame-options: DENY +Content-Type: application/json +Content-Length: 56 +Date: Wed, 02 Jul 2025 18:37:21 GMT +Connection: keep-alive\r\n\r\n" . $testBody; + + $this->request->setOutput($output); + + $response = $this->request->request('GET', 'http://example.com', [ + 'allow_redirects' => false, + ]); + + $this->assertSame(202, $response->getStatusCode()); + + $this->assertSame($testBody, $response->getBody()); + } } diff --git a/user_guide_src/source/changelogs/v4.6.2.rst b/user_guide_src/source/changelogs/v4.6.2.rst index 360e592b680d..3e45e3c2409e 100644 --- a/user_guide_src/source/changelogs/v4.6.2.rst +++ b/user_guide_src/source/changelogs/v4.6.2.rst @@ -36,6 +36,7 @@ Bugs Fixed ********** - **Cache:** Fixed a bug where a corrupted or unreadable cache file could cause an unhandled exception in ``FileHandler::getItem()``. +- **CURLRequest:** Fixed a bug where intermediate HTTP responses were not properly removed from the response chain in certain scenarios, causing incorrect status codes and headers to be returned instead of the final response. - **Database:** Fixed a bug where ``when()`` and ``whenNot()`` in ``ConditionalTrait`` incorrectly evaluated certain falsy values (such as ``[]``, ``0``, ``0.0``, and ``'0'``) as truthy, causing callbacks to be executed unexpectedly. These methods now cast the condition to a boolean using ``(bool)`` to ensure consistent behavior with PHP's native truthiness. - **Database:** Fixed encapsulation violation in ``BasePreparedQuery`` when accessing ``BaseConnection::transStatus`` protected property. - **Email:** Fixed a bug where ``Email::getHostname()`` failed to use ``$_SERVER['SERVER_ADDR']`` when ``$_SERVER['SERVER_NAME']`` was not set.