Skip to content

Commit 95eb19f

Browse files
authored
[PR aio-libs#12824/60b85e98 backport][3.15] Preserve host-only cookie scope across CookieJar save/load (aio-libs#12834)
1 parent b40867f commit 95eb19f

3 files changed

Lines changed: 171 additions & 17 deletions

File tree

‎CHANGES/12824.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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`.

‎aiohttp/cookiejar.py‎

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
_MIN_SCHEDULED_COOKIE_EXPIRATION = 100
4040
_SIMPLE_COOKIE = SimpleCookie()
4141

42+
# Not persisted; the absolute deadline is saved instead.
43+
_RELATIVE_EXPIRY_ATTRS = frozenset(("max-age", "expires"))
44+
4245

4346
class _RestrictedCookieUnpickler(pickle._Unpickler):
4447
"""A restricted unpickler that only allows cookie-related types.
@@ -174,21 +177,28 @@ def save(self, file_path: PathLike) -> None:
174177
:class:`str` or :class:`pathlib.Path` instance.
175178
"""
176179
file_path = pathlib.Path(file_path)
177-
data: dict[str, dict[str, dict[str, str | bool]]] = {}
180+
data: dict[str, dict[str, dict[str, str | bool | float]]] = {}
178181
for (domain, path), cookie in self._cookies.items():
179182
key = f"{domain}|{path}"
180183
data[key] = {}
181184
for name, morsel in cookie.items():
182-
morsel_data: dict[str, str | bool] = {
185+
morsel_data: dict[str, str | bool | float] = {
183186
"key": morsel.key,
184187
"value": morsel.value,
185188
"coded_value": morsel.coded_value,
186189
}
187-
# Save all morsel attributes that have values
190+
# Skip relative expiry; the absolute deadline is saved below.
188191
for attr in morsel._reserved: # type: ignore[attr-defined]
192+
if attr in _RELATIVE_EXPIRY_ATTRS:
193+
continue
189194
attr_val = morsel[attr]
190195
if attr_val:
191196
morsel_data[attr] = attr_val
197+
# Persist or it reloads as a domain cookie and leaks to subdomains.
198+
if (domain, name) in self._host_only_cookies:
199+
morsel_data["host_only"] = True
200+
if (exp := self._expirations.get((domain, path, name))) is not None:
201+
morsel_data["expires_timestamp"] = exp
192202
data[key][name] = morsel_data
193203

194204
# Cookie persistence may include authentication/session tokens.
@@ -209,6 +219,9 @@ def load(self, file_path: PathLike) -> None:
209219
pickle format (using a restricted unpickler) for backward
210220
compatibility with existing cookie files.
211221
222+
Replaces the current jar contents; loaded cookies pass through the
223+
same acceptance rules as :meth:`update_cookies`.
224+
212225
:param file_path: Path to file from where cookies will be
213226
imported, :class:`str` or :class:`pathlib.Path` instance.
214227
"""
@@ -217,32 +230,28 @@ def load(self, file_path: PathLike) -> None:
217230
try:
218231
with file_path.open(mode="r", encoding="utf-8") as f:
219232
data = json.load(f)
220-
self._cookies = self._load_json_data(data)
233+
self._load_json_data(data)
221234
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
222235
# Fall back to legacy pickle format with restricted unpickler
223236
with file_path.open(mode="rb") as f:
224237
self._cookies = _RestrictedCookieUnpickler(f).load()
225238

226239
def _load_json_data(
227-
self, data: dict[str, dict[str, dict[str, str | bool]]]
228-
) -> defaultdict[tuple[str, str], SimpleCookie]:
229-
"""Load cookies from parsed JSON data."""
230-
cookies: defaultdict[tuple[str, str], SimpleCookie] = defaultdict(SimpleCookie)
240+
self, data: dict[str, dict[str, dict[str, str | bool | float]]]
241+
) -> None:
242+
"""Replace contents, routing cookies through update_cookies()."""
243+
self.clear()
231244
for compound_key, cookie_data in data.items():
232245
domain, path = compound_key.split("|", 1)
233-
key = (domain, path)
234246
for name, morsel_data in cookie_data.items():
235247
morsel: Morsel[str] = Morsel()
236-
morsel_key = morsel_data["key"]
237-
morsel_value = morsel_data["value"]
238-
morsel_coded_value = morsel_data["coded_value"]
239248
# Use __setstate__ to bypass validation, same pattern
240249
# used in _build_morsel and _cookie_helpers.
241250
morsel.__setstate__( # type: ignore[attr-defined]
242251
{
243-
"key": morsel_key,
244-
"value": morsel_value,
245-
"coded_value": morsel_coded_value,
252+
"key": morsel_data["key"],
253+
"value": morsel_data["value"],
254+
"coded_value": morsel_data["coded_value"],
246255
}
247256
)
248257
# Restore morsel attributes
@@ -253,8 +262,17 @@ def _load_json_data(
253262
"coded_value",
254263
):
255264
morsel[attr] = morsel_data[attr]
256-
cookies[key][name] = morsel
257-
return cookies
265+
# Drop the domain so update_cookies() re-marks it host-only.
266+
if morsel_data.get("host_only"):
267+
morsel["domain"] = ""
268+
response_url = (
269+
URL.build(scheme="https", host=domain) if domain else URL()
270+
)
271+
self.update_cookies({name: morsel}, response_url)
272+
# Restore the absolute deadline; update_cookies() schedules none.
273+
if (exp := morsel_data.get("expires_timestamp")) is not None:
274+
self._expire_cookie(float(exp), domain, path, name)
275+
self._do_expiration()
258276

259277
def clear(self, predicate: ClearCookiePredicate | None = None) -> None:
260278
if predicate is None:

‎tests/test_cookiejar.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import datetime
33
import heapq
44
import itertools
5+
import json
56
import logging
67
import os
78
import pathlib
@@ -1814,6 +1815,140 @@ async def test_save_load_json_partitioned_cookies(tmp_path: Path) -> None:
18141815
assert s["path"] == lo["path"]
18151816

18161817

1818+
async def test_save_load_json_preserves_host_only_scope(tmp_path: Path) -> None:
1819+
"""Verify save/load keeps host-only cookies off subdomains."""
1820+
file_path = tmp_path / "host_only.json"
1821+
issuer = URL("https://auth.example.com/login")
1822+
subdomain = URL("https://sub.auth.example.com/")
1823+
1824+
jar_save = CookieJar()
1825+
jar_save.update_cookies({"sid": "hostonly"}, response_url=issuer)
1826+
assert "sid" not in jar_save.filter_cookies(subdomain)
1827+
jar_save.save(file_path=file_path)
1828+
1829+
jar_load = CookieJar()
1830+
jar_load.load(file_path=file_path)
1831+
1832+
assert jar_load.host_only_cookies == frozenset({("auth.example.com", "sid")})
1833+
assert "sid" not in jar_load.filter_cookies(subdomain)
1834+
assert "sid" in jar_load.filter_cookies(issuer)
1835+
1836+
1837+
async def test_save_load_json_domain_cookie_still_matches_subdomain(
1838+
tmp_path: Path,
1839+
) -> None:
1840+
"""Verify save/load keeps an explicit Domain cookie valid for subdomains."""
1841+
file_path = tmp_path / "domain.json"
1842+
subdomain = URL("https://sub.example.com/")
1843+
1844+
jar_save = CookieJar()
1845+
jar_save.update_cookies_from_headers(
1846+
["sid=domaincookie; Domain=example.com"], URL("https://example.com/")
1847+
)
1848+
jar_save.save(file_path=file_path)
1849+
1850+
jar_load = CookieJar()
1851+
jar_load.load(file_path=file_path)
1852+
1853+
assert jar_load.host_only_cookies == frozenset()
1854+
assert "sid" in jar_load.filter_cookies(subdomain)
1855+
1856+
1857+
async def test_save_load_json_preserves_max_age_deadline(tmp_path: Path) -> None:
1858+
"""Verify save/load restores the absolute deadline without resetting it."""
1859+
file_path = tmp_path / "max_age.json"
1860+
url = URL("https://example.com/")
1861+
1862+
jar_save = CookieJar()
1863+
jar_save.update_cookies_from_headers(
1864+
["sid=x; Max-Age=3600; Domain=example.com"], url
1865+
)
1866+
expirations = dict(jar_save._expirations)
1867+
jar_save.save(file_path=file_path)
1868+
1869+
jar_load = CookieJar()
1870+
jar_load.load(file_path=file_path)
1871+
1872+
# The deadline is restored as the original absolute time, not now + Max-Age.
1873+
assert dict(jar_load._expirations) == expirations
1874+
assert "sid" in jar_load.filter_cookies(url)
1875+
1876+
1877+
async def test_save_load_json_drops_expired_cookie(tmp_path: Path) -> None:
1878+
"""Verify a cookie whose persisted deadline is in the past is dropped on load."""
1879+
file_path = tmp_path / "expired.json"
1880+
url = URL("https://example.com/")
1881+
1882+
# Save a future-expiring cookie, then rewrite its persisted deadline to the
1883+
# past so the cookie survives save() and the drop happens on the load path.
1884+
jar_save = CookieJar()
1885+
jar_save.update_cookies_from_headers(
1886+
["sid=x; Expires=Tue, 1 Jan 2999 12:00:00 GMT; Domain=example.com"], url
1887+
)
1888+
jar_save.save(file_path=file_path)
1889+
data = json.loads(file_path.read_text())
1890+
_, cookies = next(iter(data.items()))
1891+
cookies["sid"]["expires_timestamp"] = 0.0
1892+
file_path.write_text(json.dumps(data))
1893+
1894+
jar_load = CookieJar()
1895+
jar_load.load(file_path=file_path)
1896+
1897+
assert len(jar_load) == 0
1898+
assert "sid" not in jar_load.filter_cookies(url)
1899+
1900+
1901+
async def test_save_load_json_preserves_expires_deadline(tmp_path: Path) -> None:
1902+
"""Verify a future Expires deadline survives a save/load roundtrip."""
1903+
file_path = tmp_path / "expires.json"
1904+
url = URL("https://example.com/")
1905+
1906+
jar_save = CookieJar()
1907+
jar_save.update_cookies_from_headers(
1908+
["sid=x; Expires=Tue, 1 Jan 2999 12:00:00 GMT; Domain=example.com"], url
1909+
)
1910+
expirations = dict(jar_save._expirations)
1911+
jar_save.save(file_path=file_path)
1912+
1913+
jar_load = CookieJar()
1914+
jar_load.load(file_path=file_path)
1915+
1916+
assert dict(jar_load._expirations) == expirations
1917+
assert "sid" in jar_load.filter_cookies(url)
1918+
1919+
1920+
async def test_load_json_old_format_without_new_keys(tmp_path: Path) -> None:
1921+
"""Verify a file written by an older version (no host_only/expires_timestamp) loads."""
1922+
file_path = tmp_path / "old.json"
1923+
# Old schema: no host_only, no expires_timestamp; relative max-age morsel attr.
1924+
file_path.write_text(
1925+
json.dumps(
1926+
{
1927+
"example.com|/": {
1928+
"sid": {
1929+
"key": "sid",
1930+
"value": "x",
1931+
"coded_value": "x",
1932+
"domain": "example.com",
1933+
"max-age": "3600",
1934+
}
1935+
}
1936+
}
1937+
)
1938+
)
1939+
url = URL("https://example.com/")
1940+
1941+
jar_load = CookieJar()
1942+
# No exception when the new keys are absent.
1943+
jar_load.load(file_path=file_path)
1944+
1945+
# A host-only cookie saved without Domain by an older version had no domain
1946+
# field, so it now loads as a domain cookie (the documented migration loss).
1947+
assert "sid" in jar_load.filter_cookies(url)
1948+
# max-age is rescheduled from load time rather than an absolute deadline.
1949+
assert any(key[2] == "sid" for key in jar_load._expirations)
1950+
1951+
18171952
async def test_json_format_is_safe(tmp_path: Path) -> None:
18181953
"""Verify the JSON file format cannot execute code on load."""
18191954
import json

0 commit comments

Comments
 (0)