From 1857e6df6e9efce10dcf08a4ee26a366add2e443 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 5 Sep 2026 02:53:07 +0100 Subject: [PATCH 1/2] Fix mutable morsels (#13637) --- CHANGES/13637.bugfix.rst | 1 + aiohttp/cookiejar.py | 23 ++++++++++++++--- tests/test_cookiejar.py | 54 +++++++++++++++++++++++++++++++++++----- 3 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 CHANGES/13637.bugfix.rst diff --git a/CHANGES/13637.bugfix.rst b/CHANGES/13637.bugfix.rst new file mode 100644 index 00000000000..49a8ac91986 --- /dev/null +++ b/CHANGES/13637.bugfix.rst @@ -0,0 +1 @@ +Fixed ``CookieJar.update_cookies()`` to copy user-passed mutable ``Morsel`` objects -- by :user:`Dreamsorcerer`. diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py index d4e474ab98a..a5a1052970d 100644 --- a/aiohttp/cookiejar.py +++ b/aiohttp/cookiejar.py @@ -10,14 +10,14 @@ import time import warnings from collections import defaultdict -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Iterable, Iterator, Mapping, Sequence from http.cookies import BaseCookie, Morsel, SimpleCookie from types import MappingProxyType -from typing import Union +from typing import Union, cast from yarl import URL -from ._cookie_helpers import preserve_morsel_with_coded_value +from ._cookie_helpers import parse_set_cookie_headers, preserve_morsel_with_coded_value from .abc import AbstractCookieJar, ClearCookiePredicate from .helpers import is_ip_address from .typedefs import LooseCookies, PathLike, StrOrURL @@ -324,6 +324,20 @@ def _expire_cookie(self, when: float, domain: str, path: str, name: str) -> None def update_cookies(self, cookies: LooseCookies, response_url: URL = URL()) -> None: """Update cookies.""" + self._update_cookies(cookies, response_url, copy_morsels=True) + + def update_cookies_from_headers( + self, headers: Sequence[str], response_url: URL + ) -> None: + """Update cookies from raw Set-Cookie headers.""" + if headers and (cookies_to_update := parse_set_cookie_headers(headers)): + # The freshly parsed Morsels are not shared with the caller, + # so they can be stored and normalized without a defensive copy. + self._update_cookies(cookies_to_update, response_url, copy_morsels=False) + + def _update_cookies( + self, cookies: LooseCookies, response_url: URL, *, copy_morsels: bool + ) -> None: hostname = response_url.raw_host if not self._unsafe and is_ip_address(hostname): @@ -338,6 +352,9 @@ def update_cookies(self, cookies: LooseCookies, response_url: URL = URL()) -> No tmp = SimpleCookie() tmp[name] = cookie # type: ignore[assignment] cookie = tmp[name] + elif copy_morsels: + # TODO(https://github.com/python/typeshed/pull/16346): Remove cast + cookie = cast("Morsel[str]", cookie.copy()) domain = cookie["domain"] diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index 9fa150a5d9a..d020305f3d3 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -166,11 +166,13 @@ async def test_constructor( ) -> None: jar = CookieJar() jar.update_cookies(cookies_to_send) - jar_cookies = SimpleCookie() - for cookie in jar: - dict.__setitem__(jar_cookies, cookie.key, cookie) - expected_cookies = cookies_to_send - assert jar_cookies == expected_cookies + jar_cookies = {cookie.key: cookie for cookie in jar} + assert jar_cookies.keys() == cookies_to_send.keys() + for name, expected in cookies_to_send.items(): + # The jar stores normalized copies, so only the parts that + # normalization must not touch are compared here. + assert jar_cookies[name].value == expected.value + assert jar_cookies[name].coded_value == expected.coded_value async def test_constructor_with_expired( @@ -204,7 +206,12 @@ def test_save_load( for cookie in jar_load: jar_test[cookie.key] = cookie - assert jar_test == cookies_to_receive + # The jar stores normalized copies of the received cookies, so the + # round-tripped contents are compared against the saved jar itself. + jar_expected = SimpleCookie() + for cookie in jar_save: + dict.__setitem__(jar_expected, cookie.key, cookie) + assert jar_test == jar_expected def test_save_load_partitioned_cookies(tmp_path: Path) -> None: @@ -1425,6 +1432,41 @@ def test_dummy_cookie_jar_update_cookies_from_headers() -> None: assert len(filtered) == 0 +def test_update_cookies_copies_caller_morsel() -> None: + """Test that mutating a Morsel after update_cookies() does not change the jar. + + https://github.com/aio-libs/aiohttp/issues/13634 + """ + jar = CookieJar() + url = URL("http://example.com/") + sc = SimpleCookie() + sc["auth"] = "original-value" + jar.update_cookies({"auth": sc["auth"]}, url) + + # Mutate the caller's Morsel after the jar has stored it. + sc["auth"].set("auth", "mutated-value", "mutated-value") + + assert jar.filter_cookies(url)["auth"].value == "original-value" + + +def test_update_cookies_does_not_mutate_caller_morsel() -> None: + """Test that update_cookies() normalization does not leak into the caller's Morsel. + + https://github.com/aio-libs/aiohttp/issues/13634 + """ + jar = CookieJar() + sc = SimpleCookie() + sc["sid"] = "value" + jar.update_cookies({"sid": sc["sid"]}, URL("http://example.com/sub/page")) + + # The jar normalizes its private copy, not the caller's object. + assert sc["sid"]["domain"] == "" + assert sc["sid"]["path"] == "" + + filtered = jar.filter_cookies(URL("http://example.com/sub/page")) + assert filtered["sid"].value == "value" + + async def test_shared_cookie_cache_population() -> None: """Test that shared cookies are cached correctly.""" jar = CookieJar(unsafe=True) From f9c8c70245843b1c0a1becad439f5d1876918bb6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:05:16 +0000 Subject: [PATCH 2/2] Bump platformdirs from 4.11.5 to 4.11.6 (#13633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [platformdirs](https://github.com/tox-dev/platformdirs) from 4.11.5 to 4.11.6.
Release notes

Sourced from platformdirs's releases.

4.11.6

What's Changed

Full Changelog: https://github.com/tox-dev/platformdirs/compare/4.11.5...4.11.6

Changelog

Sourced from platformdirs's changelog.

########### Changelog ###########

.. towncrier-draft-entries:: Unreleased

.. towncrier release notes start


4.11.7 (2026-09-01)



4.11.6 (2026-09-01)


  • Give :func:~platformdirs.user_bin_dir and :func:~platformdirs.user_bin_path the use_site_for_root argument. They took none, so neither could reach the Unix redirect of root to :func:~platformdirs.site_bin_dir. :pr:537

4.11.5 (2026-08-27)


  • Give :func:~platformdirs.user_preference_dir and :func:~platformdirs.user_preference_path the same arguments as :func:~platformdirs.user_config_dir. Added without arguments in :pr:491, they could only return the unscoped base directory even though the property they wrap appends the app name and version. :pr:531
  • Make :func:~platformdirs.site_applications_path return the first entry when multipath=True, matching :func:~platformdirs.site_data_path. On Unix and macOS it passed the whole $XDG_DATA_DIRS list to :class:~pathlib.Path, giving one unusable path such as /first/applications:/second/applications. :pr:532
  • Give :func:~platformdirs.user_applications_dir, :func:~platformdirs.user_applications_path, :func:~platformdirs.site_applications_dir and :func:~platformdirs.site_applications_path the app arguments. Android scopes both applications directories to the app, so without them the functions could only return the unscoped base directory there. On the two site functions they are keyword-only, keeping multipath first positional as it has been since 4.9.0; the two user functions take their boolean options keyword-only. :pr:534
  • Correct the ordering note on the iterator methods. use_site_for_root drops the user directory entirely, so the iterators are documented as yielding the most specific directory first rather than always yielding the user one. :pr:533

4.11.4 (2026-08-24)


  • Stop the iter_*_dirs methods yielding the same directory twice when a site directory resolves to its user equivalent - :pr:520 covered only Unix with use_site_for_root. It also hit :meth:~platformdirs.PlatformDirs.iter_runtime_dirs on Unix with $XDG_RUNTIME_DIR set, on Windows and macOS, and all six iterators on Android. :pr:524
  • Fix the config merging example in the how-to guide. iter_config_paths yields the user directory first, so the config.update loop let the site defaults override the user's config instead of the other way round. :pr:529

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=platformdirs&package-manager=pip&previous-version=4.11.5&new-version=4.11.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/constraints.txt | 2 +- requirements/dev.txt | 2 +- requirements/lint.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index d669aec7ba8..d47666572c3 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -168,7 +168,7 @@ pip-tools==7.6.1 # via -r requirements/dev.in pkgconfig==1.6.0 # via -r requirements/test-common-base.in -platformdirs==4.11.5 +platformdirs==4.11.6 # via virtualenv pluggy==1.6.0 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index 190ae2cfc3b..5df52c1016d 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -165,7 +165,7 @@ pip-tools==7.6.1 # via -r requirements/dev.in pkgconfig==1.6.0 # via -r requirements/test-common-base.in -platformdirs==4.11.5 +platformdirs==4.11.6 # via virtualenv pluggy==1.6.0 # via diff --git a/requirements/lint.txt b/requirements/lint.txt index 3158ce7b061..f32cf63b327 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -86,7 +86,7 @@ packaging==26.3 # via pytest pathspec==1.1.1 # via mypy -platformdirs==4.11.5 +platformdirs==4.11.6 # via virtualenv pluggy==1.6.0 # via pytest