Skip to content

Commit 990c6b4

Browse files
authored
[PR #11724/82ce525b backport][3.14] Ensure cookies are still parsed after a malformed cookie (#11730)
1 parent 57ad7fa commit 990c6b4

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
@@ -166,7 +166,10 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]:
166166
attribute names (like 'path' or 'secure') should be treated as cookies.
167167
168168
This parser uses the same regex-based approach as parse_set_cookie_headers
169-
to properly handle quoted values that may contain semicolons.
169+
to properly handle quoted values that may contain semicolons. When the
170+
regex fails to match a malformed cookie, it falls back to simple parsing
171+
to ensure subsequent cookies are not lost
172+
https://github.com/aio-libs/aiohttp/issues/11632
170173
171174
Args:
172175
header: The Cookie header value to parse
@@ -178,14 +181,40 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]:
178181
return []
179182

180183
cookies: list[tuple[str, Morsel[str]]] = []
184+
morsel: Morsel[str]
181185
i = 0
182186
n = len(header)
183187

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

190219
key = match.group("key")
191220
value = match.group("val") or ""
@@ -197,7 +226,7 @@ def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]:
197226
continue
198227

199228
# Create new morsel
200-
morsel: Morsel[str] = Morsel()
229+
morsel = Morsel()
201230
# Preserve the original value as coded_value (with quotes if present)
202231
# We use __setstate__ instead of the public set() API because it allows us to
203232
# 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)