Skip to content

Commit 12274d0

Browse files
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
1 parent eee0ad7 commit 12274d0

File tree

3 files changed

+77
-27
lines changed

3 files changed

+77
-27
lines changed

CHANGES/12395.bugfix.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
1-
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.
2-
-- by :user:`rodrigobnogueira`.
1+
Fixed a crash (``CookieError``) in the cookie parser when receiving cookies
2+
containing ASCII control characters on CPython builds with the CVE-2026-3644
3+
patch. The parser now gracefully falls back to storing the raw, still-escaped
4+
``coded_value`` when the decoded value contains control characters, and skips
5+
cookies whose raw header itself contains unsalvageable literal control
6+
characters -- by :user:`rodrigobnogueira`.

aiohttp/_cookie_helpers.py

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -87,28 +87,41 @@ def _safe_set_morsel_state(
8787
key: str,
8888
value: str,
8989
coded_value: str,
90-
) -> None:
91-
r"""Set morsel state, handling Python 3.13+ control-character rejection.
90+
) -> bool:
91+
r"""Set morsel state, handling control-character rejection after CVE-2026-3644.
9292
93-
Python 3.13 added validation in ``Morsel.__setstate__`` that rejects
94-
values containing ASCII control characters (CVE-2026-3644). When
95-
``_unquote`` decodes octal escape sequences (e.g. ``\\012`` → ``\\n``)
96-
the resulting value may contain such characters.
93+
CPython builds that include the CVE-2026-3644 patch added validation in
94+
``Morsel.__setstate__`` that rejects values containing ASCII control
95+
characters. When ``_unquote`` decodes octal escape sequences
96+
(e.g. ``\012`` → ``\n``) the resulting value may contain such characters.
9797
9898
When that happens we fall back to storing the *raw* (still-escaped)
9999
``coded_value`` as both ``value`` and ``coded_value`` so the cookie
100100
is preserved without crashing.
101+
102+
If the ``coded_value`` itself contains literal control characters
103+
(e.g. a raw ``\x07`` in the header), the cookie is unsalvageable and
104+
the function returns ``False`` so the caller can skip it.
105+
106+
Returns:
107+
True if the morsel state was set successfully, False if the
108+
cookie should be skipped.
101109
"""
102110
try:
103111
morsel.__setstate__( # type: ignore[attr-defined]
104112
{"key": key, "value": value, "coded_value": coded_value}
105113
)
106114
except CookieError:
107-
# The decoded value contains control characters that Python 3.13+
108-
# rejects. Fall back to keeping the raw coded_value as the value.
109-
morsel.__setstate__( # type: ignore[attr-defined]
110-
{"key": key, "value": coded_value, "coded_value": coded_value}
111-
)
115+
# The decoded value contains control characters rejected after
116+
# CVE-2026-3644. Fall back to keeping the raw coded_value.
117+
try:
118+
morsel.__setstate__( # type: ignore[attr-defined]
119+
{"key": key, "value": coded_value, "coded_value": coded_value}
120+
)
121+
except CookieError:
122+
# coded_value itself has literal control chars — unsalvageable.
123+
return False
124+
return True
112125

113126

114127
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]:
131144
132145
"""
133146
mrsl_val = cast("Morsel[str]", cookie.get(cookie.key, Morsel()))
134-
_safe_set_morsel_state(mrsl_val, cookie.key, cookie.value, cookie.coded_value)
147+
if not _safe_set_morsel_state(
148+
mrsl_val, cookie.key, cookie.value, cookie.coded_value
149+
):
150+
return cookie
135151
return mrsl_val
136152

137153

@@ -229,8 +245,8 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]:
229245
invalid_names.append(key)
230246
else:
231247
morsel = Morsel()
232-
_safe_set_morsel_state(morsel, key, _unquote(value), value)
233-
cookies.append((key, morsel))
248+
if _safe_set_morsel_state(morsel, key, _unquote(value), value):
249+
cookies.append((key, morsel))
234250

235251
# Move to next cookie or end
236252
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]]]:
248264
# Create new morsel
249265
morsel = Morsel()
250266
# Preserve the original value as coded_value (with quotes if present)
251-
_safe_set_morsel_state(morsel, key, _unquote(value), value)
267+
if not _safe_set_morsel_state(morsel, key, _unquote(value), value):
268+
continue
252269

253270
cookies.append((key, morsel))
254271

@@ -338,9 +355,13 @@ def parse_set_cookie_headers(headers: Sequence[str]) -> list[tuple[str, Morsel[s
338355
# Create new morsel
339356
current_morsel = Morsel()
340357
# Preserve the original value as coded_value (with quotes if present)
341-
_safe_set_morsel_state(current_morsel, key, _unquote(value), value)
342-
parsed_cookies.append((key, current_morsel))
343-
morsel_seen = True
358+
if _safe_set_morsel_state(
359+
current_morsel, key, _unquote(value), value
360+
):
361+
parsed_cookies.append((key, current_morsel))
362+
morsel_seen = True
363+
else:
364+
current_morsel = None
344365
else:
345366
# Invalid cookie string - no value for non-attribute
346367
break

tests/test_cookie_helpers.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,14 +1141,14 @@ def test_parse_set_cookie_headers_uses_unquote_with_octal(
11411141
(r'tab="\011separated\011values"', "tab", r'"\011separated\011values"'),
11421142
],
11431143
)
1144-
def test_parse_set_cookie_headers_ctl_chars(
1144+
def test_parse_set_cookie_headers_ctl_chars_from_octal(
11451145
header: str, expected_name: str, expected_coded: str
11461146
) -> None:
1147-
"""Test that parse_set_cookie_headers does not crash on control characters.
1147+
"""Ensure octal escapes that decode to control characters don't crash the parser.
11481148
1149-
Python 3.13+ rejects control characters in cookies. When octal unquoting results
1150-
in a control character, we fall back to using the safe coded_value as the value
1151-
to avoid crashing the parser.
1149+
CPython builds with the CVE-2026-3644 patch reject control characters in
1150+
cookies. When octal unquoting produces a control character, the parser
1151+
should fall back to the raw coded_value instead of raising CookieError.
11521152
"""
11531153
result = parse_set_cookie_headers([header])
11541154

@@ -1157,11 +1157,36 @@ def test_parse_set_cookie_headers_ctl_chars(
11571157

11581158
assert name == expected_name
11591159
assert morsel.coded_value == expected_coded
1160-
# Depending on the Python version, morsel.value will either be the decoded string
1161-
# (Python < 3.13) or the raw coded_value (Python >= 3.13).
1160+
# Depending on CPython build, morsel.value will either be the decoded string
1161+
# (pre CVE-2026-3644 patch) or the raw coded_value (post patch).
11621162
# We just ensure it doesn't crash and the coded_value is preserved.
11631163

11641164

1165+
def test_parse_set_cookie_headers_literal_ctl_chars() -> None:
1166+
"""Ensure literal control characters in a cookie value don't crash the parser.
1167+
1168+
If the raw header itself contains a control character (e.g. BEL \\x07),
1169+
both the decoded value and coded_value are unsalvageable. The parser
1170+
should gracefully skip the cookie instead of raising CookieError.
1171+
"""
1172+
result = parse_set_cookie_headers(['name="a\x07b"'])
1173+
# On CPython with CVE-2026-3644 patch the cookie is skipped;
1174+
# on older builds it may be accepted. Either way, no crash.
1175+
if result:
1176+
assert result[0][0] == "name"
1177+
1178+
1179+
def test_parse_set_cookie_headers_literal_ctl_chars_preserves_others() -> None:
1180+
"""Ensure a cookie with literal control chars doesn't break subsequent cookies."""
1181+
result = parse_set_cookie_headers(
1182+
['bad="a\x07b"; good=value', "another=cookie"]
1183+
)
1184+
# "good" is an attribute of "bad" (same header), so it's not a separate cookie.
1185+
# "another" is in a separate header and must always be preserved.
1186+
names = [name for name, _ in result]
1187+
assert "another" in names
1188+
1189+
11651190
# Tests for parse_cookie_header (RFC 6265 compliant Cookie header parser)
11661191

11671192

0 commit comments

Comments
 (0)