Skip to content

Commit b1aa238

Browse files
authored
[PR #11106/cfb9931 backport][3.12] Fix cookies with duplicate names being lost when updating cookie jar (#11108)
1 parent 1710f05 commit b1aa238

11 files changed

+635
-25
lines changed

CHANGES/11105.bugfix.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Fixed an issue where cookies with duplicate names but different domains or paths
2+
were lost when updating the cookie jar. The :class:`~aiohttp.ClientSession`
3+
cookie jar now correctly stores all cookies even if they have the same name but
4+
different domain or path, following the :rfc:`6265#section-5.3` storage model -- by :user:`bdraco`.
5+
6+
Note that :attr:`ClientResponse.cookies <aiohttp.ClientResponse.cookies>` returns
7+
a :class:`~http.cookies.SimpleCookie` which uses the cookie name as a key, so
8+
only the last cookie with each name is accessible via this interface. All cookies
9+
can be accessed via :meth:`ClientResponse.headers.getall('Set-Cookie')
10+
<multidict.MultiDictProxy.getall>` if needed.

CHANGES/11106.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
11105.bugfix.rst

CHANGES/4486.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
11105.bugfix.rst

aiohttp/abc.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import socket
44
from abc import ABC, abstractmethod
55
from collections.abc import Sized
6-
from http.cookies import BaseCookie, Morsel
6+
from http.cookies import BaseCookie, CookieError, Morsel, SimpleCookie
77
from typing import (
88
TYPE_CHECKING,
99
Any,
@@ -14,6 +14,7 @@
1414
Iterable,
1515
List,
1616
Optional,
17+
Sequence,
1718
Tuple,
1819
TypedDict,
1920
Union,
@@ -22,6 +23,7 @@
2223
from multidict import CIMultiDict
2324
from yarl import URL
2425

26+
from .log import client_logger
2527
from .typedefs import LooseCookies
2628

2729
if TYPE_CHECKING:
@@ -192,6 +194,31 @@ def clear_domain(self, domain: str) -> None:
192194
def update_cookies(self, cookies: LooseCookies, response_url: URL = URL()) -> None:
193195
"""Update cookies."""
194196

197+
def update_cookies_from_headers(
198+
self, headers: Sequence[str], response_url: URL
199+
) -> None:
200+
"""
201+
Update cookies from raw Set-Cookie headers.
202+
203+
Default implementation parses each header separately to preserve
204+
cookies with same name but different domain/path.
205+
"""
206+
# Default implementation for backward compatibility
207+
cookies_to_update: List[Tuple[str, Morsel[str]]] = []
208+
for cookie_header in headers:
209+
tmp_cookie = SimpleCookie()
210+
try:
211+
tmp_cookie.load(cookie_header)
212+
# Collect all cookies as tuples (name, morsel)
213+
for name, morsel in tmp_cookie.items():
214+
cookies_to_update.append((name, morsel))
215+
except CookieError as exc:
216+
client_logger.warning("Can not load response cookies: %s", exc)
217+
218+
# Update all cookies at once for efficiency
219+
if cookies_to_update:
220+
self.update_cookies(cookies_to_update, response_url)
221+
195222
@abstractmethod
196223
def filter_cookies(self, request_url: URL) -> "BaseCookie[str]":
197224
"""Return the jar's cookies filtered by their attributes."""

aiohttp/client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,11 @@ async def _connect_and_send_request(
779779
raise
780780
raise ClientOSError(*exc.args) from exc
781781

782-
if cookies := resp._cookies:
783-
self._cookie_jar.update_cookies(cookies, resp.url)
782+
# Update cookies from raw headers to preserve duplicates
783+
if resp._raw_cookie_headers:
784+
self._cookie_jar.update_cookies_from_headers(
785+
resp._raw_cookie_headers, resp.url
786+
)
784787

785788
# redirects
786789
if resp.status in (301, 302, 303, 307, 308) and allow_redirects:

aiohttp/client_reqrep.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ class ClientResponse(HeadersMixin):
291291

292292
_connection: Optional["Connection"] = None # current connection
293293
_cookies: Optional[SimpleCookie] = None
294+
_raw_cookie_headers: Optional[Tuple[str, ...]] = None
294295
_continue: Optional["asyncio.Future[bool]"] = None
295296
_source_traceback: Optional[traceback.StackSummary] = None
296297
_session: Optional["ClientSession"] = None
@@ -372,12 +373,29 @@ def _writer(self, writer: Optional["asyncio.Task[None]"]) -> None:
372373
@property
373374
def cookies(self) -> SimpleCookie:
374375
if self._cookies is None:
375-
self._cookies = SimpleCookie()
376+
if self._raw_cookie_headers is not None:
377+
# Parse cookies for response.cookies (SimpleCookie for backward compatibility)
378+
cookies = SimpleCookie()
379+
for hdr in self._raw_cookie_headers:
380+
try:
381+
cookies.load(hdr)
382+
except CookieError as exc:
383+
client_logger.warning("Can not load response cookies: %s", exc)
384+
self._cookies = cookies
385+
else:
386+
self._cookies = SimpleCookie()
376387
return self._cookies
377388

378389
@cookies.setter
379390
def cookies(self, cookies: SimpleCookie) -> None:
380391
self._cookies = cookies
392+
# Generate raw cookie headers from the SimpleCookie
393+
if cookies:
394+
self._raw_cookie_headers = tuple(
395+
morsel.OutputString() for morsel in cookies.values()
396+
)
397+
else:
398+
self._raw_cookie_headers = None
381399

382400
@reify
383401
def url(self) -> URL:
@@ -543,13 +561,8 @@ async def start(self, connection: "Connection") -> "ClientResponse":
543561

544562
# cookies
545563
if cookie_hdrs := self.headers.getall(hdrs.SET_COOKIE, ()):
546-
cookies = SimpleCookie()
547-
for hdr in cookie_hdrs:
548-
try:
549-
cookies.load(hdr)
550-
except CookieError as exc:
551-
client_logger.warning("Can not load response cookies: %s", exc)
552-
self._cookies = cookies
564+
# Store raw cookie headers for CookieJar
565+
self._raw_cookie_headers = tuple(cookie_hdrs)
553566
return self
554567

555568
def _response_eof(self) -> None:

docs/client_reference.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,19 @@ Response object
14991499
HTTP cookies of response (*Set-Cookie* HTTP header,
15001500
:class:`~http.cookies.SimpleCookie`).
15011501

1502+
.. note::
1503+
1504+
Since :class:`~http.cookies.SimpleCookie` uses cookie name as the
1505+
key, cookies with the same name but different domains or paths will
1506+
be overwritten. Only the last cookie with a given name will be
1507+
accessible via this attribute.
1508+
1509+
To access all cookies, including duplicates with the same name,
1510+
use :meth:`response.headers.getall('Set-Cookie') <multidict.MultiDictProxy.getall>`.
1511+
1512+
The session's cookie jar will correctly store all cookies, even if
1513+
they are not accessible via this attribute.
1514+
15021515
.. attribute:: headers
15031516

15041517
A case-insensitive multidict proxy with HTTP headers of

tests/test_client_functional.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,6 +2712,7 @@ async def handler(request):
27122712
async with client.get("/") as resp:
27132713
assert 200 == resp.status
27142714
cookie_names = {c.key for c in client.session.cookie_jar}
2715+
_ = resp.cookies
27152716
assert cookie_names == {"c1", "c2"}
27162717

27172718
m_log.warning.assert_called_with("Can not load response cookies: %s", mock.ANY)
@@ -5111,3 +5112,148 @@ async def redirect_handler(request: web.Request) -> web.Response:
51115112
assert (
51125113
payload.close_called
51135114
), "Payload.close() was not called when InvalidUrlRedirectClientError (invalid origin) was raised"
5115+
5116+
5117+
async def test_amazon_like_cookie_scenario(aiohttp_client: AiohttpClient) -> None:
5118+
"""Test real-world cookie scenario similar to Amazon."""
5119+
5120+
class FakeResolver(AbstractResolver):
5121+
def __init__(self, port: int):
5122+
self._port = port
5123+
5124+
async def resolve(
5125+
self, host: str, port: int = 0, family: int = 0
5126+
) -> List[ResolveResult]:
5127+
if host in ("amazon.it", "www.amazon.it"):
5128+
return [
5129+
{
5130+
"hostname": host,
5131+
"host": "127.0.0.1",
5132+
"port": self._port,
5133+
"family": socket.AF_INET,
5134+
"proto": 0,
5135+
"flags": 0,
5136+
}
5137+
]
5138+
assert False, f"Unexpected host: {host}"
5139+
5140+
async def close(self) -> None:
5141+
"""Close the resolver if needed."""
5142+
5143+
async def handler(request: web.Request) -> web.Response:
5144+
response = web.Response(text="Login successful")
5145+
5146+
# Simulate Amazon-like cookies from the issue
5147+
cookies = [
5148+
"session-id=146-7423990-7621939; Domain=.amazon.it; "
5149+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/; "
5150+
"Secure; HttpOnly",
5151+
"session-id=147-8529641-8642103; Domain=.www.amazon.it; "
5152+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/; HttpOnly",
5153+
"session-id-time=2082758401l; Domain=.amazon.it; "
5154+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/; Secure",
5155+
"session-id-time=2082758402l; Domain=.www.amazon.it; "
5156+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/",
5157+
"ubid-acbit=257-7531983-5395266; Domain=.amazon.it; "
5158+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/; Secure",
5159+
'x-acbit="KdvJzu8W@Fx6Jj3EuNFLuP0N7OtkuCfs"; Version=1; '
5160+
"Domain=.amazon.it; Path=/; Secure; HttpOnly",
5161+
"at-acbit=Atza|IwEBIM-gLr8; Domain=.amazon.it; "
5162+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/; "
5163+
"Secure; HttpOnly",
5164+
'sess-at-acbit="4+6VzSJPHIFD/OqO264hFxIng8Y="; '
5165+
"Domain=.amazon.it; Expires=Mon, 31-May-2027 10:00:00 GMT; "
5166+
"Path=/; Secure; HttpOnly",
5167+
"lc-acbit=it_IT; Domain=.amazon.it; "
5168+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/",
5169+
"i18n-prefs=EUR; Domain=.amazon.it; "
5170+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/",
5171+
"av-profile=null; Domain=.amazon.it; "
5172+
"Expires=Mon, 31-May-2027 10:00:00 GMT; Path=/; Secure",
5173+
'user-pref-token="Am81ywsJ69xObBnuJ2FbilVH0mg="; '
5174+
"Domain=.amazon.it; Path=/; Secure",
5175+
]
5176+
5177+
for cookie in cookies:
5178+
response.headers.add("Set-Cookie", cookie)
5179+
5180+
return response
5181+
5182+
app = web.Application()
5183+
app.router.add_get("/", handler)
5184+
5185+
# Get the test server
5186+
server = await aiohttp_client(app)
5187+
port = server.port
5188+
5189+
# Create a new client session with our fake resolver
5190+
resolver = FakeResolver(port)
5191+
5192+
async with (
5193+
aiohttp.TCPConnector(resolver=resolver, force_close=True) as connector,
5194+
aiohttp.ClientSession(connector=connector) as session,
5195+
):
5196+
# Make request to www.amazon.it which will resolve to
5197+
# 127.0.0.1:port. This allows cookies for both .amazon.it
5198+
# and .www.amazon.it domains
5199+
resp = await session.get(f"http://www.amazon.it:{port}/")
5200+
5201+
# Check headers
5202+
cookie_headers = resp.headers.getall("Set-Cookie")
5203+
assert (
5204+
len(cookie_headers) == 12
5205+
), f"Expected 12 headers, got {len(cookie_headers)}"
5206+
5207+
# Check parsed cookies - SimpleCookie only keeps the last
5208+
# cookie with each name. So we expect 10 unique cookie names
5209+
# (not 12)
5210+
expected_cookie_names = {
5211+
"session-id", # Will only have one
5212+
"session-id-time", # Will only have one
5213+
"ubid-acbit",
5214+
"x-acbit",
5215+
"at-acbit",
5216+
"sess-at-acbit",
5217+
"lc-acbit",
5218+
"i18n-prefs",
5219+
"av-profile",
5220+
"user-pref-token",
5221+
}
5222+
assert set(resp.cookies.keys()) == expected_cookie_names
5223+
assert (
5224+
len(resp.cookies) == 10
5225+
), f"Expected 10 cookies in SimpleCookie, got {len(resp.cookies)}"
5226+
5227+
# The important part: verify the session's cookie jar has
5228+
# all cookies. The cookie jar should have all 12 cookies,
5229+
# not just 10
5230+
jar_cookies = list(session.cookie_jar)
5231+
assert (
5232+
len(jar_cookies) == 12
5233+
), f"Expected 12 cookies in jar, got {len(jar_cookies)}"
5234+
5235+
# Verify we have both session-id cookies with different domains
5236+
session_ids = [c for c in jar_cookies if c.key == "session-id"]
5237+
assert (
5238+
len(session_ids) == 2
5239+
), f"Expected 2 session-id cookies, got {len(session_ids)}"
5240+
5241+
# Verify the domains are different
5242+
session_id_domains = {c["domain"] for c in session_ids}
5243+
assert session_id_domains == {
5244+
"amazon.it",
5245+
"www.amazon.it",
5246+
}, f"Got domains: {session_id_domains}"
5247+
5248+
# Verify we have both session-id-time cookies with different
5249+
# domains
5250+
session_id_times = [c for c in jar_cookies if c.key == "session-id-time"]
5251+
assert (
5252+
len(session_id_times) == 2
5253+
), f"Expected 2 session-id-time cookies, got {len(session_id_times)}"
5254+
5255+
# Now test that the raw headers were properly preserved
5256+
assert resp._raw_cookie_headers is not None
5257+
assert (
5258+
len(resp._raw_cookie_headers) == 12
5259+
), "All raw headers should be preserved"

0 commit comments

Comments
 (0)