new file mode 100644
@@ -0,0 +1,262 @@
+From 66405536d4410c7456a52d2b6780e827f4e56d44 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)
+(cherry picked from commit 6a07c171ef9f3be583e2907dcd1931372efca8d6)
+
+CVE: CVE-2026-111749
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/6a07c171ef9f3be583e2907dcd1931372efca8d6]
+
+Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
+---
+ CHANGES/13674.bugfix.rst | 1 +
+ aiohttp/abc.py | 2 +-
+ aiohttp/cookiejar.py | 29 ++++++++--------
+ docs/client_reference.rst | 10 ++++--
+ tests/test_client_session.py | 2 +-
+ tests/test_cookiejar.py | 64 ++++++++++++++++++++++++++++++++++--
+ 6 files changed, 89 insertions(+), 19 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 000000000..5152bceae
+--- /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/abc.py b/aiohttp/abc.py
+index b67287318..74ca7216e 100644
+--- a/aiohttp/abc.py
++++ b/aiohttp/abc.py
+@@ -181,7 +181,7 @@ class AbstractCookieJar(Sized, IterableBase):
+
+ @property
+ @abstractmethod
+- def host_only_cookies(self) -> frozenset[tuple[str, str]]:
++ def host_only_cookies(self) -> frozenset[tuple[str, str, str]]:
+ """Return the host-only cookies stored in this jar."""
+
+ @abstractmethod
+diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py
+index b949cc118..1af02144e 100644
+--- a/aiohttp/cookiejar.py
++++ b/aiohttp/cookiejar.py
+@@ -134,7 +134,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:
+@@ -166,7 +167,7 @@ class CookieJar(AbstractCookieJar):
+ return MappingProxyType(self._cookies)
+
+ @property
+- def host_only_cookies(self) -> frozenset[tuple[str, str]]:
++ def host_only_cookies(self) -> frozenset[tuple[str, str, str]]:
+ """Return the host-only cookies stored in this jar."""
+ return frozenset(self._host_only_cookies)
+
+@@ -195,7 +196,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
+@@ -358,7 +359,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)
+@@ -395,18 +396,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
+
+@@ -422,6 +417,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)
+@@ -522,7 +525,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.
+@@ -672,7 +675,7 @@ class DummyCookieJar(AbstractCookieJar):
+ return MappingProxyType({})
+
+ @property
+- def host_only_cookies(self) -> frozenset[tuple[str, str]]:
++ def host_only_cookies(self) -> frozenset[tuple[str, str, str]]:
+ """Return an empty frozenset."""
+ return frozenset()
+
+diff --git a/docs/client_reference.rst b/docs/client_reference.rst
+index a237fb64b..476e6e81f 100644
+--- a/docs/client_reference.rst
++++ b/docs/client_reference.rst
+@@ -2611,11 +2611,17 @@ Utilities
+
+ .. attribute:: host_only_cookies
+
+- A :class:`frozenset` of ``(domain, name)`` tuples indicating which
+- cookies are host-only (not sent to subdomains).
++ A :class:`frozenset` of ``(domain, path, name)`` tuples indicating
++ which cookies are host-only (not sent to subdomains).
+
+ .. versionadded:: 3.14
+
++ .. versionchanged:: 3.14.4
++
++ The tuples gained the *path* element; host-only state is tracked
++ per ``(domain, path, name)`` cookie identity so that same-named
++ cookies on other paths cannot affect it.
++
+
+ .. class:: DummyCookieJar(*, loop=None)
+
+diff --git a/tests/test_client_session.py b/tests/test_client_session.py
+index 6b3516879..4481a4bf8 100644
+--- a/tests/test_client_session.py
++++ b/tests/test_client_session.py
+@@ -758,7 +758,7 @@ async def test_cookie_jar_usage(loop: Any, aiohttp_client: Any) -> None:
+ return MappingProxyType({})
+
+ @property
+- def host_only_cookies(self) -> frozenset[tuple[str, str]]:
++ def host_only_cookies(self) -> frozenset[tuple[str, str, str]]:
+ return frozenset()
+
+ def clear(self, predicate: abc.ClearCookiePredicate | None = None) -> None:
+diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py
+index be6da2a5f..4fd2959e1 100644
+--- a/tests/test_cookiejar.py
++++ b/tests/test_cookiejar.py
+@@ -878,7 +878,45 @@ async def test_cookie_jar_host_only_cookies_property() -> None:
+
+ host_only = jar.host_only_cookies
+ assert isinstance(host_only, frozenset)
+- assert ("example.com", "hostonly") in host_only
++ assert ("example.com", "", "hostonly") in host_only
++
++
++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 == frozenset()
++ assert jar.filter_cookies(subdomain)["sid"].value == "domainwide"
+
+
+ async def test_cookie_jar_cookies_property_immutable() -> None:
+@@ -1857,7 +1895,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 == frozenset({("auth.example.com", "", "sid")})
+ assert "sid" not in jar_load.filter_cookies(subdomain)
+ assert "sid" in jar_load.filter_cookies(issuer)
+
+@@ -1882,6 +1920,28 @@ async def test_save_load_json_domain_cookie_still_matches_subdomain(
+ 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 == frozenset({("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"
@@ -10,6 +10,7 @@ SRC_URI += "file://CVE-2026-111063.patch \
file://CVE-2026-111065.patch \
file://CVE-2026-111066.patch \
file://CVE-2026-111748.patch \
+ file://CVE-2026-111749.patch \
"
CVE_PRODUCT = "aiohttp:aiohttp"