Skip to content

Commit 95daf0c

Browse files
authored
[PR #11724/82ce525b backport][3.13] Ensure cookies are still parsed after a malformed cookie (#11729)
1 parent b734e04 commit 95daf0c

File tree

4 files changed

+170
-4
lines changed

4 files changed

+170
-4
lines changed

CHANGES/11632.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed cookie parser to continue parsing subsequent cookies when encountering a malformed cookie that fails regex validation, such as Google's ``g_state`` cookie with unescaped quotes -- by :user:`bdraco`.

aiohttp/_cookie_helpers.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ def parse_cookie_header(header: str) -> List[Tuple[str, Morsel[str]]]:
165165
attribute names (like 'path' or 'secure') should be treated as cookies.
166166
167167
This parser uses the same regex-based approach as parse_set_cookie_headers
168-
to properly handle quoted values that may contain semicolons.
168+
to properly handle quoted values that may contain semicolons. When the
169+
regex fails to match a malformed cookie, it falls back to simple parsing
170+
to ensure subsequent cookies are not lost
171+
https://github.com/aio-libs/aiohttp/issues/11632
169172
170173
Args:
171174
header: The Cookie header value to parse
@@ -177,14 +180,40 @@ def parse_cookie_header(header: str) -> List[Tuple[str, Morsel[str]]]:
177180
return []
178181

179182
cookies: List[Tuple[str, Morsel[str]]] = []
183+
morsel: Morsel[str]
180184
i = 0
181185
n = len(header)
182186

183187
while i < n:
184188
# Use the same pattern as parse_set_cookie_headers to find cookies
185189
match = _COOKIE_PATTERN.match(header, i)
186190
if not match:
187-
break
191+
# Fallback for malformed cookies https://github.com/aio-libs/aiohttp/issues/11632
192+
# Find next semicolon to skip or attempt simple key=value parsing
193+
next_semi = header.find(";", i)
194+
eq_pos = header.find("=", i)
195+
196+
# Try to extract key=value if '=' comes before ';'
197+
if eq_pos != -1 and (next_semi == -1 or eq_pos < next_semi):
198+
end_pos = next_semi if next_semi != -1 else n
199+
key = header[i:eq_pos].strip()
200+
value = header[eq_pos + 1 : end_pos].strip()
201+
202+
# Validate the name (same as regex path)
203+
if not _COOKIE_NAME_RE.match(key):
204+
internal_logger.warning(
205+
"Can not load cookie: Illegal cookie name %r", key
206+
)
207+
else:
208+
morsel = Morsel()
209+
morsel.__setstate__( # type: ignore[attr-defined]
210+
{"key": key, "value": _unquote(value), "coded_value": value}
211+
)
212+
cookies.append((key, morsel))
213+
214+
# Move to next cookie or end
215+
i = next_semi + 1 if next_semi != -1 else n
216+
continue
188217

189218
key = match.group("key")
190219
value = match.group("val") or ""
@@ -196,7 +225,7 @@ def parse_cookie_header(header: str) -> List[Tuple[str, Morsel[str]]]:
196225
continue
197226

198227
# Create new morsel
199-
morsel: Morsel[str] = Morsel()
228+
morsel = Morsel()
200229
# Preserve the original value as coded_value (with quotes if present)
201230
# We use __setstate__ instead of the public set() API because it allows us to
202231
# bypass validation and set already validated state. This is more stable than

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ un
343343
unawaited
344344
unclosed
345345
undercounting
346+
unescaped
346347
unhandled
347348
unicode
348349
unittest

tests/test_cookie_helpers.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1137,7 +1137,6 @@ def test_parse_cookie_header_empty() -> None:
11371137
assert parse_cookie_header(" ") == []
11381138

11391139

1140-
@pytest.mark.xfail(reason="https://github.com/aio-libs/aiohttp/issues/11632")
11411140
def test_parse_cookie_gstate_header() -> None:
11421141
header = (
11431142
"_ga=ga; "
@@ -1444,6 +1443,142 @@ def test_parse_cookie_header_illegal_names(caplog: pytest.LogCaptureFixture) ->
14441443
assert "Can not load cookie: Illegal cookie name 'invalid,cookie'" in caplog.text
14451444

14461445

1446+
def test_parse_cookie_header_large_value() -> None:
1447+
"""Test that large cookie values don't cause DoS."""
1448+
large_value = "A" * 8192
1449+
header = f"normal=value; large={large_value}; after=cookie"
1450+
1451+
result = parse_cookie_header(header)
1452+
cookie_names = [name for name, _ in result]
1453+
1454+
assert len(result) == 3
1455+
assert "normal" in cookie_names
1456+
assert "large" in cookie_names
1457+
assert "after" in cookie_names
1458+
1459+
large_cookie = next(morsel for name, morsel in result if name == "large")
1460+
assert len(large_cookie.value) == 8192
1461+
1462+
1463+
def test_parse_cookie_header_multiple_equals() -> None:
1464+
"""Test handling of multiple equals signs in cookie values."""
1465+
header = "session=abc123; data=key1=val1&key2=val2; token=xyz"
1466+
1467+
result = parse_cookie_header(header)
1468+
1469+
assert len(result) == 3
1470+
1471+
name1, morsel1 = result[0]
1472+
assert name1 == "session"
1473+
assert morsel1.value == "abc123"
1474+
1475+
name2, morsel2 = result[1]
1476+
assert name2 == "data"
1477+
assert morsel2.value == "key1=val1&key2=val2"
1478+
1479+
name3, morsel3 = result[2]
1480+
assert name3 == "token"
1481+
assert morsel3.value == "xyz"
1482+
1483+
1484+
def test_parse_cookie_header_fallback_preserves_subsequent_cookies() -> None:
1485+
"""Test that fallback parser doesn't lose subsequent cookies."""
1486+
header = 'normal=value; malformed={"json":"value"}; after1=cookie1; after2=cookie2'
1487+
1488+
result = parse_cookie_header(header)
1489+
cookie_names = [name for name, _ in result]
1490+
1491+
assert len(result) == 4
1492+
assert cookie_names == ["normal", "malformed", "after1", "after2"]
1493+
1494+
name1, morsel1 = result[0]
1495+
assert morsel1.value == "value"
1496+
1497+
name2, morsel2 = result[1]
1498+
assert morsel2.value == '{"json":"value"}'
1499+
1500+
name3, morsel3 = result[2]
1501+
assert morsel3.value == "cookie1"
1502+
1503+
name4, morsel4 = result[3]
1504+
assert morsel4.value == "cookie2"
1505+
1506+
1507+
def test_parse_cookie_header_whitespace_in_fallback() -> None:
1508+
"""Test that fallback parser handles whitespace correctly."""
1509+
header = "a=1; b = 2 ; c= 3; d =4"
1510+
1511+
result = parse_cookie_header(header)
1512+
1513+
assert len(result) == 4
1514+
for name, morsel in result:
1515+
assert name in ("a", "b", "c", "d")
1516+
assert morsel.value in ("1", "2", "3", "4")
1517+
1518+
1519+
def test_parse_cookie_header_empty_value_in_fallback() -> None:
1520+
"""Test that fallback handles empty values correctly."""
1521+
header = "normal=value; empty=; another=test"
1522+
1523+
result = parse_cookie_header(header)
1524+
1525+
assert len(result) == 3
1526+
1527+
name1, morsel1 = result[0]
1528+
assert name1 == "normal"
1529+
assert morsel1.value == "value"
1530+
1531+
name2, morsel2 = result[1]
1532+
assert name2 == "empty"
1533+
assert morsel2.value == ""
1534+
1535+
name3, morsel3 = result[2]
1536+
assert name3 == "another"
1537+
assert morsel3.value == "test"
1538+
1539+
1540+
def test_parse_cookie_header_invalid_name_in_fallback(
1541+
caplog: pytest.LogCaptureFixture,
1542+
) -> None:
1543+
"""Test that fallback parser rejects cookies with invalid names."""
1544+
header = 'normal=value; invalid,name={"x":"y"}; another=test'
1545+
1546+
result = parse_cookie_header(header)
1547+
1548+
assert len(result) == 2
1549+
1550+
name1, morsel1 = result[0]
1551+
assert name1 == "normal"
1552+
assert morsel1.value == "value"
1553+
1554+
name2, morsel2 = result[1]
1555+
assert name2 == "another"
1556+
assert morsel2.value == "test"
1557+
1558+
assert "Can not load cookie: Illegal cookie name 'invalid,name'" in caplog.text
1559+
1560+
1561+
def test_parse_cookie_header_empty_key_in_fallback(
1562+
caplog: pytest.LogCaptureFixture,
1563+
) -> None:
1564+
"""Test that fallback parser logs warning for empty cookie names."""
1565+
header = 'normal=value; ={"malformed":"json"}; another=test'
1566+
1567+
result = parse_cookie_header(header)
1568+
1569+
assert len(result) == 2
1570+
1571+
name1, morsel1 = result[0]
1572+
assert name1 == "normal"
1573+
assert morsel1.value == "value"
1574+
1575+
name2, morsel2 = result[1]
1576+
assert name2 == "another"
1577+
assert morsel2.value == "test"
1578+
1579+
assert "Can not load cookie: Illegal cookie name ''" in caplog.text
1580+
1581+
14471582
@pytest.mark.parametrize(
14481583
("input_str", "expected"),
14491584
[

0 commit comments

Comments
 (0)