From 17db30667bc5ed49544a294f3be5be82589d012e Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Sun, 19 Apr 2026 18:01:22 -0300 Subject: [PATCH 01/13] Fix CookieError crash with control characters on Python 3.13 --- aiohttp/_cookie_helpers.py | 59 ++++++++++++++++++++++-------------- tests/test_cookie_helpers.py | 28 +++++++++++++++++ 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/aiohttp/_cookie_helpers.py b/aiohttp/_cookie_helpers.py index d545df3ef6c..cf90cce9db8 100644 --- a/aiohttp/_cookie_helpers.py +++ b/aiohttp/_cookie_helpers.py @@ -7,7 +7,7 @@ import re from collections.abc import Sequence -from http.cookies import Morsel +from http.cookies import CookieError, Morsel from typing import cast from .log import internal_logger @@ -82,6 +82,35 @@ ) +def _safe_set_morsel_state( + morsel: Morsel[str], + key: str, + value: str, + coded_value: str, +) -> None: + """Set morsel state, handling Python 3.13+ control-character rejection. + + Python 3.13 added validation in ``Morsel.__setstate__`` that rejects + values containing ASCII control characters (CVE-2026-3644). When + ``_unquote`` decodes octal escape sequences (e.g. ``\\012`` → ``\\n``) + the resulting value may contain such characters. + + When that happens we fall back to storing the *raw* (still-escaped) + ``coded_value`` as both ``value`` and ``coded_value`` so the cookie + is preserved without crashing. + """ + try: + morsel.__setstate__( # type: ignore[attr-defined] + {"key": key, "value": value, "coded_value": coded_value} + ) + except CookieError: + # The decoded value contains control characters that Python 3.13+ + # rejects. Fall back to keeping the raw coded_value as the value. + morsel.__setstate__( # type: ignore[attr-defined] + {"key": key, "value": coded_value, "coded_value": coded_value} + ) + + def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]: """ Preserve a Morsel's coded_value exactly as received from the server. @@ -102,12 +131,8 @@ def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]: """ mrsl_val = cast("Morsel[str]", cookie.get(cookie.key, Morsel())) - # We use __setstate__ instead of the public set() API because it allows us to - # bypass validation and set already validated state. This is more stable than - # setting protected attributes directly and unlikely to change since it would - # break pickling. - mrsl_val.__setstate__( # type: ignore[attr-defined] - {"key": cookie.key, "value": cookie.value, "coded_value": cookie.coded_value} + _safe_set_morsel_state( + mrsl_val, cookie.key, cookie.value, cookie.coded_value ) return mrsl_val @@ -206,9 +231,7 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]: invalid_names.append(key) else: morsel = Morsel() - morsel.__setstate__( # type: ignore[attr-defined] - {"key": key, "value": _unquote(value), "coded_value": value} - ) + _safe_set_morsel_state(morsel, key, _unquote(value), value) cookies.append((key, morsel)) # Move to next cookie or end @@ -227,13 +250,7 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]: # Create new morsel morsel = Morsel() # Preserve the original value as coded_value (with quotes if present) - # We use __setstate__ instead of the public set() API because it allows us to - # bypass validation and set already validated state. This is more stable than - # setting protected attributes directly and unlikely to change since it would - # break pickling. - morsel.__setstate__( # type: ignore[attr-defined] - {"key": key, "value": _unquote(value), "coded_value": value} - ) + _safe_set_morsel_state(morsel, key, _unquote(value), value) cookies.append((key, morsel)) @@ -323,12 +340,8 @@ def parse_set_cookie_headers(headers: Sequence[str]) -> list[tuple[str, Morsel[s # Create new morsel current_morsel = Morsel() # Preserve the original value as coded_value (with quotes if present) - # We use __setstate__ instead of the public set() API because it allows us to - # bypass validation and set already validated state. This is more stable than - # setting protected attributes directly and unlikely to change since it would - # break pickling. - current_morsel.__setstate__( # type: ignore[attr-defined] - {"key": key, "value": _unquote(value), "coded_value": value} + _safe_set_morsel_state( + current_morsel, key, _unquote(value), value ) parsed_cookies.append((key, current_morsel)) morsel_seen = True diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index fead869d6f3..44ec0927da2 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -1134,6 +1134,34 @@ def test_parse_set_cookie_headers_uses_unquote_with_octal( assert morsel.coded_value == expected_coded +@pytest.mark.parametrize( + ("header", "expected_name", "expected_coded"), + [ + (r'name="\012newline\012"', "name", r'"\012newline\012"'), + (r'tab="\011separated\011values"', "tab", r'"\011separated\011values"'), + ], +) +def test_parse_set_cookie_headers_ctl_chars( + header: str, expected_name: str, expected_coded: str +) -> None: + """Test that parse_set_cookie_headers does not crash on control characters. + + Python 3.13+ rejects control characters in cookies. When octal unquoting results + in a control character, we fall back to using the safe coded_value as the value + to avoid crashing the parser. + """ + result = parse_set_cookie_headers([header]) + + assert len(result) == 1 + name, morsel = result[0] + + assert name == expected_name + assert morsel.coded_value == expected_coded + # Depending on the Python version, morsel.value will either be the decoded string + # (Python < 3.13) or the raw coded_value (Python >= 3.13). + # We just ensure it doesn't crash and the coded_value is preserved. + + # Tests for parse_cookie_header (RFC 6265 compliant Cookie header parser) From e33ff0189e5334fac5450c39f444cb2fc5f5d129 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:02:16 +0000 Subject: [PATCH 02/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- aiohttp/_cookie_helpers.py | 8 ++------ tests/test_cookie_helpers.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/aiohttp/_cookie_helpers.py b/aiohttp/_cookie_helpers.py index cf90cce9db8..d22f2c1bc86 100644 --- a/aiohttp/_cookie_helpers.py +++ b/aiohttp/_cookie_helpers.py @@ -131,9 +131,7 @@ def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]: """ mrsl_val = cast("Morsel[str]", cookie.get(cookie.key, Morsel())) - _safe_set_morsel_state( - mrsl_val, cookie.key, cookie.value, cookie.coded_value - ) + _safe_set_morsel_state(mrsl_val, cookie.key, cookie.value, cookie.coded_value) return mrsl_val @@ -340,9 +338,7 @@ def parse_set_cookie_headers(headers: Sequence[str]) -> list[tuple[str, Morsel[s # Create new morsel current_morsel = Morsel() # Preserve the original value as coded_value (with quotes if present) - _safe_set_morsel_state( - current_morsel, key, _unquote(value), value - ) + _safe_set_morsel_state(current_morsel, key, _unquote(value), value) parsed_cookies.append((key, current_morsel)) morsel_seen = True else: diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index 44ec0927da2..e7b74d5c948 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -1145,7 +1145,7 @@ def test_parse_set_cookie_headers_ctl_chars( header: str, expected_name: str, expected_coded: str ) -> None: """Test that parse_set_cookie_headers does not crash on control characters. - + Python 3.13+ rejects control characters in cookies. When octal unquoting results in a control character, we fall back to using the safe coded_value as the value to avoid crashing the parser. From 5b9a07c814daa11a1847504eac32c276b8bcbf73 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Sun, 19 Apr 2026 18:10:24 -0300 Subject: [PATCH 03/13] Add news fragment --- CHANGES/12395.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 CHANGES/12395.bugfix.rst diff --git a/CHANGES/12395.bugfix.rst b/CHANGES/12395.bugfix.rst new file mode 100644 index 00000000000..be629bdad61 --- /dev/null +++ b/CHANGES/12395.bugfix.rst @@ -0,0 +1,2 @@ +Fixed a crash (``CookieError``) in the cookie parser when receiving a cookie containing ASCII control characters on Python 3.13+ (CVE-2026-3644). The parser now gracefully falls back to storing the raw, still-escaped ``coded_value`` without crashing the application. +-- by :user:`rodrigobnogueira`. From eee0ad7aa36138e1aa3279890d161f80acb7182f Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Sun, 19 Apr 2026 18:18:01 -0300 Subject: [PATCH 04/13] Fix flake8 D301 docstring warning --- aiohttp/_cookie_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp/_cookie_helpers.py b/aiohttp/_cookie_helpers.py index d22f2c1bc86..dce68bb16be 100644 --- a/aiohttp/_cookie_helpers.py +++ b/aiohttp/_cookie_helpers.py @@ -88,7 +88,7 @@ def _safe_set_morsel_state( value: str, coded_value: str, ) -> None: - """Set morsel state, handling Python 3.13+ control-character rejection. + r"""Set morsel state, handling Python 3.13+ control-character rejection. Python 3.13 added validation in ``Morsel.__setstate__`` that rejects values containing ASCII control characters (CVE-2026-3644). When From 12274d00f8ea5e8f7808a57a11583974384ef191 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Sun, 19 Apr 2026 23:50:27 -0300 Subject: [PATCH 05/13] Handle literal control chars in cookie values and address review feedback - _safe_set_morsel_state now returns bool; callers skip unsalvageable cookies - Handles both octal-decoded CTL chars and literal CTL chars in raw headers - Added tests for literal control character edge case (bdraco feedback) - Updated version wording to reference CVE-2026-3644 patch, not Python 3.13+ - Reworded test docstrings per Dreamsorcerer feedback --- CHANGES/12395.bugfix.rst | 8 +++-- aiohttp/_cookie_helpers.py | 57 ++++++++++++++++++++++++------------ tests/test_cookie_helpers.py | 39 +++++++++++++++++++----- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/CHANGES/12395.bugfix.rst b/CHANGES/12395.bugfix.rst index be629bdad61..a99a760ce22 100644 --- a/CHANGES/12395.bugfix.rst +++ b/CHANGES/12395.bugfix.rst @@ -1,2 +1,6 @@ -Fixed a crash (``CookieError``) in the cookie parser when receiving a cookie containing ASCII control characters on Python 3.13+ (CVE-2026-3644). The parser now gracefully falls back to storing the raw, still-escaped ``coded_value`` without crashing the application. --- by :user:`rodrigobnogueira`. +Fixed a crash (``CookieError``) in the cookie parser when receiving cookies +containing ASCII control characters on CPython builds with the CVE-2026-3644 +patch. The parser now gracefully falls back to storing the raw, still-escaped +``coded_value`` when the decoded value contains control characters, and skips +cookies whose raw header itself contains unsalvageable literal control +characters -- by :user:`rodrigobnogueira`. diff --git a/aiohttp/_cookie_helpers.py b/aiohttp/_cookie_helpers.py index dce68bb16be..67912951d52 100644 --- a/aiohttp/_cookie_helpers.py +++ b/aiohttp/_cookie_helpers.py @@ -87,28 +87,41 @@ def _safe_set_morsel_state( key: str, value: str, coded_value: str, -) -> None: - r"""Set morsel state, handling Python 3.13+ control-character rejection. +) -> bool: + r"""Set morsel state, handling control-character rejection after CVE-2026-3644. - Python 3.13 added validation in ``Morsel.__setstate__`` that rejects - values containing ASCII control characters (CVE-2026-3644). When - ``_unquote`` decodes octal escape sequences (e.g. ``\\012`` → ``\\n``) - the resulting value may contain such characters. + CPython builds that include the CVE-2026-3644 patch added validation in + ``Morsel.__setstate__`` that rejects values containing ASCII control + characters. When ``_unquote`` decodes octal escape sequences + (e.g. ``\012`` → ``\n``) the resulting value may contain such characters. When that happens we fall back to storing the *raw* (still-escaped) ``coded_value`` as both ``value`` and ``coded_value`` so the cookie is preserved without crashing. + + If the ``coded_value`` itself contains literal control characters + (e.g. a raw ``\x07`` in the header), the cookie is unsalvageable and + the function returns ``False`` so the caller can skip it. + + Returns: + True if the morsel state was set successfully, False if the + cookie should be skipped. """ try: morsel.__setstate__( # type: ignore[attr-defined] {"key": key, "value": value, "coded_value": coded_value} ) except CookieError: - # The decoded value contains control characters that Python 3.13+ - # rejects. Fall back to keeping the raw coded_value as the value. - morsel.__setstate__( # type: ignore[attr-defined] - {"key": key, "value": coded_value, "coded_value": coded_value} - ) + # The decoded value contains control characters rejected after + # CVE-2026-3644. Fall back to keeping the raw coded_value. + try: + morsel.__setstate__( # type: ignore[attr-defined] + {"key": key, "value": coded_value, "coded_value": coded_value} + ) + except CookieError: + # coded_value itself has literal control chars — unsalvageable. + return False + return True def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]: @@ -131,7 +144,10 @@ def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]: """ mrsl_val = cast("Morsel[str]", cookie.get(cookie.key, Morsel())) - _safe_set_morsel_state(mrsl_val, cookie.key, cookie.value, cookie.coded_value) + if not _safe_set_morsel_state( + mrsl_val, cookie.key, cookie.value, cookie.coded_value + ): + return cookie return mrsl_val @@ -229,8 +245,8 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]: invalid_names.append(key) else: morsel = Morsel() - _safe_set_morsel_state(morsel, key, _unquote(value), value) - cookies.append((key, morsel)) + if _safe_set_morsel_state(morsel, key, _unquote(value), value): + cookies.append((key, morsel)) # Move to next cookie or end i = next_semi + 1 if next_semi != -1 else n @@ -248,7 +264,8 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]: # Create new morsel morsel = Morsel() # Preserve the original value as coded_value (with quotes if present) - _safe_set_morsel_state(morsel, key, _unquote(value), value) + if not _safe_set_morsel_state(morsel, key, _unquote(value), value): + continue cookies.append((key, morsel)) @@ -338,9 +355,13 @@ def parse_set_cookie_headers(headers: Sequence[str]) -> list[tuple[str, Morsel[s # Create new morsel current_morsel = Morsel() # Preserve the original value as coded_value (with quotes if present) - _safe_set_morsel_state(current_morsel, key, _unquote(value), value) - parsed_cookies.append((key, current_morsel)) - morsel_seen = True + if _safe_set_morsel_state( + current_morsel, key, _unquote(value), value + ): + parsed_cookies.append((key, current_morsel)) + morsel_seen = True + else: + current_morsel = None else: # Invalid cookie string - no value for non-attribute break diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index e7b74d5c948..087c86bb0c6 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -1141,14 +1141,14 @@ def test_parse_set_cookie_headers_uses_unquote_with_octal( (r'tab="\011separated\011values"', "tab", r'"\011separated\011values"'), ], ) -def test_parse_set_cookie_headers_ctl_chars( +def test_parse_set_cookie_headers_ctl_chars_from_octal( header: str, expected_name: str, expected_coded: str ) -> None: - """Test that parse_set_cookie_headers does not crash on control characters. + """Ensure octal escapes that decode to control characters don't crash the parser. - Python 3.13+ rejects control characters in cookies. When octal unquoting results - in a control character, we fall back to using the safe coded_value as the value - to avoid crashing the parser. + CPython builds with the CVE-2026-3644 patch reject control characters in + cookies. When octal unquoting produces a control character, the parser + should fall back to the raw coded_value instead of raising CookieError. """ result = parse_set_cookie_headers([header]) @@ -1157,11 +1157,36 @@ def test_parse_set_cookie_headers_ctl_chars( assert name == expected_name assert morsel.coded_value == expected_coded - # Depending on the Python version, morsel.value will either be the decoded string - # (Python < 3.13) or the raw coded_value (Python >= 3.13). + # Depending on CPython build, morsel.value will either be the decoded string + # (pre CVE-2026-3644 patch) or the raw coded_value (post patch). # We just ensure it doesn't crash and the coded_value is preserved. +def test_parse_set_cookie_headers_literal_ctl_chars() -> None: + """Ensure literal control characters in a cookie value don't crash the parser. + + If the raw header itself contains a control character (e.g. BEL \\x07), + both the decoded value and coded_value are unsalvageable. The parser + should gracefully skip the cookie instead of raising CookieError. + """ + result = parse_set_cookie_headers(['name="a\x07b"']) + # On CPython with CVE-2026-3644 patch the cookie is skipped; + # on older builds it may be accepted. Either way, no crash. + if result: + assert result[0][0] == "name" + + +def test_parse_set_cookie_headers_literal_ctl_chars_preserves_others() -> None: + """Ensure a cookie with literal control chars doesn't break subsequent cookies.""" + result = parse_set_cookie_headers( + ['bad="a\x07b"; good=value', "another=cookie"] + ) + # "good" is an attribute of "bad" (same header), so it's not a separate cookie. + # "another" is in a separate header and must always be preserved. + names = [name for name, _ in result] + assert "another" in names + + # Tests for parse_cookie_header (RFC 6265 compliant Cookie header parser) From 5ad7288b9911eb80e563bfdff4644940c5d0b064 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:51:08 +0000 Subject: [PATCH 06/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_cookie_helpers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index 087c86bb0c6..9ca4668ecff 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -1178,9 +1178,7 @@ def test_parse_set_cookie_headers_literal_ctl_chars() -> None: def test_parse_set_cookie_headers_literal_ctl_chars_preserves_others() -> None: """Ensure a cookie with literal control chars doesn't break subsequent cookies.""" - result = parse_set_cookie_headers( - ['bad="a\x07b"; good=value', "another=cookie"] - ) + result = parse_set_cookie_headers(['bad="a\x07b"; good=value', "another=cookie"]) # "good" is an attribute of "bad" (same header), so it's not a separate cookie. # "another" is in a separate header and must always be preserved. names = [name for name, _ in result] From 9c556cf8dc024a234dd7a938ae0ec55c49d483c5 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Mon, 20 Apr 2026 00:11:22 -0300 Subject: [PATCH 07/13] Address review feedback: spelling, coverage, remove artificial test - Add 'unsalvageable' to docs spelling wordlist (fixes linter) - Add test_parse_cookie_header_literal_ctl_chars for Cookie header path - Remove artificial test_preserve_morsel_with_coded_value_literal_ctl_chars (a Morsel with control chars can't be constructed through normal APIs) --- docs/spelling_wordlist.txt | 15 ++++++++------- tests/test_cookie_helpers.py | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index fea38e954d9..63da7196b96 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -30,9 +30,9 @@ autoformatters autogenerates autogeneration awaitable -backoff backend backends +backoff backport Backport Backporting @@ -100,8 +100,8 @@ deduplicate defs Dependabot deprecations -deserialization DER +deserialization dev Dev dict @@ -144,12 +144,12 @@ gunicorn’s gzipped hackish highlevel +hostname hostnames HTTPException HttpProcessingError httpretty https -hostname impl incapsulates Indices @@ -215,11 +215,11 @@ musllinux mypy Nagle Nagle's -NFS namedtuple nameservers namespace netrc +NFS nginx Nginx Nikolay @@ -265,8 +265,8 @@ pyright pytest Pytest qop -Quickstart quickstart +Quickstart quote’s rc readline @@ -361,12 +361,13 @@ unhandled unicode unittest Unittest -unpickler -untrusted unix unobvious +unpickler +unsalvageable unsets unstripped +untrusted untyped uppercased upstr diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index 9ca4668ecff..ab947d7f9b2 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -1648,8 +1648,18 @@ def test_parse_cookie_header_empty_key_in_fallback( assert name2 == "another" assert morsel2.value == "test" - assert "Cannot load cookie. Illegal cookie name" in caplog.text - assert "''" in caplog.text + +def test_parse_cookie_header_literal_ctl_chars() -> None: + """Ensure literal control characters in a cookie value don't crash the parser. + + If the raw header itself contains a control character (e.g. BEL \\x07), + the cookie is unsalvageable. The parser should gracefully skip it. + """ + result = parse_cookie_header('name="a\x07b"; good=cookie') + # On CPython with CVE-2026-3644 patch the bad cookie is skipped; + # on older builds it may be accepted. Either way, no crash. + names = [name for name, _ in result] + assert "good" in names @pytest.mark.parametrize( From 659bc5a303d9d06effa33bfdff82250418033d41 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Mon, 20 Apr 2026 00:14:05 -0300 Subject: [PATCH 08/13] Reword news fragment to avoid spelling issue, restore wordlist --- CHANGES/12395.bugfix.rst | 4 ++-- docs/spelling_wordlist.txt | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/CHANGES/12395.bugfix.rst b/CHANGES/12395.bugfix.rst index a99a760ce22..0aa597d4661 100644 --- a/CHANGES/12395.bugfix.rst +++ b/CHANGES/12395.bugfix.rst @@ -2,5 +2,5 @@ Fixed a crash (``CookieError``) in the cookie parser when receiving cookies containing ASCII control characters on CPython builds with the CVE-2026-3644 patch. The parser now gracefully falls back to storing the raw, still-escaped ``coded_value`` when the decoded value contains control characters, and skips -cookies whose raw header itself contains unsalvageable literal control -characters -- by :user:`rodrigobnogueira`. +cookies whose raw header contains literal control characters that cannot be +safely stored -- by :user:`rodrigobnogueira`. diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 63da7196b96..fea38e954d9 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -30,9 +30,9 @@ autoformatters autogenerates autogeneration awaitable +backoff backend backends -backoff backport Backport Backporting @@ -100,8 +100,8 @@ deduplicate defs Dependabot deprecations -DER deserialization +DER dev Dev dict @@ -144,12 +144,12 @@ gunicorn’s gzipped hackish highlevel -hostname hostnames HTTPException HttpProcessingError httpretty https +hostname impl incapsulates Indices @@ -215,11 +215,11 @@ musllinux mypy Nagle Nagle's +NFS namedtuple nameservers namespace netrc -NFS nginx Nginx Nikolay @@ -265,8 +265,8 @@ pyright pytest Pytest qop -quickstart Quickstart +quickstart quote’s rc readline @@ -361,13 +361,12 @@ unhandled unicode unittest Unittest +unpickler +untrusted unix unobvious -unpickler -unsalvageable unsets unstripped -untrusted untyped uppercased upstr From 78d8173568c1b0edcf84a752f69b1d45ddddfe3e Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Mon, 20 Apr 2026 00:16:54 -0300 Subject: [PATCH 09/13] Fix flake8 D301 Use r""" if any backslashes in a docstring --- tests/test_cookie_helpers.py | 40 ++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index ab947d7f9b2..ee4eed58e6c 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -9,6 +9,8 @@ SimpleCookie, _unquote as simplecookie_unquote, ) +import typing +from unittest.mock import patch import pytest @@ -1163,7 +1165,7 @@ def test_parse_set_cookie_headers_ctl_chars_from_octal( def test_parse_set_cookie_headers_literal_ctl_chars() -> None: - """Ensure literal control characters in a cookie value don't crash the parser. + r"""Ensure literal control characters in a cookie value don't crash the parser. If the raw header itself contains a control character (e.g. BEL \\x07), both the decoded value and coded_value are unsalvageable. The parser @@ -1650,7 +1652,7 @@ def test_parse_cookie_header_empty_key_in_fallback( def test_parse_cookie_header_literal_ctl_chars() -> None: - """Ensure literal control characters in a cookie value don't crash the parser. + r"""Ensure literal control characters in a cookie value don't crash the parser. If the raw header itself contains a control character (e.g. BEL \\x07), the cookie is unsalvageable. The parser should gracefully skip it. @@ -1850,3 +1852,37 @@ def test_unquote_compatibility_with_simplecookie(test_value: str) -> None: f"our={_unquote(test_value)!r}, " f"SimpleCookie={simplecookie_unquote(test_value)!r}" ) + + +@pytest.fixture +def mock_strict_morsel() -> typing.Iterator[None]: + original_setstate = Morsel.__setstate__ # type: ignore[attr-defined] + + def _mock_setstate(self: Morsel[str], state: dict[str, str]) -> None: + if any(ord(c) < 32 for c in state.get("value", "")): + raise CookieError() + original_setstate(self, state) + + with patch( + "aiohttp._cookie_helpers.Morsel.__setstate__", + autospec=True, + side_effect=_mock_setstate, + ): + yield + + +def test_cookie_helpers_cve_fallback(mock_strict_morsel: None) -> None: + m: Morsel[str] = Morsel() + assert helpers._safe_set_morsel_state(m, "k", "v\n", "v\\012") is True + assert m.value == "v\\012" + + assert helpers._safe_set_morsel_state(Morsel(), "k", "v\n", "v\n") is False + + cookie: Morsel[str] = Morsel() + cookie._key, cookie._value, cookie._coded_value = "k", "v\n", "v\n" # type: ignore[attr-defined] + assert preserve_morsel_with_coded_value(cookie) is cookie + + assert parse_cookie_header("f=b\x07r;") == [] + assert parse_cookie_header("f=b\x07r") == [] + assert parse_cookie_header("f=\"b\x07r\";") == [] + assert parse_set_cookie_headers(["f=\"b\x07r\";"]) == [] From d6c7ad842b5547982aff8e5e95f881cf1d3f34bd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 04:03:35 +0000 Subject: [PATCH 10/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_cookie_helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index ee4eed58e6c..d68fac806a9 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -3,13 +3,13 @@ import logging import sys import time +import typing from http.cookies import ( CookieError, Morsel, SimpleCookie, _unquote as simplecookie_unquote, ) -import typing from unittest.mock import patch import pytest @@ -1884,5 +1884,5 @@ def test_cookie_helpers_cve_fallback(mock_strict_morsel: None) -> None: assert parse_cookie_header("f=b\x07r;") == [] assert parse_cookie_header("f=b\x07r") == [] - assert parse_cookie_header("f=\"b\x07r\";") == [] - assert parse_set_cookie_headers(["f=\"b\x07r\";"]) == [] + assert parse_cookie_header('f="b\x07r";') == [] + assert parse_set_cookie_headers(['f="b\x07r";']) == [] From cece051be0007f828d0f81c4cdf6c0f60dfe2d1f Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Mon, 20 Apr 2026 01:17:20 -0300 Subject: [PATCH 11/13] Pin pyupgrade to Python 3.13 to fix crash on 3.14 alpha --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6acd60a083f..980855f8769 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -103,6 +103,7 @@ repos: hooks: - id: pyupgrade args: ['--py37-plus'] + language_version: python3.13 - repo: https://github.com/PyCQA/flake8 rev: '7.3.0' hooks: From 9317e233a708513820b836a8d12fbf544bba3907 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Mon, 20 Apr 2026 21:03:08 -0300 Subject: [PATCH 12/13] Address PR review: improve tests, Sphinx roles, remove pyupgrade pin - Use :cve:`2026-3644` and :external+python:exc: roles in changelog - Add pytest IDs to CTL chars from octal parametrize - Parametrize literal CTL char test (was two separate tests) - Use any() instead of list + in for semantic clarity - Replace unittest.mock.patch with monkeypatch fixture (no new dep) - Use @pytest.mark.usefixtures for void fixture injection - Remove pyupgrade language_version: python3.13 pin --- .pre-commit-config.yaml | 1 - CHANGES/12395.bugfix.rst | 10 +++--- aiohttp/_cookie_helpers.py | 22 +++---------- tests/test_cookie_helpers.py | 63 ++++++++++++++++++++---------------- 4 files changed, 44 insertions(+), 52 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 980855f8769..6acd60a083f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -103,7 +103,6 @@ repos: hooks: - id: pyupgrade args: ['--py37-plus'] - language_version: python3.13 - repo: https://github.com/PyCQA/flake8 rev: '7.3.0' hooks: diff --git a/CHANGES/12395.bugfix.rst b/CHANGES/12395.bugfix.rst index 0aa597d4661..e3c67bfa7aa 100644 --- a/CHANGES/12395.bugfix.rst +++ b/CHANGES/12395.bugfix.rst @@ -1,6 +1,4 @@ -Fixed a crash (``CookieError``) in the cookie parser when receiving cookies -containing ASCII control characters on CPython builds with the CVE-2026-3644 -patch. The parser now gracefully falls back to storing the raw, still-escaped -``coded_value`` when the decoded value contains control characters, and skips -cookies whose raw header contains literal control characters that cannot be -safely stored -- by :user:`rodrigobnogueira`. +Fixed a crash (:external+python:exc:`~http.cookies.CookieError`) in the cookie parser when receiving cookies +containing ASCII control characters on CPython builds with the :cve:`2026-3644` +patch. The parser now gracefully skips cookies whose value contains control +characters instead of letting the exception propagate -- by :user:`rodrigobnogueira`. diff --git a/aiohttp/_cookie_helpers.py b/aiohttp/_cookie_helpers.py index 67912951d52..4459a71129a 100644 --- a/aiohttp/_cookie_helpers.py +++ b/aiohttp/_cookie_helpers.py @@ -93,15 +93,11 @@ def _safe_set_morsel_state( CPython builds that include the CVE-2026-3644 patch added validation in ``Morsel.__setstate__`` that rejects values containing ASCII control characters. When ``_unquote`` decodes octal escape sequences - (e.g. ``\012`` → ``\n``) the resulting value may contain such characters. + (e.g. ``\012`` → ``\n``) the resulting value may contain such characters, + causing ``CookieError`` to be raised. - When that happens we fall back to storing the *raw* (still-escaped) - ``coded_value`` as both ``value`` and ``coded_value`` so the cookie - is preserved without crashing. - - If the ``coded_value`` itself contains literal control characters - (e.g. a raw ``\x07`` in the header), the cookie is unsalvageable and - the function returns ``False`` so the caller can skip it. + In that case the cookie is skipped entirely — the function returns ``False`` + so the caller can move on to the next cookie without crashing. Returns: True if the morsel state was set successfully, False if the @@ -112,15 +108,7 @@ def _safe_set_morsel_state( {"key": key, "value": value, "coded_value": coded_value} ) except CookieError: - # The decoded value contains control characters rejected after - # CVE-2026-3644. Fall back to keeping the raw coded_value. - try: - morsel.__setstate__( # type: ignore[attr-defined] - {"key": key, "value": coded_value, "coded_value": coded_value} - ) - except CookieError: - # coded_value itself has literal control chars — unsalvageable. - return False + return False return True diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index d68fac806a9..41997b5b3e7 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -3,14 +3,12 @@ import logging import sys import time -import typing from http.cookies import ( CookieError, Morsel, SimpleCookie, _unquote as simplecookie_unquote, ) -from unittest.mock import patch import pytest @@ -1139,8 +1137,18 @@ def test_parse_set_cookie_headers_uses_unquote_with_octal( @pytest.mark.parametrize( ("header", "expected_name", "expected_coded"), [ - (r'name="\012newline\012"', "name", r'"\012newline\012"'), - (r'tab="\011separated\011values"', "tab", r'"\011separated\011values"'), + pytest.param( + r'name="\012newline\012"', + "name", + r'"\012newline\012"', + id="newline-octal-012", + ), + pytest.param( + r'tab="\011separated\011values"', + "tab", + r'"\011separated\011values"', + id="tab-octal-011", + ), ], ) def test_parse_set_cookie_headers_ctl_chars_from_octal( @@ -1150,18 +1158,17 @@ def test_parse_set_cookie_headers_ctl_chars_from_octal( CPython builds with the CVE-2026-3644 patch reject control characters in cookies. When octal unquoting produces a control character, the parser - should fall back to the raw coded_value instead of raising CookieError. + skips the cookie entirely instead of raising CookieError. """ result = parse_set_cookie_headers([header]) - assert len(result) == 1 - name, morsel = result[0] - - assert name == expected_name - assert morsel.coded_value == expected_coded - # Depending on CPython build, morsel.value will either be the decoded string - # (pre CVE-2026-3644 patch) or the raw coded_value (post patch). - # We just ensure it doesn't crash and the coded_value is preserved. + # On CPython with CVE-2026-3644 patch the cookie is rejected (result is empty); + # on older builds it may be accepted with the decoded value. + # Either way, no crash. + if result: + name, morsel = result[0] + assert name == expected_name + assert morsel.coded_value == expected_coded def test_parse_set_cookie_headers_literal_ctl_chars() -> None: @@ -1183,8 +1190,7 @@ def test_parse_set_cookie_headers_literal_ctl_chars_preserves_others() -> None: result = parse_set_cookie_headers(['bad="a\x07b"; good=value', "another=cookie"]) # "good" is an attribute of "bad" (same header), so it's not a separate cookie. # "another" is in a separate header and must always be preserved. - names = [name for name, _ in result] - assert "another" in names + assert any(name == "another" for name, _ in result) # Tests for parse_cookie_header (RFC 6265 compliant Cookie header parser) @@ -1660,8 +1666,7 @@ def test_parse_cookie_header_literal_ctl_chars() -> None: result = parse_cookie_header('name="a\x07b"; good=cookie') # On CPython with CVE-2026-3644 patch the bad cookie is skipped; # on older builds it may be accepted. Either way, no crash. - names = [name for name, _ in result] - assert "good" in names + assert any(name == "good" for name, _ in result) @pytest.mark.parametrize( @@ -1855,7 +1860,9 @@ def test_unquote_compatibility_with_simplecookie(test_value: str) -> None: @pytest.fixture -def mock_strict_morsel() -> typing.Iterator[None]: +def mock_strict_morsel( + monkeypatch: pytest.MonkeyPatch, +) -> None: original_setstate = Morsel.__setstate__ # type: ignore[attr-defined] def _mock_setstate(self: Morsel[str], state: dict[str, str]) -> None: @@ -1863,19 +1870,18 @@ def _mock_setstate(self: Morsel[str], state: dict[str, str]) -> None: raise CookieError() original_setstate(self, state) - with patch( + monkeypatch.setattr( "aiohttp._cookie_helpers.Morsel.__setstate__", - autospec=True, - side_effect=_mock_setstate, - ): - yield - + _mock_setstate, + ) -def test_cookie_helpers_cve_fallback(mock_strict_morsel: None) -> None: - m: Morsel[str] = Morsel() - assert helpers._safe_set_morsel_state(m, "k", "v\n", "v\\012") is True - assert m.value == "v\\012" +@pytest.mark.usefixtures("mock_strict_morsel") +def test_cookie_helpers_cve_fallback() -> None: + # Clean value: mock delegates to original_setstate → succeeds + assert helpers._safe_set_morsel_state(Morsel(), "k", "clean", "clean") is True + # With strict morsel: any CTL char in value → CookieError → rejected + assert helpers._safe_set_morsel_state(Morsel(), "k", "v\n", "v\\012") is False assert helpers._safe_set_morsel_state(Morsel(), "k", "v\n", "v\n") is False cookie: Morsel[str] = Morsel() @@ -1886,3 +1892,4 @@ def test_cookie_helpers_cve_fallback(mock_strict_morsel: None) -> None: assert parse_cookie_header("f=b\x07r") == [] assert parse_cookie_header('f="b\x07r";') == [] assert parse_set_cookie_headers(['f="b\x07r";']) == [] + assert parse_set_cookie_headers([r'name="\012newline\012"']) == [] From ab616025b48d12992d61d3e598a4947adae4301f Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Wed, 6 May 2026 23:30:49 -0300 Subject: [PATCH 13/13] Inline _safe_set_morsel_state per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop _safe_set_morsel_state helper and inline the try/except at each call site, as suggested by Dreamsorcerer — the function was simplified to a trivial wrapper and no longer warrants its own definition. --- aiohttp/_cookie_helpers.py | 76 +++++++++++++++++------------------- tests/test_cookie_helpers.py | 15 +++++-- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/aiohttp/_cookie_helpers.py b/aiohttp/_cookie_helpers.py index 4459a71129a..039340b8941 100644 --- a/aiohttp/_cookie_helpers.py +++ b/aiohttp/_cookie_helpers.py @@ -82,36 +82,6 @@ ) -def _safe_set_morsel_state( - morsel: Morsel[str], - key: str, - value: str, - coded_value: str, -) -> bool: - r"""Set morsel state, handling control-character rejection after CVE-2026-3644. - - CPython builds that include the CVE-2026-3644 patch added validation in - ``Morsel.__setstate__`` that rejects values containing ASCII control - characters. When ``_unquote`` decodes octal escape sequences - (e.g. ``\012`` → ``\n``) the resulting value may contain such characters, - causing ``CookieError`` to be raised. - - In that case the cookie is skipped entirely — the function returns ``False`` - so the caller can move on to the next cookie without crashing. - - Returns: - True if the morsel state was set successfully, False if the - cookie should be skipped. - """ - try: - morsel.__setstate__( # type: ignore[attr-defined] - {"key": key, "value": value, "coded_value": coded_value} - ) - except CookieError: - return False - return True - - def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]: """ Preserve a Morsel's coded_value exactly as received from the server. @@ -132,9 +102,15 @@ def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]: """ mrsl_val = cast("Morsel[str]", cookie.get(cookie.key, Morsel())) - if not _safe_set_morsel_state( - mrsl_val, cookie.key, cookie.value, cookie.coded_value - ): + try: + mrsl_val.__setstate__( # type: ignore[attr-defined] + { + "key": cookie.key, + "value": cookie.value, + "coded_value": cookie.coded_value, + } + ) + except CookieError: return cookie return mrsl_val @@ -233,7 +209,17 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]: invalid_names.append(key) else: morsel = Morsel() - if _safe_set_morsel_state(morsel, key, _unquote(value), value): + try: + morsel.__setstate__( # type: ignore[attr-defined] + { + "key": key, + "value": _unquote(value), + "coded_value": value, + } + ) + except CookieError: + pass + else: cookies.append((key, morsel)) # Move to next cookie or end @@ -252,7 +238,11 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]: # Create new morsel morsel = Morsel() # Preserve the original value as coded_value (with quotes if present) - if not _safe_set_morsel_state(morsel, key, _unquote(value), value): + try: + morsel.__setstate__( # type: ignore[attr-defined] + {"key": key, "value": _unquote(value), "coded_value": value} + ) + except CookieError: continue cookies.append((key, morsel)) @@ -343,13 +333,19 @@ def parse_set_cookie_headers(headers: Sequence[str]) -> list[tuple[str, Morsel[s # Create new morsel current_morsel = Morsel() # Preserve the original value as coded_value (with quotes if present) - if _safe_set_morsel_state( - current_morsel, key, _unquote(value), value - ): + try: + current_morsel.__setstate__( # type: ignore[attr-defined] + { + "key": key, + "value": _unquote(value), + "coded_value": value, + } + ) + except CookieError: + current_morsel = None + else: parsed_cookies.append((key, current_morsel)) morsel_seen = True - else: - current_morsel = None else: # Invalid cookie string - no value for non-attribute break diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index 41997b5b3e7..be6228d698f 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -1879,10 +1879,19 @@ def _mock_setstate(self: Morsel[str], state: dict[str, str]) -> None: @pytest.mark.usefixtures("mock_strict_morsel") def test_cookie_helpers_cve_fallback() -> None: # Clean value: mock delegates to original_setstate → succeeds - assert helpers._safe_set_morsel_state(Morsel(), "k", "clean", "clean") is True + m: Morsel[str] = Morsel() + m.__setstate__({"key": "k", "value": "clean", "coded_value": "clean"}) # type: ignore[attr-defined] + assert m.key == "k" + # With strict morsel: any CTL char in value → CookieError → rejected - assert helpers._safe_set_morsel_state(Morsel(), "k", "v\n", "v\\012") is False - assert helpers._safe_set_morsel_state(Morsel(), "k", "v\n", "v\n") is False + with pytest.raises(CookieError): + Morsel().__setstate__( # type: ignore[attr-defined] + {"key": "k", "value": "v\n", "coded_value": "v\\012"} + ) + with pytest.raises(CookieError): + Morsel().__setstate__( # type: ignore[attr-defined] + {"key": "k", "value": "v\n", "coded_value": "v\n"} + ) cookie: Morsel[str] = Morsel() cookie._key, cookie._value, cookie._coded_value = "k", "v\n", "v\n" # type: ignore[attr-defined]