Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/_cookie_helpers.py: 16%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2Internal cookie handling helpers.
4This module contains internal utilities for cookie parsing and manipulation.
5These are not part of the public API and may change without notice.
6"""
8import re
9from collections.abc import Sequence
10from http.cookies import CookieError, Morsel
12from .log import internal_logger
14__all__ = (
15 "parse_set_cookie_headers",
16 "parse_cookie_header",
17 "preserve_morsel_with_coded_value",
18)
20# Cookie parsing constants
21# Allow more characters in cookie names to handle real-world cookies
22# that don't strictly follow RFC standards (fixes #2683)
23# RFC 6265 defines cookie-name token as per RFC 2616 Section 2.2,
24# but many servers send cookies with characters like {} [] () etc.
25# This makes the cookie parser more tolerant of real-world cookies
26# while still providing some validation to catch obviously malformed names.
27_COOKIE_NAME_RE = re.compile(r"^[!#$%&\'()*+\-./0-9:<=>?@A-Z\[\]^_`a-z{|}~]+$")
28_COOKIE_KNOWN_ATTRS = frozenset( # AKA Morsel._reserved
29 (
30 "path",
31 "domain",
32 "max-age",
33 "expires",
34 "secure",
35 "httponly",
36 "samesite",
37 "partitioned",
38 "version",
39 "comment",
40 )
41)
42_COOKIE_BOOL_ATTRS = frozenset( # AKA Morsel._flags
43 ("secure", "httponly", "partitioned")
44)
46# Implementation limits as permitted by RFC 6265 Section 6.1.
47_MAX_COOKIES_PER_RESPONSE = 50
48_MAX_COOKIE_PAIR_LENGTH = 4096
49_MAX_COOKIE_ATTRIBUTE_VALUE_LENGTH = 1024
50# Like Chromium's ParsedCookie::kMaxPairs, but more lenient.
51_MAX_COOKIE_ATTRIBUTES = 32
53# Where the next attribute can start: skips runs of ";" and whitespace.
54_ATTRIBUTE_START_RE = re.compile(r"[^;\s]", re.ASCII)
55_COOKIE_FORBIDDEN_CTL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
56_COOKIE_CTL_RE = re.compile(r"[\x00-\x1f\x7f]")
58# SimpleCookie's pattern for parsing cookies with relaxed validation
59# Based on http.cookies pattern but extended to allow more characters in cookie names
60# to handle real-world cookies (fixes #2683)
61_COOKIE_PATTERN = re.compile(
62 r"""
63 \s* # Optional whitespace at start of cookie
64 (?P<key> # Start of group 'key'
65 # aiohttp has extended to include [] for compatibility with real-world cookies
66 [\w\d!#%&'~_`><@,:/\$\*\+\-\.\^\|\)\(\?\}\{\[\]]+ # Any word of at least one letter
67 ) # End of group 'key'
68 ( # Optional group: there may not be a value.
69 \s*=\s* # Equal Sign
70 (?P<val> # Start of group 'val'
71 "(?:[^\\"]|\\.)*" # Any double-quoted string (properly closed)
72 | # or
73 "[^";]* # Unmatched opening quote (differs from SimpleCookie - issue #7993)
74 | # or
75 # Special case for "expires" attr - RFC 822, RFC 850, RFC 1036, RFC 1123
76 (\w{3,6}day|\w{3}),\s # Day of the week or abbreviated day (with comma)
77 [\w\d\s-]{9,11}\s[\d:]{8}\s # Date and time in specific format
78 (GMT|[+-]\d{4}) # Timezone: GMT or RFC 2822 offset like -0000, +0100
79 # NOTE: RFC 2822 timezone support is an aiohttp extension
80 # for issue #4493 - SimpleCookie does NOT support this
81 | # or
82 # ANSI C asctime() format: "Wed Jun 9 10:18:14 2021"
83 # NOTE: This is an aiohttp extension for issue #4327 - SimpleCookie does NOT support this format
84 \w{3}\s+\w{3}\s+[\s\d]\d\s+\d{2}:\d{2}:\d{2}\s+\d{4}
85 | # or
86 [\w\d!#%&'~_`><@,:/\$\*\+\-\.\^\|\)\(\?\}\{\=\[\]]* # Any word or empty string
87 ) # End of group 'val'
88 )? # End of optional value group
89 \s* # Any number of spaces.
90 (\s+|;|$) # Ending either at space, semicolon, or EOS.
91 """,
92 re.VERBOSE | re.ASCII,
93)
96def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]:
97 """
98 Preserve a Morsel's coded_value exactly as received from the server.
100 This function ensures that cookie encoding is preserved exactly as sent by
101 the server, which is critical for compatibility with old servers that have
102 strict requirements about cookie formats.
104 This addresses the issue described in https://github.com/aio-libs/aiohttp/pull/1453
105 where Python's SimpleCookie would re-encode cookies, breaking authentication
106 with certain servers.
108 Args:
109 cookie: A Morsel object from SimpleCookie
111 Returns:
112 A Morsel object with preserved coded_value
114 """
115 # Don't look up cookie.key in the Morsel: it maps attribute names, so a
116 # cookie named ``path`` would return the Path attribute.
117 mrsl_val: Morsel[str] = Morsel()
118 # We use __setstate__ instead of the public set() API because it allows us to
119 # bypass validation and set already validated state. This is more stable than
120 # setting protected attributes directly and unlikely to change since it would
121 # break pickling.
122 try:
123 mrsl_val.__setstate__( # type: ignore[attr-defined]
124 {
125 "key": cookie.key,
126 "value": cookie.value,
127 "coded_value": cookie.coded_value,
128 }
129 )
130 except CookieError:
131 return cookie
132 return mrsl_val
135_unquote_sub = re.compile(r"\\(?:([0-3][0-7][0-7])|(.))").sub
138def _unquote_replace(m: re.Match[str]) -> str:
139 """
140 Replace function for _unquote_sub regex substitution.
142 Handles escaped characters in cookie values:
143 - Octal sequences are converted to their character representation
144 - Other escaped characters are unescaped by removing the backslash
145 """
146 if m[1]:
147 return chr(int(m[1], 8))
148 return m[2]
151def _unquote(value: str) -> str:
152 """
153 Unquote a cookie value.
155 Vendored from http.cookies._unquote to ensure compatibility.
157 Note: The original implementation checked for None, but we've removed
158 that check since all callers already ensure the value is not None.
159 """
160 # If there aren't any doublequotes,
161 # then there can't be any special characters. See RFC 2109.
162 if len(value) < 2:
163 return value
164 if value[0] != '"' or value[-1] != '"':
165 return value
167 # We have to assume that we must decode this string.
168 # Down to work.
170 # Remove the "s
171 value = value[1:-1]
173 # Check for special sequences. Examples:
174 # \012 --> \n
175 # \" --> "
176 #
177 return _unquote_sub(_unquote_replace, value)
180def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]:
181 """
182 Parse a Cookie header according to RFC 6265 Section 5.4.
184 Cookie headers contain only name-value pairs separated by semicolons.
185 There are no attributes in Cookie headers - even names that match
186 attribute names (like 'path' or 'secure') should be treated as cookies.
188 This parser uses _COOKIE_PATTERN to properly handle quoted values that may
189 contain semicolons. When the regex fails to match a malformed cookie, it
190 falls back to simple parsing to ensure subsequent cookies are not lost
191 https://github.com/aio-libs/aiohttp/issues/11632
193 Args:
194 header: The Cookie header value to parse
196 Returns:
197 List of (name, Morsel) tuples for compatibility with SimpleCookie.update()
198 """
199 if not header:
200 return []
202 morsel: Morsel[str]
203 cookies: list[tuple[str, Morsel[str]]] = []
204 i = 0
205 n = len(header)
207 invalid_names = []
208 while i < n:
209 # Find the next cookie-pair
210 match = _COOKIE_PATTERN.match(header, i)
211 if not match:
212 # Fallback for malformed cookies https://github.com/aio-libs/aiohttp/issues/11632
213 # Find next semicolon to skip or attempt simple key=value parsing
214 next_semi = header.find(";", i)
215 eq_pos = header.find("=", i)
217 # Try to extract key=value if '=' comes before ';'
218 if eq_pos != -1 and (next_semi == -1 or eq_pos < next_semi):
219 end_pos = next_semi if next_semi != -1 else n
220 key = header[i:eq_pos].strip()
221 value = header[eq_pos + 1 : end_pos].strip()
223 # Validate the name (same as regex path)
224 if not _COOKIE_NAME_RE.match(key):
225 invalid_names.append(key)
226 else:
227 morsel = Morsel()
228 try:
229 morsel.__setstate__( # type: ignore[attr-defined]
230 {
231 "key": key,
232 "value": _unquote(value),
233 "coded_value": value,
234 }
235 )
236 except CookieError:
237 pass
238 else:
239 cookies.append((key, morsel))
241 # Move to next cookie or end
242 i = next_semi + 1 if next_semi != -1 else n
243 continue
245 key = match.group("key")
246 value = match.group("val") or ""
247 i = match.end(0)
249 # Validate the name
250 if not key or not _COOKIE_NAME_RE.match(key):
251 invalid_names.append(key)
252 continue
254 # Create new morsel
255 morsel = Morsel()
256 # Preserve the original value as coded_value (with quotes if present)
257 # We use __setstate__ instead of the public set() API because it allows us to
258 # bypass validation and set already validated state. This is more stable than
259 # setting protected attributes directly and unlikely to change since it would
260 # break pickling.
261 try:
262 morsel.__setstate__( # type: ignore[attr-defined]
263 {"key": key, "value": _unquote(value), "coded_value": value}
264 )
265 except CookieError:
266 continue
268 cookies.append((key, morsel))
270 if invalid_names:
271 internal_logger.debug(
272 "Cannot load cookie. Illegal cookie names: %r", invalid_names
273 )
275 return cookies
278def _encoded_length(text: str) -> int | None:
279 """Return the UTF-8 length of text, or None if it can't be encoded."""
280 if text.isascii():
281 return len(text)
282 try:
283 return len(text.encode("utf-8"))
284 except UnicodeEncodeError:
285 return None
288def _apply_cookie_attributes(morsel: Morsel[str], attributes: str) -> None:
289 """Set the recognized attributes from the text after the cookie-pair.
291 The field must already be free of control characters other than tab.
292 """
293 # Walk with the cookie pattern so a quoted value keeps its ";". Missing
294 # semicolons between attributes are tolerated; every match is an attribute
295 # of this cookie, never a new cookie.
296 index = 0
297 end = len(attributes)
298 for _ in range(_MAX_COOKIE_ATTRIBUTES):
299 if index >= end:
300 break
301 if (attribute_match := _COOKIE_PATTERN.match(attributes, index)) is None:
302 # Skip a malformed segment and any run of ";" or whitespace after it.
303 if (
304 not (next_index := attributes.find(";", index) + 1)
305 or (start := _ATTRIBUTE_START_RE.search(attributes, next_index)) is None
306 ):
307 break
308 index = start.start()
309 continue
310 index = attribute_match.end()
311 lower_key = attribute_match.group("key").lower()
312 if lower_key not in _COOKIE_KNOWN_ATTRS:
313 # RFC 6265 Section 5.2: ignore unknown attributes.
314 continue
315 if lower_key not in morsel._reserved: # type: ignore[attr-defined]
316 # Python versions before 3.14 do not expose Partitioned.
317 continue
319 # The key and value are checked here, so skip Morsel.__setitem__'s checks.
320 if lower_key in _COOKIE_BOOL_ATTRS:
321 # Like Firefox, a flag's value is ignored.
322 dict.__setitem__(morsel, lower_key, True)
323 continue
324 attr_value = attribute_match.group("val") or ""
325 if (
326 len(attr_value) <= _MAX_COOKIE_ATTRIBUTE_VALUE_LENGTH
327 and attr_value.isascii()
328 and attr_value[:1] != '"'
329 and "\t" not in attr_value
330 ):
331 # The field has no other control characters and nothing to unquote.
332 dict.__setitem__(morsel, lower_key, attr_value)
333 continue
334 attr_value_length = _encoded_length(attr_value)
335 if (
336 attr_value_length is None
337 or attr_value_length > _MAX_COOKIE_ATTRIBUTE_VALUE_LENGTH
338 ):
339 continue
340 # Patched CPython rejects control characters, even ones produced by
341 # unquoting, so ignore such an attribute rather than raise.
342 if _COOKIE_CTL_RE.search(value := _unquote(attr_value)) is None:
343 dict.__setitem__(morsel, lower_key, value)
346def _parse_quoted_pair(header: str) -> tuple[str, str, int] | None:
347 """Parse a first pair whose quoted value contains ";".
349 aiohttp has always accepted these. Returns the name, the quoted value and
350 where the attributes start, or None to split at the first ";" instead.
351 """
352 match = _COOKIE_PATTERN.match(header)
353 if match is None or not (value := match.group("val")):
354 return None
355 if not (value.startswith('"') and value.endswith('"') and ";" in value):
356 return None
357 tail = header[match.end("val") :].lstrip(" \t")
358 if tail and not tail.startswith(";"):
359 return None
360 return match.group("key"), value, len(header) - len(tail) + bool(tail)
363def parse_set_cookie_headers(headers: Sequence[str]) -> list[tuple[str, Morsel[str]]]:
364 """Parse Set-Cookie fields into at most one cookie per field.
366 Fields with control characters, oversized or nameless pairs, names outside
367 the allowlist, or tabs in the pair are skipped.
368 """
369 parsed_cookies: list[tuple[str, Morsel[str]]] = []
370 over_limit = 0
372 for position, header in enumerate(headers):
373 if len(parsed_cookies) >= _MAX_COOKIES_PER_RESPONSE:
374 over_limit = len(headers) - position
375 break
376 if not header or _COOKIE_FORBIDDEN_CTL_RE.search(header) is not None:
377 continue
379 if (pair_end := header.find(";")) == -1:
380 pair_end = attributes_start = len(header)
381 else:
382 attributes_start = pair_end + 1
383 # A quote before the first ";" may start a quoted value containing ";".
384 if header.find('"', 0, pair_end) != -1 and (
385 quoted := _parse_quoted_pair(header)
386 ):
387 key, coded_value, attributes_start = quoted
388 else:
389 key, sep, coded_value = header[:pair_end].partition("=")
390 if not sep:
391 continue
393 key = key.strip(" \t")
394 coded_value = coded_value.strip(" \t")
396 if not key:
397 continue
398 if not _COOKIE_NAME_RE.match(key):
399 internal_logger.warning("Can not load cookies: Illegal cookie name %r", key)
400 continue
401 pair_length = _encoded_length(key + coded_value)
402 if pair_length is None or pair_length > _MAX_COOKIE_PAIR_LENGTH:
403 continue
405 value = _unquote(coded_value)
406 if _COOKIE_CTL_RE.search(value) is not None:
407 continue
408 morsel: Morsel[str] = Morsel()
409 try:
410 morsel.__setstate__( # type: ignore[attr-defined]
411 {"key": key, "value": value, "coded_value": coded_value}
412 )
413 except CookieError:
414 continue
416 _apply_cookie_attributes(morsel, header[attributes_start:])
417 parsed_cookies.append((key, morsel))
419 if (invalid := len(headers) - over_limit - len(parsed_cookies)) or over_limit:
420 internal_logger.debug(
421 "Ignored %d invalid Set-Cookie field(s) and %d over the %d-cookie limit",
422 invalid,
423 over_limit,
424 _MAX_COOKIES_PER_RESPONSE,
425 )
426 return parsed_cookies