CVE-2022-29248: Reliance on Cookies without Validation and Integrity Checking (CWE-565)
value: Guzzle is a PHP HTTP client. Guzzle prior to versions 6.5.6 and 7.4.3 contains a vulnerability with the cookie middleware. The vulnerability is that it is not checked if the cookie domain equals the domain of the server which sets the cookie via the Set-Cookie header, allowing a malicious server to set cookies for unrelated domains. The cookie middleware is disabled by default, so most library consumers will not be affected by this issue. Only those who manually add the cookie middleware to the handler stack or construct the client with ['cookies' => true] are affected. Moreover, those who do not use the same Guzzle client to call multiple domains and have disabled redirect forwarding are not affected by this vulnerability. Guzzle versions 6.5.6 and 7.4.3 contain a patch for this issue. As a workaround, turn off the cookie middleware.
CWE description: The product relies on the existence or values of cookies when performing security-critical operations, but it does not properly ensure that the setting is valid for the associated user.
CVSS v3 base score: 8.1
Published: 2022-05-25T18:15Z
Repository: https://github.com/guzzle/guzzle
Language: PHP
Files changed: CHANGELOG.md, README.md, src/Cookie/CookieJar.php, src/Cookie/SetCookie.php, tests/Cookie/CookieJarTest.php
Commit date: 2022-05-25 14:24:33 +0100
Commit message: [7.x] Fix cross-domain cookie leakage (#3018)

Co-authored-by: Tim Düsterhus <209270+TimWolla@users.noreply.github.com>
Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index a078bfbb..8d19ea86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,13 @@
 # Change Log
 
 Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version.
 
+## 7.4.3 - 2022-05-25
+
+* Fix cross-domain cookie leakage
+
 ## 7.4.2 - 2022-03-20
 
 ### Fixed
 
 - Remove curl auth on cross-domain redirects to align with the Authorization HTTP header
diff --git a/README.md b/README.md
index ea0781a6..c96b246e 100644
--- a/README.md
+++ b/README.md
@@ -58,17 +58,17 @@ composer require guzzlehttp/guzzle
 ```
 
 
 ## Version Guidance
 
-| Version | Status     | Packagist           | Namespace    | Repo                | Docs                | PSR-7 | PHP Version |
-|---------|------------|---------------------|--------------|---------------------|---------------------|-------|-------------|
-| 3.x     | EOL        | `guzzle/guzzle`     | `Guzzle`     | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No    | >= 5.3.3    |
-| 4.x     | EOL        | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A                 | No    | >= 5.4      |
-| 5.x     | EOL        | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No    | >= 5.4      |
-| 6.x     | Security fixes | `guzzlehttp/guzzle` | `GuzzleHttp` | [v6][guzzle-6-repo] | [v6][guzzle-6-docs] | Yes   | >= 5.5      |
-| 7.x     | Latest     | `guzzlehttp/guzzle` | `GuzzleHttp` | [v7][guzzle-7-repo] | [v7][guzzle-7-docs] | Yes   | >= 7.2      |
+| Version | Status         | Packagist           | Namespace    | Repo                | Docs                | PSR-7 | PHP Version  |
+|---------|----------------|---------------------|--------------|---------------------|---------------------|-------|--------------|
+| 3.x     | EOL            | `guzzle/guzzle`     | `Guzzle`     | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No    | >=5.3.3,<7.0 |
+| 4.x     | EOL            | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A                 | No    | >=5.4,<7.0   |
+| 5.x     | EOL            | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No    | >=5.4,<7.4   |
+| 6.x     | Security fixes | `guzzlehttp/guzzle` | `GuzzleHttp` | [v6][guzzle-6-repo] | [v6][guzzle-6-docs] | Yes   | >=5.5,<8.0   |
+| 7.x     | Latest         | `guzzlehttp/guzzle` | `GuzzleHttp` | [v7][guzzle-7-repo] | [v7][guzzle-7-docs] | Yes   | >=7.2.5,<8.2 |
 
 [guzzle-3-repo]: https://github.com/guzzle/guzzle3
 [guzzle-4-repo]: https://github.com/guzzle/guzzle/tree/4.x
 [guzzle-5-repo]: https://github.com/guzzle/guzzle/tree/5.3
 [guzzle-6-repo]: https://github.com/guzzle/guzzle/tree/6.5
diff --git a/src/Cookie/CookieJar.php b/src/Cookie/CookieJar.php
index d6757c65..6ef8e8c1 100644
--- a/src/Cookie/CookieJar.php
+++ b/src/Cookie/CookieJar.php
@@ -239,10 +239,15 @@ class CookieJar implements CookieJarInterface
                     $sc->setDomain($request->getUri()->getHost());
                 }
                 if (0 !== \strpos($sc->getPath(), '/')) {
                     $sc->setPath($this->getCookiePathFromRequest($request));
                 }
+                if (!$sc->matchesDomain($request->getUri()->getHost())) {
+                    continue;
+                }
+                // Note: At this point `$sc->getDomain()` being a public suffix should
+                // be rejected, but we don't want to pull in the full PSL dependency.
                 $this->setCookie($sc);
             }
         }
     }
 
diff --git a/src/Cookie/SetCookie.php b/src/Cookie/SetCookie.php
index 7c04034d..a613c77b 100644
--- a/src/Cookie/SetCookie.php
+++ b/src/Cookie/SetCookie.php
@@ -377,14 +377,16 @@ class SetCookie
             return true;
         }
 
         // Remove the leading '.' as per spec in RFC 6265.
         // https://tools.ietf.org/html/rfc6265#section-5.2.3
-        $cookieDomain = \ltrim($cookieDomain, '.');
+        $cookieDomain = \ltrim(\strtolower($cookieDomain), '.');
+
+        $domain = \strtolower($domain);
 
         // Domain not set or exact match.
-        if (!$cookieDomain || !\strcasecmp($domain, $cookieDomain)) {
+        if ('' === $cookieDomain || $domain === $cookieDomain) {
             return true;
         }
 
         // Matching the subdomain according to RFC 6265.
         // https://tools.ietf.org/html/rfc6265#section-5.1.3
diff --git a/tests/Cookie/CookieJarTest.php b/tests/Cookie/CookieJarTest.php
index 66f9b4a2..0b67c665 100644
--- a/tests/Cookie/CookieJarTest.php
+++ b/tests/Cookie/CookieJarTest.php
@@ -269,11 +269,11 @@ class CookieJarTest extends TestCase
     }
 
     /**
      * @dataProvider getMatchingCookiesDataProvider
      */
-    public function testReturnsCookiesMatchingRequests($url, $cookies)
+    public function testReturnsCookiesMatchingRequests(string $url, string $cookies)
     {
         $bag = [
             new SetCookie([
                 'Name'    => 'foo',
                 'Value'   => 'bar',
@@ -384,20 +384,17 @@ class CookieJarTest extends TestCase
             ['', '/'],
             ['/', '/'],
             ['/foo', '/'],
             ['/foo/bar', '/foo'],
             ['/foo/bar/', '/foo/bar'],
-            ['foo', '/'],
-            ['foo/bar', '/'],
-            ['foo/bar/', '/'],
         ];
     }
 
     /**
      * @dataProvider getCookiePathsDataProvider
      */
-    public function testCookiePathWithEmptySetCookiePath($uriPath, $cookiePath)
+    public function testCookiePathWithEmptySetCookiePath(string $uriPath, string $cookiePath)
     {
         $response = (new Response(200))
             ->withAddedHeader(
                 'Set-Cookie',
                 "foo=bar; expires={$this->futureExpirationDate()}; domain=www.example.com; path=;"
@@ -405,17 +402,49 @@ class CookieJarTest extends TestCase
             ->withAddedHeader(
                 'Set-Cookie',
                 "bar=foo; expires={$this->futureExpirationDate()}; domain=www.example.com; path=foobar;"
             )
         ;
-        $request = (new Request('GET', $uriPath))->withHeader('Host', 'www.example.com');
+        $request = (new Request('GET', "https://www.example.com{$uriPath}"));
         $this->jar->extractCookies($request, $response);
 
         self::assertSame($cookiePath, $this->jar->toArray()[0]['Path']);
         self::assertSame($cookiePath, $this->jar->toArray()[1]['Path']);
     }
 
+    public function getDomainMatchesProvider()
+    {
+        return [
+            ['www.example.com', 'www.example.com', true],
+            ['www.example.com', 'www.EXAMPLE.com', true],
+            ['www.example.com', 'www.example.net', false],
+            ['www.example.com', 'ftp.example.com', false],
+            ['www.example.com', 'example.com', true],
+            ['www.example.com', 'EXAMPLE.com', true],
+            ['fra.de.example.com', 'EXAMPLE.com', true],
+            ['www.EXAMPLE.com', 'www.example.com', true],
+            ['www.EXAMPLE.com', 'www.example.COM', true],
+        ];
+    }
+
+    /**
+     * @dataProvider getDomainMatchesProvider
+     */
+    public function testIgnoresCookiesForMismatchingDomains(string $requestHost, string $domainAttribute, bool $matches)
+    {
+        $response = (new Response(200))
+            ->withAddedHeader(
+                'Set-Cookie',
+                "foo=bar; expires={$this->futureExpirationDate()}; domain={$domainAttribute}; path=/;"
+            )
+        ;
+        $request = (new Request('GET', "https://{$requestHost}/"));
+        $this->jar->extractCookies($request, $response);
+
+        self::assertCount($matches ? 1 : 0, $this->jar->toArray());
+    }
+
     private function futureExpirationDate()
     {
         return (new DateTimeImmutable)->add(new DateInterval('P1D'))->format(DateTime::COOKIE);
     }
 }

Vulnerable code: | Version | Status     | Packagist           | Namespace    | Repo                | Docs                | PSR-7 | PHP Version |
|---------|------------|---------------------|--------------|---------------------|---------------------|-------|-------------|
| 3.x     | EOL        | `guzzle/guzzle`     | `Guzzle`     | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No    | >= 5.3.3    |
| 4.x     | EOL        | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A                 | No    | >= 5.4      |
| 5.x     | EOL        | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No    | >= 5.4      |
| 6.x     | Security fixes | `guzzlehttp/guzzle` | `GuzzleHttp` | [v6][guzzle-6-repo] | [v6][guzzle-6-docs] | Yes   | >= 5.5      |
| 7.x     | Latest     | `guzzlehttp/guzzle` | `GuzzleHttp` | [v7][guzzle-7-repo] | [v7][guzzle-7-docs] | Yes   | >= 7.2      |
        $cookieDomain = \ltrim($cookieDomain, '.');
        if (!$cookieDomain || !\strcasecmp($domain, $cookieDomain)) {
    public function testReturnsCookiesMatchingRequests($url, $cookies)
            ['foo', '/'],
            ['foo/bar', '/'],
            ['foo/bar/', '/'],
    public function testCookiePathWithEmptySetCookiePath($uriPath, $cookiePath)
        $request = (new Request('GET', $uriPath))->withHeader('Host', 'www.example.com');
Fixed code: ## 7.4.3 - 2022-05-25

* Fix cross-domain cookie leakage

| Version | Status         | Packagist           | Namespace    | Repo                | Docs                | PSR-7 | PHP Version  |
|---------|----------------|---------------------|--------------|---------------------|---------------------|-------|--------------|
| 3.x     | EOL            | `guzzle/guzzle`     | `Guzzle`     | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No    | >=5.3.3,<7.0 |
| 4.x     | EOL            | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A                 | No    | >=5.4,<7.0   |
| 5.x     | EOL            | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No    | >=5.4,<7.4   |
| 6.x     | Security fixes | `guzzlehttp/guzzle` | `GuzzleHttp` | [v6][guzzle-6-repo] | [v6][guzzle-6-docs] | Yes   | >=5.5,<8.0   |
| 7.x     | Latest         | `guzzlehttp/guzzle` | `GuzzleHttp` | [v7][guzzle-7-repo] | [v7][guzzle-7-docs] | Yes   | >=7.2.5,<8.2 |
                if (!$sc->matchesDomain($request->getUri()->getHost())) {
                    continue;
                }
                // Note: At this point `$sc->getDomain()` being a public suffix should
                // be rejected, but we don't want to pull in the full PSL dependency.
        $cookieDomain = \ltrim(\strtolower($cookieDomain), '.');

        $domain = \strtolower($domain);
        if ('' === $cookieDomain || $domain === $cookieDomain) {
    public function testReturnsCookiesMatchingRequests(string $url, string $cookies)
    public function testCookiePathWithEmptySetCookiePath(string $uriPath, string $cookiePath)
        $request = (new Request('GET', "https://www.example.com{$uriPath}"));
    public function getDomainMatchesProvider()
    {
        return [
            ['www.example.com', 'www.example.com', true],
            ['www.example.com', 'www.EXAMPLE.com', true],
            ['www.example.com', 'www.example.net', false],
            ['www.example.com', 'ftp.example.com', false],
            ['www.example.com', 'example.com', true],
            ['www.example.com', 'EXAMPLE.com', true],
            ['fra.de.example.com', 'EXAMPLE.com', true],
            ['www.EXAMPLE.com', 'www.example.com', true],
            ['www.EXAMPLE.com', 'www.example.COM', true],
        ];
    }

    /**
     * @dataProvider getDomainMatchesProvider
     */
    public function testIgnoresCookiesForMismatchingDomains(string $requestHost, string $domainAttribute, bool $matches)
    {
        $response = (new Response(200))
            ->withAddedHeader(
                'Set-Cookie',
                "foo=bar; expires={$this->futureExpirationDate()}; domain={$domainAttribute}; path=/;"
            )
        ;
        $request = (new Request('GET', "https://{$requestHost}/"));
        $this->jar->extractCookies($request, $response);

        self::assertCount($matches ? 1 : 0, $this->jar->toArray());
    }