From 5cb0aa77ea5482a081c34f588de8b10e7d0801e6 Mon Sep 17 00:00:00 2001 From: Yuval Elbar <41901908+YuvalElbar6@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:02:45 +0200 Subject: [PATCH 1/9] Restrict pickle deserialization in CookieJar.load() (#12091) --------- Co-authored-by: Sam Bull (cherry picked from commit 8a631e74c1d266499dbc6bcdbc83c60f4ea3ee3c) --- CHANGES/12091.bugfix.rst | 6 ++ aiohttp/cookiejar.py | 114 +++++++++++++++++++++- docs/client_reference.rst | 18 +++- docs/spelling_wordlist.txt | 7 +- tests/test_cookiejar.py | 188 +++++++++++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 CHANGES/12091.bugfix.rst diff --git a/CHANGES/12091.bugfix.rst b/CHANGES/12091.bugfix.rst new file mode 100644 index 00000000000..45ffbc3557f --- /dev/null +++ b/CHANGES/12091.bugfix.rst @@ -0,0 +1,6 @@ +Switched :py:meth:`~aiohttp.CookieJar.save` to use JSON format and +:py:meth:`~aiohttp.CookieJar.load` to try JSON first with a fallback to +a restricted pickle unpickler that only allows cookie-related types +(``SimpleCookie``, ``Morsel``, ``defaultdict``, etc.), preventing +arbitrary code execution via malicious pickle payloads +(CWE-502) -- by :user:`YuvalElbar6`. diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py index 016fae94d20..8a11bdd53ec 100644 --- a/aiohttp/cookiejar.py +++ b/aiohttp/cookiejar.py @@ -4,6 +4,7 @@ import datetime import heapq import itertools +import json import os # noqa import pathlib import pickle @@ -38,6 +39,41 @@ _SIMPLE_COOKIE = SimpleCookie() +class _RestrictedCookieUnpickler(pickle.Unpickler): + """A restricted unpickler that only allows cookie-related types. + + This prevents arbitrary code execution when loading pickled cookie data + from untrusted sources. Only types that are expected in a serialized + CookieJar are permitted. + + See: https://docs.python.org/3/library/pickle.html#restricting-globals + """ + + _ALLOWED_CLASSES: frozenset[tuple[str, str]] = frozenset( + { + # Core cookie types + ("http.cookies", "SimpleCookie"), + ("http.cookies", "Morsel"), + # Container types used by CookieJar._cookies + ("collections", "defaultdict"), + # builtins that pickle uses for reconstruction + ("builtins", "tuple"), + ("builtins", "set"), + ("builtins", "frozenset"), + ("builtins", "dict"), + } + ) + + def find_class(self, module: str, name: str) -> type: + if (module, name) not in self._ALLOWED_CLASSES: + raise pickle.UnpicklingError( + f"Forbidden class: {module}.{name}. " + "CookieJar.load() only allows cookie-related types for security. " + "See https://docs.python.org/3/library/pickle.html#restricting-globals" + ) + return super().find_class(module, name) # type: ignore[no-any-return] + + class CookieJar(AbstractCookieJar): """Implements cookie storage adhering to RFC 6265.""" @@ -112,14 +148,84 @@ def quote_cookie(self) -> bool: return self._quote_cookie def save(self, file_path: PathLike) -> None: + """Save cookies to a file using JSON format. + + :param file_path: Path to file where cookies will be serialized, + :class:`str` or :class:`pathlib.Path` instance. + """ file_path = pathlib.Path(file_path) - with file_path.open(mode="wb") as f: - pickle.dump(self._cookies, f, pickle.HIGHEST_PROTOCOL) + data: dict[str, dict[str, dict[str, str | bool]]] = {} + 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] = { + "key": morsel.key, + "value": morsel.value, + "coded_value": morsel.coded_value, + } + # Save all morsel attributes that have values + for attr in morsel._reserved: # type: ignore[attr-defined] + attr_val = morsel[attr] + if attr_val: + morsel_data[attr] = attr_val + data[key][name] = morsel_data + with file_path.open(mode="w", encoding="utf-8") as f: + json.dump(data, f, indent=2) def load(self, file_path: PathLike) -> None: + """Load cookies from a file. + + Tries to load JSON format first. Falls back to loading legacy + pickle format (using a restricted unpickler) for backward + compatibility with existing cookie files. + + :param file_path: Path to file from where cookies will be + imported, :class:`str` or :class:`pathlib.Path` instance. + """ file_path = pathlib.Path(file_path) - with file_path.open(mode="rb") as f: - self._cookies = pickle.load(f) + # Try JSON format first + try: + with file_path.open(mode="r", encoding="utf-8") as f: + data = json.load(f) + self._cookies = 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) + 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, + } + ) + # Restore morsel attributes + for attr in morsel._reserved: # type: ignore[attr-defined] + if attr in morsel_data and attr not in ( + "key", + "value", + "coded_value", + ): + morsel[attr] = morsel_data[attr] + cookies[key][name] = morsel + return cookies def clear(self, predicate: ClearCookiePredicate | None = None) -> None: if predicate is None: diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 0b1ae5dff19..a8096b01c3d 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -2474,16 +2474,28 @@ Utilities .. method:: save(file_path) - Write a pickled representation of cookies into the file + Write a JSON representation of cookies into the file at provided path. + .. versionchanged:: 3.14 + + Previously used pickle format. Now uses JSON for safe + serialization. + :param file_path: Path to file where cookies will be serialized, :class:`str` or :class:`pathlib.Path` instance. .. method:: load(file_path) - Load a pickled representation of cookies from the file - at provided path. + Load cookies from the file at provided path. Tries JSON format + first, then falls back to legacy pickle format (using a restricted + unpickler that only allows cookie-related types) for backward + compatibility with existing cookie files. + + .. versionchanged:: 3.14 + + Now loads JSON format by default. Falls back to restricted + pickle for files saved by older versions. :param file_path: Path to file from where cookies will be imported, :class:`str` or :class:`pathlib.Path` instance. diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 9b5eafcea4a..7e633b9b1eb 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -99,6 +99,7 @@ deduplicate defs Dependabot deprecations +deserialization DER dev Dev @@ -213,7 +214,8 @@ Multipart musllinux mypy Nagle -Nagle’s +Nagle's +NFS namedtuple nameservers namespace @@ -235,6 +237,7 @@ param params parsers pathlib +payloads peername performant pickleable @@ -355,6 +358,8 @@ unhandled unicode unittest Unittest +unpickler +untrusted unix unobvious unsets diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index dc2c9f4cc98..816a120c4d9 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -1620,3 +1620,191 @@ async def test_shared_cookie_with_multiple_domains() -> None: # Verify cache is reused efficiently assert ("", "") in jar._morsel_cache assert "universal" in jar._morsel_cache[("", "")] + + +# === Security tests for restricted unpickler and JSON save/load === + + +def test_load_rejects_malicious_pickle(tmp_path: Path) -> None: + """Verify CookieJar.load() blocks arbitrary code execution via pickle. + + A crafted pickle payload using os.system (or any non-cookie class) + must be rejected by the restricted unpickler. + """ + import os + + file_path = tmp_path / "malicious.pkl" + + class RCEPayload: + def __reduce__(self) -> tuple[object, ...]: + return (os.system, ("echo PWNED",)) + + with open(file_path, "wb") as f: + pickle.dump(RCEPayload(), f, pickle.HIGHEST_PROTOCOL) + + jar = CookieJar() + with pytest.raises(pickle.UnpicklingError, match="Forbidden class"): + jar.load(file_path) + + +def test_load_rejects_eval_payload(tmp_path: Path) -> None: + """Verify CookieJar.load() blocks eval-based pickle payloads.""" + file_path = tmp_path / "eval_payload.pkl" + + class EvalPayload: + def __reduce__(self) -> tuple[object, ...]: + return (eval, ("__import__('os').system('echo PWNED')",)) + + with open(file_path, "wb") as f: + pickle.dump(EvalPayload(), f, pickle.HIGHEST_PROTOCOL) + + jar = CookieJar() + with pytest.raises(pickle.UnpicklingError, match="Forbidden class"): + jar.load(file_path) + + +def test_load_rejects_subprocess_payload(tmp_path: Path) -> None: + """Verify CookieJar.load() blocks subprocess-based pickle payloads.""" + import subprocess + + file_path = tmp_path / "subprocess_payload.pkl" + + class SubprocessPayload: + def __reduce__(self) -> tuple[object, ...]: + return (subprocess.call, (["echo", "PWNED"],)) + + with open(file_path, "wb") as f: + pickle.dump(SubprocessPayload(), f, pickle.HIGHEST_PROTOCOL) + + jar = CookieJar() + with pytest.raises(pickle.UnpicklingError, match="Forbidden class"): + jar.load(file_path) + + +def test_load_falls_back_to_pickle( + tmp_path: Path, + cookies_to_receive: SimpleCookie, +) -> None: + """Verify load() falls back to restricted pickle for legacy cookie files. + + Existing cookie files saved with older versions of aiohttp used pickle. + load() should detect that the file is not JSON and fall back to the + restricted pickle unpickler for backward compatibility. + """ + file_path = tmp_path / "legit.pkl" + + # Write a legacy pickle file directly (as old aiohttp save() would) + jar_save = CookieJar() + jar_save.update_cookies(cookies_to_receive) + with file_path.open(mode="wb") as f: + pickle.dump(jar_save._cookies, f, pickle.HIGHEST_PROTOCOL) + + jar_load = CookieJar() + jar_load.load(file_path=file_path) + + jar_test = SimpleCookie() + for cookie in jar_load: + jar_test[cookie.key] = cookie + + assert jar_test == cookies_to_receive + + +def test_save_load_json_roundtrip( + tmp_path: Path, + cookies_to_receive: SimpleCookie, +) -> None: + """Verify save/load roundtrip preserves cookies via JSON format.""" + file_path = tmp_path / "cookies.json" + + jar_save = CookieJar() + jar_save.update_cookies(cookies_to_receive) + jar_save.save(file_path=file_path) + + jar_load = CookieJar() + jar_load.load(file_path=file_path) + + saved_cookies = SimpleCookie() + for cookie in jar_save: + saved_cookies[cookie.key] = cookie + + loaded_cookies = SimpleCookie() + for cookie in jar_load: + loaded_cookies[cookie.key] = cookie + + assert saved_cookies == loaded_cookies + + +def test_save_load_json_partitioned_cookies(tmp_path: Path) -> None: + """Verify save/load roundtrip works with partitioned cookies.""" + file_path = tmp_path / "partitioned.json" + + jar_save = CookieJar() + jar_save.update_cookies_from_headers( + ["session=cookie; Partitioned"], URL("https://example.com/") + ) + jar_save.save(file_path=file_path) + + jar_load = CookieJar() + jar_load.load(file_path=file_path) + + # Compare individual cookie values (same approach as test_save_load_partitioned_cookies) + saved = list(jar_save) + loaded = list(jar_load) + assert len(saved) == len(loaded) + for s, lo in zip(saved, loaded): + assert s.key == lo.key + assert s.value == lo.value + assert s["domain"] == lo["domain"] + assert s["path"] == lo["path"] + + +def test_json_format_is_safe(tmp_path: Path) -> None: + """Verify the JSON file format cannot execute code on load.""" + import json + + file_path = tmp_path / "safe.json" + + # Write something that might look dangerous but is just data + malicious_data = { + "evil.com|/": { + "session": { + "key": "session", + "value": "__import__('os').system('echo PWNED')", + "coded_value": "__import__('os').system('echo PWNED')", + } + } + } + with open(file_path, "w") as f: + json.dump(malicious_data, f) + + jar = CookieJar() + jar.load(file_path=file_path) + + # The "malicious" string is just a cookie value, not executed code + cookies = list(jar) + assert len(cookies) == 1 + assert cookies[0].value == "__import__('os').system('echo PWNED')" + + +def test_save_load_json_secure_cookies(tmp_path: Path) -> None: + """Verify save/load preserves Secure and HttpOnly flags.""" + file_path = tmp_path / "secure.json" + + jar_save = CookieJar() + jar_save.update_cookies_from_headers( + ["token=abc123; Secure; HttpOnly; Path=/; Domain=example.com"], + URL("https://example.com/"), + ) + jar_save.save(file_path=file_path) + + jar_load = CookieJar() + jar_load.load(file_path=file_path) + + loaded_cookies = list(jar_load) + assert len(loaded_cookies) == 1 + cookie = loaded_cookies[0] + assert cookie.key == "token" + assert cookie.value == "abc123" + assert cookie["secure"] is True + assert cookie["httponly"] is True + assert cookie["domain"] == "example.com" From 1838ab829fd2be5cbce548a79def3f1e7c2ecece Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 20 Feb 2026 22:06:39 +0000 Subject: [PATCH 2/9] Update test_cookiejar.py --- tests/test_cookiejar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index 816a120c4d9..d41d4721c31 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -9,6 +9,7 @@ import unittest from http.cookies import BaseCookie, Morsel, SimpleCookie from operator import not_ +from pathlib import Path from unittest import mock import pytest From f31426ea3a4cc7d10d47200c65f3e7f65bb6e9b7 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 20 Feb 2026 22:31:15 +0000 Subject: [PATCH 3/9] Update test_cookiejar.py --- tests/test_cookiejar.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index d41d4721c31..9e4f7500afc 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -1626,7 +1626,7 @@ async def test_shared_cookie_with_multiple_domains() -> None: # === Security tests for restricted unpickler and JSON save/load === -def test_load_rejects_malicious_pickle(tmp_path: Path) -> None: +async def test_load_rejects_malicious_pickle(tmp_path: Path) -> None: """Verify CookieJar.load() blocks arbitrary code execution via pickle. A crafted pickle payload using os.system (or any non-cookie class) @@ -1648,7 +1648,7 @@ def __reduce__(self) -> tuple[object, ...]: jar.load(file_path) -def test_load_rejects_eval_payload(tmp_path: Path) -> None: +async def test_load_rejects_eval_payload(tmp_path: Path) -> None: """Verify CookieJar.load() blocks eval-based pickle payloads.""" file_path = tmp_path / "eval_payload.pkl" @@ -1664,7 +1664,7 @@ def __reduce__(self) -> tuple[object, ...]: jar.load(file_path) -def test_load_rejects_subprocess_payload(tmp_path: Path) -> None: +async def test_load_rejects_subprocess_payload(tmp_path: Path) -> None: """Verify CookieJar.load() blocks subprocess-based pickle payloads.""" import subprocess @@ -1682,7 +1682,7 @@ def __reduce__(self) -> tuple[object, ...]: jar.load(file_path) -def test_load_falls_back_to_pickle( +async def test_load_falls_back_to_pickle( tmp_path: Path, cookies_to_receive: SimpleCookie, ) -> None: @@ -1710,7 +1710,7 @@ def test_load_falls_back_to_pickle( assert jar_test == cookies_to_receive -def test_save_load_json_roundtrip( +async def test_save_load_json_roundtrip( tmp_path: Path, cookies_to_receive: SimpleCookie, ) -> None: @@ -1735,7 +1735,7 @@ def test_save_load_json_roundtrip( assert saved_cookies == loaded_cookies -def test_save_load_json_partitioned_cookies(tmp_path: Path) -> None: +async def test_save_load_json_partitioned_cookies(tmp_path: Path) -> None: """Verify save/load roundtrip works with partitioned cookies.""" file_path = tmp_path / "partitioned.json" @@ -1759,7 +1759,7 @@ def test_save_load_json_partitioned_cookies(tmp_path: Path) -> None: assert s["path"] == lo["path"] -def test_json_format_is_safe(tmp_path: Path) -> None: +async def test_json_format_is_safe(tmp_path: Path) -> None: """Verify the JSON file format cannot execute code on load.""" import json @@ -1787,7 +1787,7 @@ def test_json_format_is_safe(tmp_path: Path) -> None: assert cookies[0].value == "__import__('os').system('echo PWNED')" -def test_save_load_json_secure_cookies(tmp_path: Path) -> None: +async def test_save_load_json_secure_cookies(tmp_path: Path) -> None: """Verify save/load preserves Secure and HttpOnly flags.""" file_path = tmp_path / "secure.json" From 37952b49d43d07bda08af896cbb9ba83a313f3a9 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sun, 22 Feb 2026 14:51:16 +0000 Subject: [PATCH 4/9] Update conftest.py --- tests/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 661f539a632..64b00e496bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,6 +95,10 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]: bb.functions[func].can_block_in( "aiohttp/web_urldispatcher.py", "add_static" ) + # save/load is not async, so we must allow this: + bb.functions["io.TextIOWrapper.read"].can_block_in( + "aiohttp/cookiejar.py", "load" + ) # Note: coverage.py uses locking internally which can cause false positives # in blockbuster when it instruments code. This is particularly problematic # on Windows where it can lead to flaky test failures. From abd78adaa82344e4946dcfabe2aae18bf1a1a382 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sun, 22 Feb 2026 14:59:26 +0000 Subject: [PATCH 5/9] Update conftest.py --- tests/conftest.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 64b00e496bc..7b322b36552 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,9 +96,14 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]: "aiohttp/web_urldispatcher.py", "add_static" ) # save/load is not async, so we must allow this: - bb.functions["io.TextIOWrapper.read"].can_block_in( - "aiohttp/cookiejar.py", "load" - ) + for func in ( + "io.TextIOWrapper.read", + "io.BufferedReader.read", + "io.TextIOWrapper.write", + ): + bb.functions[func].can_block_in( + "aiohttp/cookiejar.py", "load" + ) # Note: coverage.py uses locking internally which can cause false positives # in blockbuster when it instruments code. This is particularly problematic # on Windows where it can lead to flaky test failures. From b92aa74dfa5bc98ce24cd9b7878270cf9f5a04d9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:00:32 +0000 Subject: [PATCH 6/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/conftest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7b322b36552..7c840040846 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -101,9 +101,7 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]: "io.BufferedReader.read", "io.TextIOWrapper.write", ): - bb.functions[func].can_block_in( - "aiohttp/cookiejar.py", "load" - ) + bb.functions[func].can_block_in("aiohttp/cookiejar.py", "load") # Note: coverage.py uses locking internally which can cause false positives # in blockbuster when it instruments code. This is particularly problematic # on Windows where it can lead to flaky test failures. From 2719947f933e7f80bc617f575a5726ba722ad44c Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sun, 22 Feb 2026 15:12:30 +0000 Subject: [PATCH 7/9] Update conftest.py --- tests/conftest.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7c840040846..0ec022127b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,12 +96,9 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]: "aiohttp/web_urldispatcher.py", "add_static" ) # save/load is not async, so we must allow this: - for func in ( - "io.TextIOWrapper.read", - "io.BufferedReader.read", - "io.TextIOWrapper.write", - ): + for func in ("io.TextIOWrapper.read", "io.BufferedReader.read"): bb.functions[func].can_block_in("aiohttp/cookiejar.py", "load") + bb.functions["io.TextIOWrapper.write"].can_block_in("aiohttp/cookiejar.py", "save") # Note: coverage.py uses locking internally which can cause false positives # in blockbuster when it instruments code. This is particularly problematic # on Windows where it can lead to flaky test failures. From 748556484f6fb25af5fd53ad6dc27a3ff8822162 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:13:09 +0000 Subject: [PATCH 8/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0ec022127b5..06422b09a7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,7 +98,9 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]: # save/load is not async, so we must allow this: for func in ("io.TextIOWrapper.read", "io.BufferedReader.read"): bb.functions[func].can_block_in("aiohttp/cookiejar.py", "load") - bb.functions["io.TextIOWrapper.write"].can_block_in("aiohttp/cookiejar.py", "save") + bb.functions["io.TextIOWrapper.write"].can_block_in( + "aiohttp/cookiejar.py", "save" + ) # Note: coverage.py uses locking internally which can cause false positives # in blockbuster when it instruments code. This is particularly problematic # on Windows where it can lead to flaky test failures. From 231eccbdd31442a6636e58a74547c2030f2a2cc9 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sun, 22 Feb 2026 15:40:01 +0000 Subject: [PATCH 9/9] Update conftest.py --- tests/conftest.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 06422b09a7d..71e773ddca8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,9 +98,8 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]: # save/load is not async, so we must allow this: for func in ("io.TextIOWrapper.read", "io.BufferedReader.read"): bb.functions[func].can_block_in("aiohttp/cookiejar.py", "load") - bb.functions["io.TextIOWrapper.write"].can_block_in( - "aiohttp/cookiejar.py", "save" - ) + for func in ("io.TextIOWrapper.write", "io.BufferedWriter.write"): + bb.functions[func].can_block_in("aiohttp/cookiejar.py", "save") # Note: coverage.py uses locking internally which can cause false positives # in blockbuster when it instruments code. This is particularly problematic # on Windows where it can lead to flaky test failures.