Change in port should be considered a change in origin in Guzzle
Description
Guzzle, a PHP HTTP client, fails to remove sensitive Authorization and Cookie headers when following a redirect to a different port, potentially leaking credentials to untrusted third parties.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Guzzle, a PHP HTTP client, fails to remove sensitive Authorization and Cookie headers when following a redirect to a different port, potentially leaking credentials to untrusted third parties.
Vulnerability
Description
Guzzle, an extensible PHP HTTP client, contains a vulnerability where sensitive HTTP headers such as Authorization and Cookie are not removed when following a redirect to a URI with a different port. The official description notes that previously, only a change in host or scheme would trigger header removal, but a change in port was overlooked [1]. This oversight means that when a redirect response (e.g., HTTP 302) indicates a Location header with a different port, Guzzle may forward the original request's credentials to that new endpoint.
Attack
Vector and Requirements
An attacker does not need direct network access to the victim; instead, the vulnerability is exploitable through a malicious or compromised server that the client interacts with, which can issue a redirect to a different port. No special authentication is required for the attacker beyond controlling the redirect destination. The client must be configured to follow redirects (the default behavior). The partial fix in Guzzle 7.4.2 only covered changes in host, but not scheme or port, leaving the port-change attack unpatched [1]. The vulnerability affects all Guzzle versions prior to 6.5.8 and 7.4.5, as well as earlier series that are no longer supported [1][3].
Impact
If an attacker can cause the Guzzle client to follow a redirect to a different port, the client will send the original Authorization and Cookie headers to the attacker-controlled server. This can lead to credential theft, session hijacking, or unauthorized access to other services, depending on the nature of the leaked headers. The impact is especially severe for applications using HTTP Basic or Digest authentication, or relying on cookies for session management.
Mitigation
The Guzzle project released fixed versions 6.5.8 and 7.4.5 that properly remove sensitive headers on any redirect where the host, scheme, or port changes [1][2]. Users should upgrade to these versions immediately. For those unable to upgrade, an alternative is to implement custom redirect middleware that strips sensitive headers manually, or to disable following redirects entirely if not needed [1]. Note that as of 2023, Guzzle 6.x reached end-of-life, so users of that series are strongly encouraged to upgrade to 7.x for continued security support [3].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
guzzlehttp/guzzlePackagist | < 6.5.8 | 6.5.8 |
guzzlehttp/guzzlePackagist | >= 7.0.0, < 7.4.5 | 7.4.5 |
Affected products
2Patches
11dd98b0564cbRelease 7.4.5 (#3043)
5 files changed · +137 −62
CHANGELOG.md+5 −0 modified@@ -2,6 +2,11 @@ Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version. +## 7.4.5 - 2022-06-20 + +* Fix change in port should be considered a change in origin +* Fix `CURLOPT_HTTPAUTH` option not cleared on change of origin + ## 7.4.4 - 2022-06-09 * Fix failure to strip Authorization header on HTTP downgrade
composer.json+1 −1 modified@@ -54,7 +54,7 @@ "php": "^7.2.5 || ^8.0", "ext-json": "*", "guzzlehttp/promises": "^1.5", - "guzzlehttp/psr7": "^1.8.3 || ^2.1", + "guzzlehttp/psr7": "^1.9 || ^2.4", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" },
README.md+5 −5 modified@@ -44,7 +44,7 @@ We use GitHub issues only to discuss bugs and new features. For support please r - [Documentation](https://docs.guzzlephp.org) - [Stack Overflow](https://stackoverflow.com/questions/tagged/guzzle) -- [#guzzle](https://app.slack.com/client/T0D2S9JCT/CE6UAAKL4) channel on [PHP-HTTP Slack](http://slack.httplug.io/) +- [#guzzle](https://app.slack.com/client/T0D2S9JCT/CE6UAAKL4) channel on [PHP-HTTP Slack](https://slack.httplug.io/) - [Gitter](https://gitter.im/guzzle/guzzle) @@ -73,10 +73,10 @@ composer require guzzlehttp/guzzle [guzzle-5-repo]: https://github.com/guzzle/guzzle/tree/5.3 [guzzle-6-repo]: https://github.com/guzzle/guzzle/tree/6.5 [guzzle-7-repo]: https://github.com/guzzle/guzzle -[guzzle-3-docs]: http://guzzle3.readthedocs.org -[guzzle-5-docs]: http://docs.guzzlephp.org/en/5.3/ -[guzzle-6-docs]: http://docs.guzzlephp.org/en/6.5/ -[guzzle-7-docs]: http://docs.guzzlephp.org/en/latest/ +[guzzle-3-docs]: https://guzzle3.readthedocs.io/ +[guzzle-5-docs]: https://docs.guzzlephp.org/en/5.3/ +[guzzle-6-docs]: https://docs.guzzlephp.org/en/6.5/ +[guzzle-7-docs]: https://docs.guzzlephp.org/en/latest/ ## Security
src/RedirectMiddleware.php+4 −29 modified@@ -88,10 +88,8 @@ public function checkRedirect(RequestInterface $request, array $options, Respons $this->guardMax($request, $response, $options); $nextRequest = $this->modifyRequest($request, $options, $response); - // If authorization is handled by curl, unset it if host is different. - if ($request->getUri()->getHost() !== $nextRequest->getUri()->getHost() - && defined('\CURLOPT_HTTPAUTH') - ) { + // If authorization is handled by curl, unset it if URI is cross-origin. + if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $nextRequest->getUri()) && defined('\CURLOPT_HTTPAUTH')) { unset( $options['curl'][\CURLOPT_HTTPAUTH], $options['curl'][\CURLOPT_USERPWD] @@ -198,38 +196,15 @@ public function modifyRequest(RequestInterface $request, array $options, Respons $modify['remove_headers'][] = 'Referer'; } - // Remove Authorization and Cookie headers if required. - if (self::shouldStripSensitiveHeaders($request->getUri(), $modify['uri'])) { + // Remove Authorization and Cookie headers if URI is cross-origin. + if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri'])) { $modify['remove_headers'][] = 'Authorization'; $modify['remove_headers'][] = 'Cookie'; } return Psr7\Utils::modifyRequest($request, $modify); } - /** - * Determine if we should strip sensitive headers from the request. - * - * We return true if either of the following conditions are true: - * - * 1. the host is different; - * 2. the scheme has changed, and now is non-https. - */ - private static function shouldStripSensitiveHeaders( - UriInterface $originalUri, - UriInterface $modifiedUri - ): bool { - if (\strcasecmp($originalUri->getHost(), $modifiedUri->getHost()) !== 0) { - return true; - } - - if ($originalUri->getScheme() !== $modifiedUri->getScheme() && 'https' !== $modifiedUri->getScheme()) { - return true; - } - - return false; - } - /** * Set the appropriate URL on the request based on the location header. */
tests/RedirectMiddlewareTest.php+122 −27 modified@@ -272,65 +272,105 @@ public function testInvokesOnRedirectForRedirects() self::assertTrue($call); } - public function crossOriginRedirectProvider() + /** + * @testWith ["digest"] + * ["ntlm"] + */ + public function testRemoveCurlAuthorizationOptionsOnRedirectCrossHost($auth) { - return [ - ['http://example.com?a=b', 'http://test.com/', false], - ['https://example.com?a=b', 'https://test.com/', false], - ['http://example.com?a=b', 'https://test.com/', false], - ['https://example.com?a=b', 'http://test.com/', false], - ['http://example.com?a=b', 'http://example.com/', true], - ['https://example.com?a=b', 'https://example.com/', true], - ['http://example.com?a=b', 'https://example.com/', true], - ['https://example.com?a=b', 'http://example.com/', false], - ]; + if (!defined('\CURLOPT_HTTPAUTH')) { + self::markTestSkipped('ext-curl is required for this test'); + } + + $mock = new MockHandler([ + new Response(302, ['Location' => 'http://test.com']), + static function (RequestInterface $request, $options) { + self::assertFalse( + isset($options['curl'][\CURLOPT_HTTPAUTH]), + 'curl options still contain CURLOPT_HTTPAUTH entry' + ); + self::assertFalse( + isset($options['curl'][\CURLOPT_USERPWD]), + 'curl options still contain CURLOPT_USERPWD entry' + ); + return new Response(200); + } + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $client->get('http://example.com?a=b', ['auth' => ['testuser', 'testpass', $auth]]); } /** - * @dataProvider crossOriginRedirectProvider + * @testWith ["digest"] + * ["ntlm"] */ - public function testHeadersTreatmentOnRedirect($originalUri, $targetUri, $shouldBePresent) + public function testRemoveCurlAuthorizationOptionsOnRedirectCrossPort($auth) { - $mock = new MockHandler([ - new Response(302, ['Location' => $targetUri]), - static function (RequestInterface $request) use ($shouldBePresent) { - self::assertSame($shouldBePresent, $request->hasHeader('Authorization')); - self::assertSame($shouldBePresent, $request->hasHeader('Cookie')); + if (!defined('\CURLOPT_HTTPAUTH')) { + self::markTestSkipped('ext-curl is required for this test'); + } + $mock = new MockHandler([ + new Response(302, ['Location' => 'http://example.com:81/']), + static function (RequestInterface $request, $options) { + self::assertFalse( + isset($options['curl'][\CURLOPT_HTTPAUTH]), + 'curl options still contain CURLOPT_HTTPAUTH entry' + ); + self::assertFalse( + isset($options['curl'][\CURLOPT_USERPWD]), + 'curl options still contain CURLOPT_USERPWD entry' + ); return new Response(200); } ]); $handler = HandlerStack::create($mock); $client = new Client(['handler' => $handler]); - $client->get($originalUri, ['auth' => ['testuser', 'testpass'], 'headers' => ['Cookie' => 'foo=bar']]); + $client->get('http://example.com?a=b', ['auth' => ['testuser', 'testpass', $auth]]); } - public function testNotRemoveAuthorizationHeaderOnRedirect() + /** + * @testWith ["digest"] + * ["ntlm"] + */ + public function testRemoveCurlAuthorizationOptionsOnRedirectCrossScheme($auth) { + if (!defined('\CURLOPT_HTTPAUTH')) { + self::markTestSkipped('ext-curl is required for this test'); + } + $mock = new MockHandler([ - new Response(302, ['Location' => 'http://example.com/2']), - static function (RequestInterface $request) { - self::assertTrue($request->hasHeader('Authorization')); + new Response(302, ['Location' => 'http://example.com?a=b']), + static function (RequestInterface $request, $options) { + self::assertFalse( + isset($options['curl'][\CURLOPT_HTTPAUTH]), + 'curl options still contain CURLOPT_HTTPAUTH entry' + ); + self::assertFalse( + isset($options['curl'][\CURLOPT_USERPWD]), + 'curl options still contain CURLOPT_USERPWD entry' + ); return new Response(200); } ]); $handler = HandlerStack::create($mock); $client = new Client(['handler' => $handler]); - $client->get('http://example.com?a=b', ['auth' => ['testuser', 'testpass']]); + $client->get('https://example.com?a=b', ['auth' => ['testuser', 'testpass', $auth]]); } /** * @testWith ["digest"] * ["ntlm"] */ - public function testRemoveCurlAuthorizationOptionsOnRedirect($auth) + public function testRemoveCurlAuthorizationOptionsOnRedirectCrossSchemeSamePort($auth) { if (!defined('\CURLOPT_HTTPAUTH')) { self::markTestSkipped('ext-curl is required for this test'); } $mock = new MockHandler([ - new Response(302, ['Location' => 'http://test.com']), + new Response(302, ['Location' => 'http://example.com:80?a=b']), static function (RequestInterface $request, $options) { self::assertFalse( isset($options['curl'][\CURLOPT_HTTPAUTH]), @@ -345,7 +385,7 @@ static function (RequestInterface $request, $options) { ]); $handler = HandlerStack::create($mock); $client = new Client(['handler' => $handler]); - $client->get('http://example.com?a=b', ['auth' => ['testuser', 'testpass', $auth]]); + $client->get('https://example.com?a=b', ['auth' => ['testuser', 'testpass', $auth]]); } /** @@ -377,6 +417,61 @@ static function (RequestInterface $request, $options) { $client->get('http://example.com?a=b', ['auth' => ['testuser', 'testpass', $auth]]); } + public function crossOriginRedirectProvider() + { + return [ + ['http://example.com/123', 'http://example.com/', false], + ['http://example.com/123', 'http://example.com:80/', false], + ['http://example.com:80/123', 'http://example.com/', false], + ['http://example.com:80/123', 'http://example.com:80/', false], + ['http://example.com/123', 'https://example.com/', true], + ['http://example.com/123', 'http://www.example.com/', true], + ['http://example.com/123', 'http://example.com:81/', true], + ['http://example.com:80/123', 'http://example.com:81/', true], + ['https://example.com/123', 'https://example.com/', false], + ['https://example.com/123', 'https://example.com:443/', false], + ['https://example.com:443/123', 'https://example.com/', false], + ['https://example.com:443/123', 'https://example.com:443/', false], + ['https://example.com/123', 'http://example.com/', true], + ['https://example.com/123', 'https://www.example.com/', true], + ['https://example.com/123', 'https://example.com:444/', true], + ['https://example.com:443/123', 'https://example.com:444/', true], + ]; + } + + /** + * @dataProvider crossOriginRedirectProvider + */ + public function testHeadersTreatmentOnRedirect($originalUri, $targetUri, $isCrossOrigin) + { + $mock = new MockHandler([ + new Response(302, ['Location' => $targetUri]), + static function (RequestInterface $request) use ($isCrossOrigin) { + self::assertSame(!$isCrossOrigin, $request->hasHeader('Authorization')); + self::assertSame(!$isCrossOrigin, $request->hasHeader('Cookie')); + + return new Response(200); + } + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $client->get($originalUri, ['auth' => ['testuser', 'testpass'], 'headers' => ['Cookie' => 'foo=bar']]); + } + + public function testNotRemoveAuthorizationHeaderOnRedirect() + { + $mock = new MockHandler([ + new Response(302, ['Location' => 'http://example.com/2']), + static function (RequestInterface $request) { + self::assertTrue($request->hasHeader('Authorization')); + return new Response(200); + } + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $client->get('http://example.com?a=b', ['auth' => ['testuser', 'testpass']]); + } + /** * Verifies how RedirectMiddleware::modifyRequest() modifies the method and body of a request issued when * encountering a redirect response.
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-q559-8m2m-g699ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-31091ghsaADVISORY
- security.gentoo.org/glsa/202305-24ghsavendor-advisoryWEB
- www.debian.org/security/2022/dsa-5246ghsavendor-advisoryWEB
- github.com/FriendsOfPHP/security-advisories/blob/master/guzzlehttp/guzzle/CVE-2022-31091.yamlghsaWEB
- github.com/guzzle/guzzle/commit/1dd98b0564cb3f6bd16ce683cb755f94c10fbd82ghsaWEB
- github.com/guzzle/guzzle/security/advisories/GHSA-q559-8m2m-g699ghsaWEB
News mentions
0No linked articles in our index yet.