new file mode 100644
@@ -0,0 +1,205 @@
+From 6a07c171ef9f3be583e2907dcd1931372efca8d6 Mon Sep 17 00:00:00 2001
+From: Sam Bull <git@sambull.org>
+Date: Wed, 9 Sep 2026 02:21:06 +0100
+Subject: [PATCH] Fix host-only state on cookie expiration (#13674) (#13676)
+
+(cherry picked from commit 9332972cfcb0f5a2b6677e7307819ab8f13b5d09)
+
+CVE: CVE-2026-111749
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/6a07c171ef9f3be583e2907dcd1931372efca8d6]
+
+Backport Changes:
+- Refresh the runtime hunks for aiohttp 3.13.5 and its existing
+ JSON cookie persistence backport; retain Set/Tuple type spelling.
+- Omit annotation changes for the public host_only_cookies property
+ in CookieJar, DummyCookieJar, AbstractCookieJar and the test jar.
+ That property and its documentation were introduced in 3.14.
+- Omit the absent public-property test update and use private
+ _host_only_cookies assertions in the regression and JSON tests.
+ Adapt both existing JSON scope assertions to the private state.
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ CHANGES/13674.bugfix.rst | 1 +
+ aiohttp/cookiejar.py | 25 +++++++++-------
+ tests/test_cookiejar.py | 64 ++++++++++++++++++++++++++++++++++++++--
+ 3 files changed, 77 insertions(+), 13 deletions(-)
+ create mode 100644 CHANGES/13674.bugfix.rst
+
+diff --git a/CHANGES/13674.bugfix.rst b/CHANGES/13674.bugfix.rst
+new file mode 100644
+index 0000000..5152bce
+--- /dev/null
++++ b/CHANGES/13674.bugfix.rst
+@@ -0,0 +1 @@
++Fixed host-only cookie state being lost on expiration -- by :user:`Dreamsorcerer`.
+diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py
+index b07d1fd..c5e5b09 100644
+--- a/aiohttp/cookiejar.py
++++ b/aiohttp/cookiejar.py
+@@ -139,7 +139,8 @@ class CookieJar(AbstractCookieJar):
+ self._morsel_cache: DefaultDict[Tuple[str, str], Dict[str, Morsel[str]]] = (
+ defaultdict(dict)
+ )
+- self._host_only_cookies: Set[Tuple[str, str]] = set()
++ # Cookie identity is (domain, path, name).
++ self._host_only_cookies: Set[Tuple[str, str, str]] = set()
+ self._unsafe = unsafe
+ self._quote_cookie = quote_cookie
+ if treat_as_secure_origin is None:
+@@ -186,7 +187,7 @@ class CookieJar(AbstractCookieJar):
+ 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:
++ if (domain, path, 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
+@@ -340,7 +341,7 @@ class CookieJar(AbstractCookieJar):
+
+ def _delete_cookies(self, to_del: List[Tuple[str, str, str]]) -> None:
+ for domain, path, name in to_del:
+- self._host_only_cookies.discard((domain, name))
++ self._host_only_cookies.discard((domain, path, name))
+ self._cookies[(domain, path)].pop(name, None)
+ self._morsel_cache[(domain, path)].pop(name, None)
+ self._expirations.pop((domain, path, name), None)
+@@ -377,18 +378,12 @@ class CookieJar(AbstractCookieJar):
+ domain = ""
+ del cookie["domain"]
+
+- if not domain and hostname is not None:
+- # Set the cookie's domain to the response hostname
+- # and set its host-only-flag
+- self._host_only_cookies.add((hostname, name))
+- domain = cookie["domain"] = hostname
+-
+ if domain and domain[0] == ".":
+ # Remove leading dot
+ domain = domain[1:]
+ cookie["domain"] = domain
+
+- if hostname and not self._is_domain_match(domain, hostname):
++ if domain and hostname and not self._is_domain_match(domain, hostname):
+ # Setting cookies for different domains is not allowed
+ continue
+
+@@ -404,6 +399,14 @@ class CookieJar(AbstractCookieJar):
+ cookie["path"] = path
+ path = path.rstrip("/")
+
++ if not domain and hostname is not None:
++ self._host_only_cookies.add((hostname, path, name))
++ domain = cookie["domain"] = hostname
++ else:
++ # A cookie with an explicit Domain attribute replaces any
++ # host-only cookie with the same (domain, path, name) identity.
++ self._host_only_cookies.discard((domain, path, name))
++
+ if max_age := cookie["max-age"]:
+ try:
+ delta_seconds = int(max_age)
+@@ -493,7 +496,7 @@ class CookieJar(AbstractCookieJar):
+ for name, cookie in self._cookies[p].items():
+ domain = cookie["domain"]
+
+- if (domain, name) in self._host_only_cookies and domain != hostname:
++ if domain != hostname and p + (name,) in self._host_only_cookies:
+ continue
+
+ # Skip edge case when the cookie has a trailing slash but request doesn't.
+diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py
+index ba7ee32..f82861e 100644
+--- a/tests/test_cookiejar.py
++++ b/tests/test_cookiejar.py
+@@ -1625,6 +1625,44 @@ async def test_shared_cookie_with_multiple_domains() -> None:
+ assert "universal" in jar._morsel_cache[("", "")]
+
+
++async def test_host_only_marker_survives_same_name_expiry_on_other_path() -> None:
++ """Expiring a same-name cookie on another path must not clear host-only state."""
++ jar = CookieJar()
++ origin = URL("http://auth.example.com/")
++ subdomain = URL("http://evil.auth.example.com/")
++
++ jar.update_cookies_from_headers(["sid=secret; Path=/"], origin)
++ assert "sid" not in jar.filter_cookies(subdomain)
++
++ # Attacker-controlled descendant expires a same-name cookie on its own path.
++ jar.update_cookies_from_headers(
++ ["sid=gone; Domain=auth.example.com; Path=/attacker; Max-Age=0"],
++ subdomain,
++ )
++
++ assert ("auth.example.com", "", "sid") in jar._host_only_cookies
++ assert "sid" not in jar.filter_cookies(subdomain)
++ assert jar.filter_cookies(origin)["sid"].value == "secret"
++
++
++async def test_explicit_domain_replacement_clears_host_only_marker() -> None:
++ """A replacing cookie with an explicit Domain is a domain cookie."""
++ jar = CookieJar()
++ origin = URL("http://example.com/")
++ subdomain = URL("http://sub.example.com/")
++
++ jar.update_cookies_from_headers(["sid=hostonly; Path=/"], origin)
++ assert ("example.com", "", "sid") in jar._host_only_cookies
++ assert "sid" not in jar.filter_cookies(subdomain)
++
++ jar.update_cookies_from_headers(
++ ["sid=domainwide; Domain=example.com; Path=/"], origin
++ )
++
++ assert jar._host_only_cookies == set()
++ assert jar.filter_cookies(subdomain)["sid"].value == "domainwide"
++
++
+ # === Security tests for restricted unpickler and JSON save/load ===
+
+
+@@ -1775,7 +1813,7 @@ async def test_save_load_json_preserves_host_only_scope(tmp_path: Path) -> None:
+ jar_load = CookieJar()
+ jar_load.load(file_path=file_path)
+
+- assert jar_load.host_only_cookies == frozenset({("auth.example.com", "sid")})
++ 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)
+
+@@ -1796,10 +1834,32 @@ async def test_save_load_json_domain_cookie_still_matches_subdomain(
+ jar_load = CookieJar()
+ jar_load.load(file_path=file_path)
+
+- assert jar_load.host_only_cookies == frozenset()
++ assert jar_load._host_only_cookies == set()
+ assert "sid" in jar_load.filter_cookies(subdomain)
+
+
++async def test_save_load_json_host_only_per_path(tmp_path: Path) -> None:
++ """Verify save/load keeps host-only state per (domain, path, name)."""
++ file_path = tmp_path / "per_path.json"
++ origin = URL("https://example.com/")
++ subdomain = URL("https://sub.example.com/")
++
++ jar_save = CookieJar()
++ jar_save.update_cookies_from_headers(
++ ["sid=hostonly; Path=/", "sid=domainwide; Domain=example.com; Path=/api"],
++ origin,
++ )
++ jar_save.save(file_path=file_path)
++
++ jar_load = CookieJar()
++ jar_load.load(file_path=file_path)
++
++ assert jar_load._host_only_cookies == {("example.com", "", "sid")}
++ assert "sid" not in jar_load.filter_cookies(subdomain)
++ filtered = jar_load.filter_cookies(URL("https://sub.example.com/api/x"))
++ assert filtered["sid"].value == "domainwide"
++
++
+ 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"
+--
+2.35.6
@@ -25,6 +25,7 @@ SRC_URI += " \
file://CVE-2026-111066_p1.patch \
file://CVE-2026-111066_p2.patch \
file://CVE-2026-111750.patch \
+ file://CVE-2026-111749.patch \
"
CVE_PRODUCT = "aiohttp:aiohttp"