From 9848bb178a79dc5cab79e702f26a96978c5da8d5 Mon Sep 17 00:00:00 2001 From: Darsh Kelaiya Date: Mon, 28 Sep 2026 10:43:22 -0700 Subject: [PATCH] python3-aiohttp: fix CVE-2026-54279 This patch applies the reviewed upstream fix shown in [1]. The advisory identifying the fix is referenced in [2]. [1] https://github.com/aio-libs/aiohttp/commit/a329a7aacad5284f087af36103aff778746da0f2 [2] https://nvd.nist.gov/vuln/detail/CVE-2026-54279 Signed-off-by: Darsh Kelaiya Signed-off-by: Anuj Mittal --- .../python3-aiohttp/CVE-2026-54279.patch | 317 ++++++++++++++++++ .../python/python3-aiohttp_3.9.5.bb | 1 + 2 files changed, 318 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54279.patch diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54279.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54279.patch new file mode 100644 index 0000000000..8c735c209b --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54279.patch @@ -0,0 +1,317 @@ +From 7b86b0ebf15a848e2c8e61ff58186b3bd340edfb Mon Sep 17 00:00:00 2001 +From: "J. Nick Koston" +Date: Sun, 7 Jun 2026 00:40:24 -0500 +Subject: [PATCH] [PR #12824/60b85e98 backport][3.14] Preserve host-only cookie + scope across CookieJar save/load (#12833) + +CVE: CVE-2026-54279 +Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/a329a7aacad5284f087af36103aff778746da0f2] + +Backport Changes: +- Adapted regression tests to aiohttp 3.9.5's private + `_host_only_cookies` state because the newer public + `host_only_cookies` property is absent. +- Used `SimpleCookie` with `update_cookies()` because aiohttp 3.9.5 + predates the `update_cookies_from_headers()` test API. +- This fix depends on the JSON CookieJar persistence implementation + introduced by CVE-2026-34993.patch. Keep CVE-2026-34993.patch + earlier in SRC_URI. + +(cherry picked from commit a329a7aacad5284f087af36103aff778746da0f2) +Signed-off-by: Darsh Kelaiya +--- + CHANGES/12824.bugfix.rst | 1 + + aiohttp/cookiejar.py | 52 +++++++++----- + tests/test_cookiejar.py | 144 +++++++++++++++++++++++++++++++++++++++ + 3 files changed, 180 insertions(+), 17 deletions(-) + create mode 100644 CHANGES/12824.bugfix.rst + +diff --git a/CHANGES/12824.bugfix.rst b/CHANGES/12824.bugfix.rst +new file mode 100644 +index 000000000..f8dbd169c +--- /dev/null ++++ b/CHANGES/12824.bugfix.rst +@@ -0,0 +1 @@ ++Fixed :class:`~aiohttp.CookieJar` dropping the host-only flag of cookies when persisted with :meth:`~aiohttp.CookieJar.save` and reloaded with :meth:`~aiohttp.CookieJar.load`, so a cookie set without a ``Domain`` attribute is again scoped to the exact host that set it after a reload; the absolute expiration deadline is now persisted as well, so a reloaded cookie keeps its original lifetime instead of being rescheduled from the load time. :meth:`~aiohttp.CookieJar.load` now replaces the jar contents rather than merging onto prior state, and loaded cookies pass through the same acceptance rules as :meth:`~aiohttp.CookieJar.update_cookies`, so a cookie for an IP-address host is dropped when loaded into a jar created without ``unsafe=True`` -- by :user:`bdraco`. +diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py +index 376f7597a..4f694b6d7 100644 +--- a/aiohttp/cookiejar.py ++++ b/aiohttp/cookiejar.py +@@ -36,6 +36,9 @@ __all__ = ("CookieJar", "DummyCookieJar") + + CookieItem = Union[str, "Morsel[str]"] + ++# Not persisted; the absolute deadline is saved instead. ++_RELATIVE_EXPIRY_ATTRS = frozenset(("max-age", "expires")) ++ + + class _RestrictedCookieUnpickler(pickle.Unpickler): + """A restricted unpickler that only allows cookie-related types. +@@ -146,21 +149,28 @@ class CookieJar(AbstractCookieJar): + :class:`str` or :class:`pathlib.Path` instance. + """ + file_path = pathlib.Path(file_path) +- data: dict[str, dict[str, dict[str, str | bool]]] = {} ++ data: dict[str, dict[str, dict[str, str | bool | float]]] = {} + for (domain, path), cookie in self._cookies.items(): + key = f"{domain}|{path}" + data[key] = {} + for name, morsel in cookie.items(): +- morsel_data: dict[str, str | bool] = { ++ morsel_data: dict[str, str | bool | float] = { + "key": morsel.key, + "value": morsel.value, + "coded_value": morsel.coded_value, + } +- # Save all morsel attributes that have values ++ # Skip relative expiry; the absolute deadline is saved below. + for attr in morsel._reserved: # type: ignore[attr-defined] ++ if attr in _RELATIVE_EXPIRY_ATTRS: ++ continue + attr_val = morsel[attr] + if attr_val: + morsel_data[attr] = attr_val ++ # Persist or it reloads as a domain cookie and leaks to subdomains. ++ if (domain, name) in self._host_only_cookies: ++ morsel_data["host_only"] = True ++ if (exp := self._expirations.get((domain, path, name))) is not None: ++ morsel_data["expires_timestamp"] = exp + data[key][name] = morsel_data + with file_path.open(mode="w", encoding="utf-8") as f: + json.dump(data, f, indent=2) +@@ -172,6 +182,9 @@ class CookieJar(AbstractCookieJar): + pickle format (using a restricted unpickler) for backward + compatibility with existing cookie files. + ++ Replaces the current jar contents; loaded cookies pass through the ++ same acceptance rules as :meth:`update_cookies`. ++ + :param file_path: Path to file from where cookies will be + imported, :class:`str` or :class:`pathlib.Path` instance. + """ +@@ -180,32 +193,28 @@ class CookieJar(AbstractCookieJar): + try: + with file_path.open(mode="r", encoding="utf-8") as f: + data = json.load(f) +- self._cookies = self._load_json_data(data) ++ self._load_json_data(data) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + # Fall back to legacy pickle format with restricted unpickler + with file_path.open(mode="rb") as f: + self._cookies = _RestrictedCookieUnpickler(f).load() + + def _load_json_data( +- self, data: dict[str, dict[str, dict[str, str | bool]]] +- ) -> defaultdict[tuple[str, str], SimpleCookie]: +- """Load cookies from parsed JSON data.""" +- cookies: defaultdict[tuple[str, str], SimpleCookie] = defaultdict(SimpleCookie) ++ self, data: dict[str, dict[str, dict[str, str | bool | float]]] ++ ) -> None: ++ """Replace contents, routing cookies through update_cookies().""" ++ self.clear() + for compound_key, cookie_data in data.items(): + domain, path = compound_key.split("|", 1) +- key = (domain, path) + for name, morsel_data in cookie_data.items(): + morsel: Morsel[str] = Morsel() +- morsel_key = morsel_data["key"] +- morsel_value = morsel_data["value"] +- morsel_coded_value = morsel_data["coded_value"] + # Use __setstate__ to bypass validation, same pattern + # used in _build_morsel and _cookie_helpers. + morsel.__setstate__( # type: ignore[attr-defined] + { +- "key": morsel_key, +- "value": morsel_value, +- "coded_value": morsel_coded_value, ++ "key": morsel_data["key"], ++ "value": morsel_data["value"], ++ "coded_value": morsel_data["coded_value"], + } + ) + # Restore morsel attributes +@@ -216,8 +225,17 @@ class CookieJar(AbstractCookieJar): + "coded_value", + ): + morsel[attr] = morsel_data[attr] +- cookies[key][name] = morsel +- return cookies ++ # Drop the domain so update_cookies() re-marks it host-only. ++ if morsel_data.get("host_only"): ++ morsel["domain"] = "" ++ response_url = ( ++ URL.build(scheme="https", host=domain) if domain else URL() ++ ) ++ self.update_cookies({name: morsel}, response_url) ++ # Restore the absolute deadline; update_cookies() schedules none. ++ if (exp := morsel_data.get("expires_timestamp")) is not None: ++ self._expire_cookie(float(exp), domain, path, name) ++ self._do_expiration() + + def clear(self, predicate: Optional[ClearCookiePredicate] = None) -> None: + if predicate is None: +diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py +index bdc5cfd72..df59759c5 100644 +--- a/tests/test_cookiejar.py ++++ b/tests/test_cookiejar.py +@@ -1,6 +1,7 @@ + import asyncio + import datetime + import itertools ++import json + import pathlib + import pickle + import unittest +@@ -979,6 +980,149 @@ async def test_save_load_json_roundtrip( + assert saved_cookies == loaded_cookies + + ++async def test_save_load_json_preserves_host_only_scope(tmp_path: Path) -> None: ++ """Verify save/load keeps host-only cookies off subdomains.""" ++ file_path = tmp_path / "host_only.json" ++ issuer = URL("https://auth.example.com/login") ++ subdomain = URL("https://sub.auth.example.com/") ++ ++ jar_save = CookieJar() ++ jar_save.update_cookies({"sid": "hostonly"}, response_url=issuer) ++ assert "sid" not in jar_save.filter_cookies(subdomain) ++ jar_save.save(file_path=file_path) ++ ++ jar_load = CookieJar() ++ jar_load.load(file_path=file_path) ++ ++ assert jar_load._host_only_cookies == {("auth.example.com", "sid")} ++ assert "sid" not in jar_load.filter_cookies(subdomain) ++ assert "sid" in jar_load.filter_cookies(issuer) ++ ++ ++async def test_save_load_json_domain_cookie_still_matches_subdomain( ++ tmp_path: Path, ++) -> None: ++ """Verify save/load keeps an explicit Domain cookie valid for subdomains.""" ++ file_path = tmp_path / "domain.json" ++ subdomain = URL("https://sub.example.com/") ++ ++ jar_save = CookieJar() ++ jar_save.update_cookies( ++ SimpleCookie("sid=domaincookie; Domain=example.com"), ++ URL("https://example.com/"), ++ ) ++ jar_save.save(file_path=file_path) ++ ++ jar_load = CookieJar() ++ jar_load.load(file_path=file_path) ++ ++ assert jar_load._host_only_cookies == set() ++ assert "sid" in jar_load.filter_cookies(subdomain) ++ ++ ++async def test_save_load_json_preserves_max_age_deadline(tmp_path: Path) -> None: ++ """Verify save/load restores the absolute deadline without resetting it.""" ++ file_path = tmp_path / "max_age.json" ++ url = URL("https://example.com/") ++ ++ jar_save = CookieJar() ++ jar_save.update_cookies( ++ SimpleCookie("sid=x; Max-Age=3600; Domain=example.com"), url ++ ) ++ expirations = dict(jar_save._expirations) ++ jar_save.save(file_path=file_path) ++ ++ jar_load = CookieJar() ++ jar_load.load(file_path=file_path) ++ ++ # The deadline is restored as the original absolute time, not now + Max-Age. ++ assert dict(jar_load._expirations) == expirations ++ assert "sid" in jar_load.filter_cookies(url) ++ ++ ++async def test_save_load_json_drops_expired_cookie(tmp_path: Path) -> None: ++ """Verify a cookie whose persisted deadline is in the past is dropped on load.""" ++ file_path = tmp_path / "expired.json" ++ url = URL("https://example.com/") ++ ++ # Save a future-expiring cookie, then rewrite its persisted deadline to the ++ # past so the cookie survives save() and the drop happens on the load path. ++ jar_save = CookieJar() ++ jar_save.update_cookies( ++ SimpleCookie( ++ "sid=x; Expires=Tue, 1 Jan 2999 12:00:00 GMT; Domain=example.com" ++ ), ++ url, ++ ) ++ jar_save.save(file_path=file_path) ++ data = json.loads(file_path.read_text()) ++ _, cookies = next(iter(data.items())) ++ cookies["sid"]["expires_timestamp"] = 0.0 ++ file_path.write_text(json.dumps(data)) ++ ++ jar_load = CookieJar() ++ jar_load.load(file_path=file_path) ++ ++ assert len(jar_load) == 0 ++ assert "sid" not in jar_load.filter_cookies(url) ++ ++ ++async def test_save_load_json_preserves_expires_deadline(tmp_path: Path) -> None: ++ """Verify a future Expires deadline survives a save/load roundtrip.""" ++ file_path = tmp_path / "expires.json" ++ url = URL("https://example.com/") ++ ++ jar_save = CookieJar() ++ jar_save.update_cookies( ++ SimpleCookie( ++ "sid=x; Expires=Tue, 1 Jan 2999 12:00:00 GMT; Domain=example.com" ++ ), ++ url, ++ ) ++ expirations = dict(jar_save._expirations) ++ jar_save.save(file_path=file_path) ++ ++ jar_load = CookieJar() ++ jar_load.load(file_path=file_path) ++ ++ assert dict(jar_load._expirations) == expirations ++ assert "sid" in jar_load.filter_cookies(url) ++ ++ ++async def test_load_json_old_format_without_new_keys(tmp_path: Path) -> None: ++ """Verify a file written by an older version (no host_only/expires_timestamp) loads.""" ++ file_path = tmp_path / "old.json" ++ # Old schema: no host_only, no expires_timestamp; relative max-age morsel attr. ++ file_path.write_text( ++ json.dumps( ++ { ++ "example.com|/": { ++ "sid": { ++ "key": "sid", ++ "value": "x", ++ "coded_value": "x", ++ "domain": "example.com", ++ "max-age": "3600", ++ } ++ } ++ } ++ ) ++ ) ++ url = URL("https://example.com/") ++ subdomain = URL("https://sub.example.com/") ++ ++ jar_load = CookieJar() ++ # No exception when the new keys are absent. ++ jar_load.load(file_path=file_path) ++ ++ # The old schema has a domain field but no host_only marker, so a cookie ++ # originally set without Domain is indistinguishable from a domain cookie. ++ assert "sid" in jar_load.filter_cookies(url) ++ assert "sid" in jar_load.filter_cookies(subdomain) ++ # max-age is rescheduled from load time rather than an absolute deadline. ++ assert any(key[2] == "sid" for key in jar_load._expirations) ++ ++ + async def test_json_format_is_safe(tmp_path: Path) -> None: + """Verify the JSON file format cannot execute code on load.""" + import json +-- +2.35.6 + diff --git a/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb b/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb index f91c9cb181..88ae49dec4 100644 --- a/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb +++ b/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb @@ -33,6 +33,7 @@ SRC_URI += "file://CVE-2024-52304.patch \ file://CVE-2026-54275.patch \ file://CVE-2026-54277.patch \ file://CVE-2026-54278.patch \ + file://CVE-2026-54279.patch \ " CVE_STATUS[CVE-2026-34515] = "not-applicable-platform: Vulnerability only affects applications running on Windows"