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

163 statements  

1""" 

2Internal cookie handling helpers. 

3 

4This module contains internal utilities for cookie parsing and manipulation. 

5These are not part of the public API and may change without notice. 

6""" 

7 

8import re 

9from collections.abc import Sequence 

10from http.cookies import CookieError, Morsel 

11 

12from .log import internal_logger 

13 

14__all__ = ( 

15 "parse_set_cookie_headers", 

16 "parse_cookie_header", 

17 "preserve_morsel_with_coded_value", 

18) 

19 

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) 

45 

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 

52 

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]") 

57 

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) 

94 

95 

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. 

99 

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. 

103 

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. 

107 

108 Args: 

109 cookie: A Morsel object from SimpleCookie 

110 

111 Returns: 

112 A Morsel object with preserved coded_value 

113 

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 

133 

134 

135_unquote_sub = re.compile(r"\\(?:([0-3][0-7][0-7])|(.))").sub 

136 

137 

138def _unquote_replace(m: re.Match[str]) -> str: 

139 """ 

140 Replace function for _unquote_sub regex substitution. 

141 

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] 

149 

150 

151def _unquote(value: str) -> str: 

152 """ 

153 Unquote a cookie value. 

154 

155 Vendored from http.cookies._unquote to ensure compatibility. 

156 

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 

166 

167 # We have to assume that we must decode this string. 

168 # Down to work. 

169 

170 # Remove the "s 

171 value = value[1:-1] 

172 

173 # Check for special sequences. Examples: 

174 # \012 --> \n 

175 # \" --> " 

176 # 

177 return _unquote_sub(_unquote_replace, value) 

178 

179 

180def parse_cookie_header(header: str) -> list[tuple[str, Morsel[str]]]: 

181 """ 

182 Parse a Cookie header according to RFC 6265 Section 5.4. 

183 

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. 

187 

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 

192 

193 Args: 

194 header: The Cookie header value to parse 

195 

196 Returns: 

197 List of (name, Morsel) tuples for compatibility with SimpleCookie.update() 

198 """ 

199 if not header: 

200 return [] 

201 

202 morsel: Morsel[str] 

203 cookies: list[tuple[str, Morsel[str]]] = [] 

204 i = 0 

205 n = len(header) 

206 

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) 

216 

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() 

222 

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)) 

240 

241 # Move to next cookie or end 

242 i = next_semi + 1 if next_semi != -1 else n 

243 continue 

244 

245 key = match.group("key") 

246 value = match.group("val") or "" 

247 i = match.end(0) 

248 

249 # Validate the name 

250 if not key or not _COOKIE_NAME_RE.match(key): 

251 invalid_names.append(key) 

252 continue 

253 

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 

267 

268 cookies.append((key, morsel)) 

269 

270 if invalid_names: 

271 internal_logger.debug( 

272 "Cannot load cookie. Illegal cookie names: %r", invalid_names 

273 ) 

274 

275 return cookies 

276 

277 

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 

286 

287 

288def _apply_cookie_attributes(morsel: Morsel[str], attributes: str) -> None: 

289 """Set the recognized attributes from the text after the cookie-pair. 

290 

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 

318 

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) 

344 

345 

346def _parse_quoted_pair(header: str) -> tuple[str, str, int] | None: 

347 """Parse a first pair whose quoted value contains ";". 

348 

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) 

361 

362 

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. 

365 

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 

371 

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 

378 

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 

392 

393 key = key.strip(" \t") 

394 coded_value = coded_value.strip(" \t") 

395 

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 

404 

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 

415 

416 _apply_cookie_attributes(morsel, header[attributes_start:]) 

417 parsed_cookies.append((key, morsel)) 

418 

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