Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/tornado/httputil.py: 31%
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#
2# Copyright 2009 Facebook
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
16"""HTTP utility code shared by clients and servers.
18This module also defines the `HTTPServerRequest` class which is exposed
19via `tornado.web.RequestHandler.request`.
20"""
22from __future__ import annotations
24import calendar
25import collections.abc
26import copy
27import dataclasses
28import datetime
29import email.utils
30import http.cookies
31import re
32import time
33import unicodedata
34from functools import lru_cache
35from http.client import responses
36from ssl import SSLError
37from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
38import warnings
40from tornado.escape import native_str, parse_qs_bytes, to_unicode, utf8
41from tornado.util import ObjectDict, unicode_type
43# responses is unused in this file, but we re-export it to other files.
44# Reference it so pyflakes doesn't complain.
45responses
47import typing
48from collections.abc import Awaitable, Generator, Iterable, Iterator, Mapping
49from typing import (
50 AnyStr,
51)
53if typing.TYPE_CHECKING:
54 # These are relatively heavy imports and aren't needed in this file
55 # unless we're type-checking.
56 import unittest
57 from asyncio import Future
59# To be used with str.strip() and related methods.
60HTTP_WHITESPACE = " \t"
62# Roughly the inverse of RequestHandler._VALID_HEADER_CHARS, but permits
63# chars greater than \xFF (which may appear after decoding utf8).
64_FORBIDDEN_HEADER_CHARS_RE = re.compile(r"[\x00-\x08\x0A-\x1F\x7F]")
67class _ABNF:
68 """Class that holds a subset of ABNF rules from RFC 9110 and friends.
70 Class attributes are re.Pattern objects, with the same name as in the RFC
71 (with hyphens changed to underscores). Currently contains only the subset
72 we use (which is why this class is not public). Unfortunately the fields
73 cannot be alphabetized as they are in the RFCs because of dependencies.
74 """
76 # RFC 3986 (URI)
77 # The URI hostname ABNF is both complex (including detailed validation of IPv4 and IPv6
78 # literals) and not strict enough (a lot of punctuation is allowed by the ABNF even though
79 # it is not allowed by DNS). We simplify it by allowing square brackets and colons in any
80 # position, not only for their use in IPv6 literals.
81 uri_unreserved = re.compile(r"[A-Za-z0-9\-._~]")
82 uri_sub_delims = re.compile(r"[!$&'()*+,;=]")
83 uri_pct_encoded = re.compile(r"%[0-9A-Fa-f]{2}")
84 uri_host = re.compile(
85 rf"(?:[\[\]:]|{uri_unreserved.pattern}|{uri_sub_delims.pattern}|{uri_pct_encoded.pattern})*"
86 )
87 uri_port = re.compile(r"[0-9]*")
89 # RFC 5234 (ABNF)
90 VCHAR = re.compile(r"[\x21-\x7E]")
92 # RFC 9110 (HTTP Semantics)
93 obs_text = re.compile(r"[\x80-\xFF]")
94 field_vchar = re.compile(rf"(?:{VCHAR.pattern}|{obs_text.pattern})")
95 # Not exactly from the RFC to simplify and combine field-content and field-value.
96 field_value = re.compile(
97 rf"|"
98 rf"{field_vchar.pattern}|"
99 rf"{field_vchar.pattern}(?:{field_vchar.pattern}| |\t)*{field_vchar.pattern}"
100 )
101 tchar = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]")
102 token = re.compile(rf"{tchar.pattern}+")
103 field_name = token
104 method = token
105 host = re.compile(rf"(?:{uri_host.pattern})(?::{uri_port.pattern})?")
107 # RFC 9112 (HTTP/1.1)
108 HTTP_version = re.compile(r"HTTP/[0-9]\.[0-9]")
109 reason_phrase = re.compile(rf"(?:[\t ]|{VCHAR.pattern}|{obs_text.pattern})+")
110 # request_target delegates to the URI RFC 3986, which is complex and may be
111 # too restrictive (for example, the WHATWG version of the URL spec allows non-ASCII
112 # characters). Instead, we allow everything but control chars and whitespace.
113 request_target = re.compile(rf"{field_vchar.pattern}+")
114 request_line = re.compile(
115 rf"({method.pattern}) ({request_target.pattern}) ({HTTP_version.pattern})"
116 )
117 status_code = re.compile(r"[0-9]{3}")
118 status_line = re.compile(
119 rf"({HTTP_version.pattern}) ({status_code.pattern}) ({reason_phrase.pattern})?"
120 )
123@lru_cache(1000)
124def _normalize_header(name: str) -> str:
125 """Map a header name to Http-Header-Case.
127 >>> _normalize_header("coNtent-TYPE")
128 'Content-Type'
129 """
130 return "-".join([w.capitalize() for w in name.split("-")])
133class HTTPHeaders(collections.abc.MutableMapping[str, str]):
134 """A dictionary that maintains ``Http-Header-Case`` for all keys.
136 Supports multiple values per key via a pair of new methods,
137 `add()` and `get_list()`. The regular dictionary interface
138 returns a single value per key, with multiple values joined by a
139 comma.
141 >>> h = HTTPHeaders({"content-type": "text/html"})
142 >>> list(h.keys())
143 ['Content-Type']
144 >>> h["Content-Type"]
145 'text/html'
147 >>> h.add("Set-Cookie", "A=B")
148 >>> h.add("Set-Cookie", "C=D")
149 >>> h["set-cookie"]
150 'A=B,C=D'
151 >>> h.get_list("set-cookie")
152 ['A=B', 'C=D']
154 >>> for (k,v) in sorted(h.get_all()):
155 ... print('%s: %s' % (k,v))
156 ...
157 Content-Type: text/html
158 Set-Cookie: A=B
159 Set-Cookie: C=D
160 """
162 @typing.overload
163 def __init__(self, __arg: Mapping[str, list[str]]) -> None:
164 pass
166 @typing.overload
167 def __init__(self, __arg: Mapping[str, str]) -> None:
168 pass
170 @typing.overload
171 def __init__(self, *args: tuple[str, str]) -> None:
172 pass
174 @typing.overload
175 def __init__(self, **kwargs: str) -> None:
176 pass
178 def __init__(self, *args: typing.Any, **kwargs: str) -> None:
179 # Formally, HTTP headers are a mapping from a field name to a "combined field value",
180 # which may be constructed from multiple field lines by joining them with commas.
181 # In practice, however, some headers (notably Set-Cookie) do not follow this convention,
182 # so we maintain a mapping from field name to a list of field lines in self._as_list.
183 # self._combined_cache is a cache of the combined field values derived from self._as_list
184 # on demand (and cleared whenever the list is modified).
185 self._as_list: dict[str, list[str]] = {}
186 self._combined_cache: dict[str, str] = {}
187 if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], HTTPHeaders):
188 # Copy constructor
189 for k, v in args[0].get_all():
190 self.add(k, v)
191 else:
192 # Dict-style initialization
193 self.update(*args, **kwargs)
195 # new public methods
197 def add(self, name: str, value: str, *, _chars_are_bytes: bool = True) -> None:
198 """Adds a new value for the given key."""
199 if not _ABNF.field_name.fullmatch(name):
200 raise HTTPInputError("Invalid header name %r" % name)
201 if _chars_are_bytes:
202 if not _ABNF.field_value.fullmatch(to_unicode(value)):
203 # TODO: the fact we still support bytes here (contrary to type annotations)
204 # and still test for it should probably be changed.
205 raise HTTPInputError("Invalid header value %r" % value)
206 else:
207 if _FORBIDDEN_HEADER_CHARS_RE.search(value):
208 raise HTTPInputError("Invalid header value %r" % value)
209 norm_name = _normalize_header(name)
210 if norm_name in self:
211 self._combined_cache.pop(norm_name, None)
212 self._as_list[norm_name].append(value)
213 else:
214 self[norm_name] = value
216 def get_list(self, name: str) -> list[str]:
217 """Returns all values for the given header as a list."""
218 norm_name = _normalize_header(name)
219 return self._as_list.get(norm_name, [])
221 def get_all(self) -> Iterable[tuple[str, str]]:
222 """Returns an iterable of all (name, value) pairs.
224 If a header has multiple values, multiple pairs will be
225 returned with the same name.
226 """
227 for name, values in self._as_list.items():
228 for value in values:
229 yield (name, value)
231 def parse_line(self, line: str, *, _chars_are_bytes: bool = True) -> None:
232 r"""Updates the dictionary with a single header line.
234 >>> h = HTTPHeaders()
235 >>> h.parse_line("Content-Type: text/html")
236 >>> h.get('content-type')
237 'text/html'
238 >>> h.parse_line("Content-Length: 42\r\n")
239 >>> h.get('content-type')
240 'text/html'
242 .. versionchanged:: 6.5
243 Now supports lines with or without the trailing CRLF, making it possible
244 to pass lines from AsyncHTTPClient's header_callback directly to this method.
246 .. deprecated:: 6.5
247 In Tornado 7.0, certain deprecated features of HTTP will become errors.
248 Specifically, line folding and the use of LF (with CR) as a line separator
249 will be removed.
251 .. versionchanged:: 6.6
252 Support for line folding was removed due to security/DoS concerns. This removal
253 was ahead of the original removal schedule announced for Tornado 7.0.
254 Support for LF without CR remains until Tornado 7.0.
256 .. versionchanged:: 6.6.1
257 Parsing of header lines terminated by LF without CR will be removed in Tornado 6.7
258 instead of 7.0 as previously documented.
259 """
260 if m := re.search(r"\r?\n$", line):
261 # RFC 9112 section 2.2: a recipient MAY recognize a single LF as a line
262 # terminator and ignore any preceding CR.
263 # TODO(7.0): Remove this support for LF-only line endings.
264 line = line[: m.start()]
265 if not line:
266 # Empty line, or the final CRLF of a header block.
267 return
268 if line[0] in HTTP_WHITESPACE:
269 raise HTTPInputError(
270 "header line cannot start with whitespace, folding not supported"
271 )
272 try:
273 name, value = line.split(":", 1)
274 except ValueError:
275 raise HTTPInputError("no colon in header line")
276 self.add(name, value.strip(HTTP_WHITESPACE), _chars_are_bytes=_chars_are_bytes)
278 @classmethod
279 def parse(cls, headers: str, *, _chars_are_bytes: bool = True) -> HTTPHeaders:
280 """Returns a dictionary from HTTP header text.
282 >>> h = HTTPHeaders.parse("Content-Type: text/html\\r\\nContent-Length: 42\\r\\n")
283 >>> sorted(h.items())
284 [('Content-Length', '42'), ('Content-Type', 'text/html')]
286 .. versionchanged:: 5.1
288 Raises `HTTPInputError` on malformed headers instead of a
289 mix of `KeyError`, and `ValueError`.
291 """
292 # _chars_are_bytes is a hack. This method is used in two places, HTTP headers (in which
293 # non-ascii characters are to be interpreted as latin-1) and multipart/form-data (in which
294 # they are to be interpreted as utf-8). For historical reasons, this method handled this by
295 # expecting both callers to decode the headers to strings before parsing them. This wasn't a
296 # problem until we started doing stricter validation of the characters allowed in HTTP
297 # headers (using ABNF rules defined in terms of byte values), which inadvertently started
298 # disallowing non-latin1 characters in multipart/form-data filenames.
299 #
300 # This method should have accepted bytes and a desired encoding, but this change is being
301 # introduced in a patch release that shouldn't change the API. Instead, the _chars_are_bytes
302 # flag decides whether to use HTTP-style ABNF validation (treating the string as bytes
303 # smuggled through the latin1 encoding) or to accept any non-control unicode characters
304 # as required by multipart/form-data. This method will change to accept bytes in a future
305 # release.
306 h = cls()
308 start = 0
309 while True:
310 lf = headers.find("\n", start)
311 if lf == -1:
312 h.parse_line(headers[start:], _chars_are_bytes=_chars_are_bytes)
313 break
314 line = headers[start : lf + 1]
315 start = lf + 1
316 h.parse_line(line, _chars_are_bytes=_chars_are_bytes)
317 return h
319 # MutableMapping abstract method implementations.
321 def __setitem__(self, name: str, value: str) -> None:
322 norm_name = _normalize_header(name)
323 self._combined_cache[norm_name] = value
324 self._as_list[norm_name] = [value]
326 def __contains__(self, name: object) -> bool:
327 # This is an important optimization to avoid the expensive concatenation
328 # in __getitem__ when it's not needed.
329 if not isinstance(name, str):
330 return False
331 norm_name = _normalize_header(name)
332 return norm_name in self._as_list
334 def __getitem__(self, name: str) -> str:
335 header = _normalize_header(name)
336 if header not in self._combined_cache:
337 self._combined_cache[header] = ",".join(self._as_list[header])
338 return self._combined_cache[header]
340 def __delitem__(self, name: str) -> None:
341 norm_name = _normalize_header(name)
342 # _combined_cache is populated lazily by __getitem__ and invalidated
343 # by add(), so the name may be absent from it even when the header is
344 # present. Only _as_list determines whether the key exists.
345 self._combined_cache.pop(norm_name, None)
346 del self._as_list[norm_name]
348 def __len__(self) -> int:
349 return len(self._as_list)
351 def __iter__(self) -> Iterator[typing.Any]:
352 return iter(self._as_list)
354 def copy(self) -> HTTPHeaders:
355 # defined in dict but not in MutableMapping.
356 return HTTPHeaders(self)
358 # Use our overridden copy method for the copy.copy module.
359 # This makes shallow copies one level deeper, but preserves
360 # the appearance that HTTPHeaders is a single container.
361 __copy__ = copy
363 def __str__(self) -> str:
364 lines = []
365 for name, value in self.get_all():
366 lines.append(f"{name}: {value}\n")
367 return "".join(lines)
369 __unicode__ = __str__
372class HTTPServerRequest:
373 """A single HTTP request.
375 All attributes are type `str` unless otherwise noted.
377 .. attribute:: method
379 HTTP request method, e.g. "GET" or "POST"
381 .. attribute:: uri
383 The requested uri.
385 .. attribute:: path
387 The path portion of `uri`
389 .. attribute:: query
391 The query portion of `uri`
393 .. attribute:: version
395 HTTP version specified in request, e.g. "HTTP/1.1"
397 .. attribute:: headers
399 `.HTTPHeaders` dictionary-like object for request headers. Acts like
400 a case-insensitive dictionary with additional methods for repeated
401 headers.
403 .. attribute:: body
405 Request body, if present, as a byte string.
407 .. attribute:: remote_ip
409 Client's IP address as a string. If ``HTTPServer.xheaders`` is set,
410 will pass along the real IP address provided by a load balancer
411 in the ``X-Real-Ip`` or ``X-Forwarded-For`` header.
413 .. versionchanged:: 3.1
414 The list format of ``X-Forwarded-For`` is now supported.
416 .. attribute:: protocol
418 The protocol used, either "http" or "https". If ``HTTPServer.xheaders``
419 is set, will pass along the protocol used by a load balancer if
420 reported via an ``X-Scheme`` header.
422 .. attribute:: host
424 The requested hostname, usually taken from the ``Host`` header.
426 .. attribute:: arguments
428 GET/POST arguments are available in the arguments property, which
429 maps arguments names to lists of values (to support multiple values
430 for individual names). Names are of type `str`, while arguments
431 are byte strings. Note that this is different from
432 `.RequestHandler.get_argument`, which returns argument values as
433 unicode strings.
435 .. attribute:: query_arguments
437 Same format as ``arguments``, but contains only arguments extracted
438 from the query string.
440 .. versionadded:: 3.2
442 .. attribute:: body_arguments
444 Same format as ``arguments``, but contains only arguments extracted
445 from the request body.
447 .. versionadded:: 3.2
449 .. attribute:: files
451 File uploads are available in the files property, which maps file
452 names to lists of `.HTTPFile`.
454 .. attribute:: connection
456 An HTTP request is attached to a single HTTP connection, which can
457 be accessed through the "connection" attribute. Since connections
458 are typically kept open in HTTP/1.1, multiple requests can be handled
459 sequentially on a single connection.
461 .. versionchanged:: 4.0
462 Moved from ``tornado.httpserver.HTTPRequest``.
464 .. deprecated:: 6.5.2
465 The ``host`` argument to the ``HTTPServerRequest`` constructor is deprecated. Use
466 ``headers["Host"]`` instead. This argument was mistakenly removed in Tornado 6.5.0 and
467 temporarily restored in 6.5.2. It will be removed in Tornado 6.7.
469 .. deprecated:: 6.6
470 Creating an ``HTTPServerRequest`` without a ``start_line`` argument is deprecated.
471 This argument will require a non-None value in Tornado 6.7. The ``method``, ``uri``,
472 and ``version`` arguments are deprecated and will be removed in Tornado 6.7, along
473 with the previously-deprecated ``host`` argument. At this time all remaining arguments
474 will become keyword-only.
475 """
477 path: str
478 query: str
480 # HACK: Used for stream_request_body
481 _body_future: Future[None]
483 def __init__(
484 self,
485 method: str | None = None,
486 uri: str | None = None,
487 version: str = "HTTP/1.0",
488 headers: HTTPHeaders | None = None,
489 body: bytes | None = None,
490 host: str | None = None,
491 files: dict[str, list[HTTPFile]] | None = None,
492 connection: HTTPConnection | None = None,
493 start_line: RequestStartLine | None = None,
494 server_connection: object | None = None,
495 ) -> None:
496 if method is not None or uri is not None:
497 warnings.warn(
498 "The method, uri, and version arguments to HTTPServerRequest are deprecated and "
499 "will be removed in Tornado 6.7. Use the start_line argument instead.",
500 DeprecationWarning,
501 stacklevel=2,
502 )
503 if start_line is None:
504 warnings.warn(
505 "The start_line argument to HTTPServerRequest will be required in Tornado 6.7.",
506 DeprecationWarning,
507 stacklevel=2,
508 )
509 start_line = RequestStartLine(method or "GET", uri or "/", version)
510 del method, uri, version
511 self.method, self.uri, self.version = start_line
513 self.headers = headers or HTTPHeaders()
514 if host is not None:
515 warnings.warn(
516 "The host argument to HTTPServerRequest is deprecated and will be removed "
517 "in Tornado 6.7. Use headers['Host'] instead.",
518 DeprecationWarning,
519 stacklevel=2,
520 )
521 self.headers["Host"] = host
522 del host
524 self.body = body or b""
526 # set remote IP and protocol
527 context = getattr(connection, "context", None)
528 self.remote_ip = getattr(context, "remote_ip", None)
529 self.protocol = getattr(context, "protocol", "http")
531 try:
532 self.host = self.headers["Host"]
533 except KeyError:
534 if self.version == "HTTP/1.0":
535 # HTTP/1.0 does not require the Host header.
536 self.host = "127.0.0.1"
537 else:
538 raise HTTPInputError("Missing Host header")
539 if not _ABNF.host.fullmatch(self.host):
540 raise HTTPInputError("Invalid Host header: %r" % self.host)
541 if "," in self.host:
542 # https://www.rfc-editor.org/rfc/rfc9112.html#name-request-target
543 # Server MUST respond with 400 Bad Request if multiple
544 # Host headers are present.
545 #
546 # We test for the presence of a comma instead of the number of
547 # headers received because a proxy may have converted
548 # multiple headers into a single comma-separated value
549 # (per RFC 9110 section 5.3).
550 #
551 # This is technically a departure from the RFC since the ABNF
552 # does not forbid commas in the host header. However, since
553 # commas are not allowed in DNS names, it is appropriate to
554 # disallow them. (The same argument could be made for other special
555 # characters, but commas are the most problematic since they could
556 # be used to exploit differences between proxies when multiple headers
557 # are supplied).
558 raise HTTPInputError("Multiple host headers not allowed: %r" % self.host)
559 self.host_name = split_host_and_port(self.host.lower())[0]
560 self.files = files or {}
561 self.connection = connection
562 self.server_connection = server_connection
563 self._start_time = time.time()
564 self._finish_time = None
566 if self.uri is not None:
567 self.path, sep, self.query = self.uri.partition("?")
568 try:
569 self.arguments = parse_qs_bytes(
570 self.query,
571 keep_blank_values=True,
572 # The query string is bounded by max_header_size, but parsing
573 # is expensive enough per field to be worth limiting. Use the
574 # same limit as a urlencoded body. This reads the global
575 # config because HTTPServerRequest has no access to the
576 # per-connection configuration; see set_parse_body_config.
577 max_num_fields=_DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments,
578 )
579 except Exception as e:
580 raise HTTPInputError("Invalid query string: %s" % e) from e
581 self.query_arguments = copy.deepcopy(self.arguments)
582 self.body_arguments: dict[str, list[bytes]] = {}
584 @property
585 def cookies(self) -> dict[str, http.cookies.Morsel]:
586 """A dictionary of ``http.cookies.Morsel`` objects."""
587 if not hasattr(self, "_cookies"):
588 self._cookies: http.cookies.SimpleCookie = http.cookies.SimpleCookie()
589 if "Cookie" in self.headers:
590 try:
591 parsed = parse_cookie(self.headers["Cookie"])
592 except Exception:
593 pass
594 else:
595 for k, v in parsed.items():
596 try:
597 self._cookies[k] = v
598 except Exception:
599 # SimpleCookie imposes some restrictions on keys;
600 # parse_cookie does not. Discard any cookies
601 # with disallowed keys.
602 pass
603 return self._cookies
605 def full_url(self) -> str:
606 """Reconstructs the full URL for this request."""
607 return self.protocol + "://" + self.host + self.uri # type: ignore[operator]
609 def request_time(self) -> float:
610 """Returns the amount of time it took for this request to execute."""
611 if self._finish_time is None:
612 return time.time() - self._start_time
613 else:
614 return self._finish_time - self._start_time
616 def get_ssl_certificate(self, binary_form: bool = False) -> None | dict | bytes:
617 """Returns the client's SSL certificate, if any.
619 To use client certificates, the HTTPServer's
620 `ssl.SSLContext.verify_mode` field must be set, e.g.::
622 ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
623 ssl_ctx.load_cert_chain("foo.crt", "foo.key")
624 ssl_ctx.load_verify_locations("cacerts.pem")
625 ssl_ctx.verify_mode = ssl.CERT_REQUIRED
626 server = HTTPServer(app, ssl_options=ssl_ctx)
628 By default, the return value is a dictionary (or None, if no
629 client certificate is present). If ``binary_form`` is true, a
630 DER-encoded form of the certificate is returned instead. See
631 SSLSocket.getpeercert() in the standard library for more
632 details.
633 http://docs.python.org/library/ssl.html#sslsocket-objects
634 """
635 try:
636 if self.connection is None:
637 return None
638 # TODO: add a method to HTTPConnection for this so it can work with HTTP/2
639 return self.connection.stream.socket.getpeercert( # type: ignore
640 binary_form=binary_form
641 )
642 except SSLError:
643 return None
645 def _parse_body(self) -> None:
646 parse_body_arguments(
647 self.headers.get("Content-Type", ""),
648 self.body,
649 self.body_arguments,
650 self.files,
651 self.headers,
652 )
654 for k, v in self.body_arguments.items():
655 self.arguments.setdefault(k, []).extend(v)
657 def __repr__(self) -> str:
658 attrs = ("protocol", "host", "method", "uri", "version", "remote_ip")
659 args = ", ".join([f"{n}={getattr(self, n)!r}" for n in attrs])
660 return f"{self.__class__.__name__}({args})"
663class HTTPInputError(Exception):
664 """Exception class for malformed HTTP requests or responses
665 from remote sources.
667 .. versionadded:: 4.0
668 """
670 pass
673class HTTPOutputError(Exception):
674 """Exception class for errors in HTTP output.
676 .. versionadded:: 4.0
677 """
679 pass
682class HTTPServerConnectionDelegate:
683 """Implement this interface to handle requests from `.HTTPServer`.
685 .. versionadded:: 4.0
686 """
688 def start_request(
689 self, server_conn: object, request_conn: HTTPConnection
690 ) -> HTTPMessageDelegate:
691 """This method is called by the server when a new request has started.
693 :arg server_conn: is an opaque object representing the long-lived
694 (e.g. tcp-level) connection.
695 :arg request_conn: is a `.HTTPConnection` object for a single
696 request/response exchange.
698 This method should return a `.HTTPMessageDelegate`.
699 """
700 raise NotImplementedError()
702 def on_close(self, server_conn: object) -> None:
703 """This method is called when a connection has been closed.
705 :arg server_conn: is a server connection that has previously been
706 passed to ``start_request``.
707 """
708 pass
711class HTTPMessageDelegate:
712 """Implement this interface to handle an HTTP request or response.
714 .. versionadded:: 4.0
715 """
717 # TODO: genericize this class to avoid exposing the Union.
718 def headers_received(
719 self,
720 start_line: RequestStartLine | ResponseStartLine,
721 headers: HTTPHeaders,
722 ) -> Awaitable[None] | None:
723 """Called when the HTTP headers have been received and parsed.
725 :arg start_line: a `.RequestStartLine` or `.ResponseStartLine`
726 depending on whether this is a client or server message.
727 :arg headers: a `.HTTPHeaders` instance.
729 Some `.HTTPConnection` methods can only be called during
730 ``headers_received``.
732 May return a `.Future`; if it does the body will not be read
733 until it is done.
734 """
735 pass
737 def data_received(self, chunk: bytes) -> Awaitable[None] | None:
738 """Called when a chunk of data has been received.
740 May return a `.Future` for flow control.
741 """
742 pass
744 def finish(self) -> None:
745 """Called after the last chunk of data has been received."""
746 pass
748 def on_connection_close(self) -> None:
749 """Called if the connection is closed without finishing the request.
751 If ``headers_received`` is called, either ``finish`` or
752 ``on_connection_close`` will be called, but not both.
753 """
754 pass
757class HTTPConnection:
758 """Applications use this interface to write their responses.
760 .. versionadded:: 4.0
761 """
763 def write_headers(
764 self,
765 start_line: RequestStartLine | ResponseStartLine,
766 headers: HTTPHeaders,
767 chunk: bytes | None = None,
768 ) -> Future[None]:
769 """Write an HTTP header block.
771 :arg start_line: a `.RequestStartLine` or `.ResponseStartLine`.
772 :arg headers: a `.HTTPHeaders` instance.
773 :arg chunk: the first (optional) chunk of data. This is an optimization
774 so that small responses can be written in the same call as their
775 headers.
777 The ``version`` field of ``start_line`` is ignored.
779 Returns a future for flow control.
781 .. versionchanged:: 6.0
783 The ``callback`` argument was removed.
784 """
785 raise NotImplementedError()
787 def write(self, chunk: bytes) -> Future[None]:
788 """Writes a chunk of body data.
790 Returns a future for flow control.
792 .. versionchanged:: 6.0
794 The ``callback`` argument was removed.
795 """
796 raise NotImplementedError()
798 def finish(self) -> None:
799 """Indicates that the last body data has been written."""
800 raise NotImplementedError()
803def url_concat(
804 url: str,
805 args: None | dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...],
806) -> str:
807 """Concatenate url and arguments regardless of whether
808 url has existing query parameters.
810 ``args`` may be either a dictionary or a list of key-value pairs
811 (the latter allows for multiple values with the same key.
813 >>> url_concat("http://example.com/foo", dict(c="d"))
814 'http://example.com/foo?c=d'
815 >>> url_concat("http://example.com/foo?a=b", dict(c="d"))
816 'http://example.com/foo?a=b&c=d'
817 >>> url_concat("http://example.com/foo?a=b", [("c", "d"), ("c", "d2")])
818 'http://example.com/foo?a=b&c=d&c=d2'
819 """
820 if args is None:
821 return url
822 parsed_url = urlparse(url)
823 if isinstance(args, dict):
824 parsed_query = parse_qsl(parsed_url.query, keep_blank_values=True)
825 parsed_query.extend(args.items())
826 elif isinstance(args, list) or isinstance(args, tuple):
827 parsed_query = parse_qsl(parsed_url.query, keep_blank_values=True)
828 parsed_query.extend(args)
829 else:
830 err = "'args' parameter should be dict, list or tuple. Not {0}".format(
831 type(args)
832 )
833 raise TypeError(err)
834 final_query = urlencode(parsed_query)
835 url = urlunparse(
836 (
837 parsed_url[0],
838 parsed_url[1],
839 parsed_url[2],
840 parsed_url[3],
841 final_query,
842 parsed_url[5],
843 )
844 )
845 return url
848class HTTPFile(ObjectDict):
849 """Represents a file uploaded via a form.
851 For backwards compatibility, its instance attributes are also
852 accessible as dictionary keys.
854 * ``filename``
855 * ``body``
856 * ``content_type``
857 """
859 filename: str
860 body: bytes
861 content_type: str
864def _parse_request_range(
865 range_header: str,
866) -> tuple[int | None, int | None] | None:
867 """Parses a Range header.
869 Returns either ``None`` or tuple ``(start, end)``.
870 Note that while the HTTP headers use inclusive byte positions,
871 this method returns indexes suitable for use in slices.
873 >>> start, end = _parse_request_range("bytes=1-2")
874 >>> start, end
875 (1, 3)
876 >>> [0, 1, 2, 3, 4][start:end]
877 [1, 2]
878 >>> _parse_request_range("bytes=6-")
879 (6, None)
880 >>> _parse_request_range("bytes=-6")
881 (-6, None)
882 >>> _parse_request_range("bytes=-0")
883 (None, 0)
884 >>> _parse_request_range("bytes=")
885 (None, None)
886 >>> _parse_request_range("foo=42")
887 >>> _parse_request_range("bytes=1-2,6-10")
889 Note: only supports one range (ex, ``bytes=1-2,6-10`` is not allowed).
891 See [0] for the details of the range header.
893 [0]: http://greenbytes.de/tech/webdav/draft-ietf-httpbis-p5-range-latest.html#byte.ranges
894 """
895 unit, _, value = range_header.partition("=")
896 unit, value = unit.strip(), value.strip()
897 if unit != "bytes":
898 return None
899 start_b, _, end_b = value.partition("-")
900 try:
901 start = _int_or_none(start_b)
902 end = _int_or_none(end_b)
903 except ValueError:
904 return None
905 if end is not None:
906 if start is None:
907 if end != 0:
908 start = -end
909 end = None
910 else:
911 end += 1
912 return (start, end)
915def _get_content_range(start: int | None, end: int | None, total: int) -> str:
916 """Returns a suitable Content-Range header:
918 >>> print(_get_content_range(None, 1, 4))
919 bytes 0-0/4
920 >>> print(_get_content_range(1, 3, 4))
921 bytes 1-2/4
922 >>> print(_get_content_range(None, None, 4))
923 bytes 0-3/4
924 """
925 start = start or 0
926 end = (end or total) - 1
927 return f"bytes {start}-{end}/{total}"
930def _int_or_none(val: str) -> int | None:
931 val = val.strip()
932 if val == "":
933 return None
934 return int(val)
937@dataclasses.dataclass
938class ParseMultipartConfig:
939 """This class configures the parsing of ``multipart/form-data`` request bodies.
941 Its primary purpose is to place limits on the size and complexity of request messages
942 to avoid potential denial-of-service attacks.
944 .. versionadded:: 6.5.5
945 """
947 enabled: bool = True
948 """Set this to false to disable the parsing of ``multipart/form-data`` requests entirely.
950 This may be desirable for applications that do not need to handle this format, since
951 multipart request have a history of DoS vulnerabilities in Tornado. Multipart requests
952 are used primarily for ``<input type="file">`` in HTML forms, or in APIs that mimic this
953 format. File uploads that use the HTTP ``PUT`` method generally do not use the multipart
954 format.
955 """
957 max_parts: int = 100
958 """The maximum number of parts accepted in a multipart request.
960 Each ``<input>`` element in an HTML form corresponds to at least one "part".
961 """
963 max_part_header_size: int = 10 * 1024
964 """The maximum size of the headers for each part of a multipart request.
966 The header for a part contains the name of the form field and optionally the filename
967 and content type of the uploaded file.
968 """
971@dataclasses.dataclass
972class ParseUrlEncodedConfig:
973 """This class configures the parsing of ``application/x-www-form-urlencoded`` request bodies.
975 Its primary purpose is to place limits on the size and complexity of request messages
976 to avoid potential denial-of-service attacks.
978 .. versionadded:: 6.5.8
979 """
981 max_arguments: int = 1000
982 """The maximum number of arguments accepted in a urlencoded request.
984 Each ``<input>`` element in an HTML form corresponds to at least one argument.
985 """
988@dataclasses.dataclass
989class ParseBodyConfig:
990 """This class configures the parsing of request bodies.
992 .. versionadded:: 6.5.5
993 """
995 multipart: ParseMultipartConfig = dataclasses.field(
996 default_factory=ParseMultipartConfig
997 )
998 urlencoded: ParseUrlEncodedConfig = dataclasses.field(
999 default_factory=ParseUrlEncodedConfig
1000 )
1001 """Configuration for ``multipart/form-data`` request bodies."""
1004_DEFAULT_PARSE_BODY_CONFIG = ParseBodyConfig()
1007def set_parse_body_config(config: ParseBodyConfig) -> None:
1008 r"""Sets the **global** default configuration for parsing request bodies.
1010 This global setting is provided as a stopgap for applications that need to raise the limits
1011 introduced in Tornado 6.5.5, or who wish to disable the parsing of multipart/form-data bodies
1012 entirely. Non-global configuration for this functionality will be introduced in a future
1013 release.
1015 >>> content_type = "multipart/form-data; boundary=foo"
1016 >>> multipart_body = b"--foo--\r\n"
1017 >>> parse_body_arguments(content_type, multipart_body, {}, {})
1018 >>> multipart_config = ParseMultipartConfig(enabled=False)
1019 >>> config = ParseBodyConfig(multipart=multipart_config)
1020 >>> set_parse_body_config(config)
1021 >>> parse_body_arguments(content_type, multipart_body, {}, {})
1022 Traceback (most recent call last):
1023 ...
1024 tornado.httputil.HTTPInputError: ...: multipart/form-data parsing is disabled
1025 >>> set_parse_body_config(ParseBodyConfig()) # reset to defaults
1027 .. versionadded:: 6.5.5
1028 """
1029 global _DEFAULT_PARSE_BODY_CONFIG
1030 _DEFAULT_PARSE_BODY_CONFIG = config
1033def parse_body_arguments(
1034 content_type: str,
1035 body: bytes,
1036 arguments: dict[str, list[bytes]],
1037 files: dict[str, list[HTTPFile]],
1038 headers: HTTPHeaders | None = None,
1039 *,
1040 config: ParseBodyConfig | None = None,
1041) -> None:
1042 """Parses a form request body.
1044 Supports ``application/x-www-form-urlencoded`` and
1045 ``multipart/form-data``. The ``content_type`` parameter should be
1046 a string and ``body`` should be a byte string. The ``arguments``
1047 and ``files`` parameters are dictionaries that will be updated
1048 with the parsed contents.
1049 """
1050 if config is None:
1051 config = _DEFAULT_PARSE_BODY_CONFIG
1052 if content_type.startswith("application/x-www-form-urlencoded"):
1053 if headers and "Content-Encoding" in headers:
1054 raise HTTPInputError(
1055 "Unsupported Content-Encoding: %s" % headers["Content-Encoding"]
1056 )
1057 try:
1058 # real charset decoding will happen in RequestHandler.decode_argument()
1059 uri_arguments = parse_qs_bytes(
1060 body,
1061 keep_blank_values=True,
1062 max_num_fields=config.urlencoded.max_arguments,
1063 )
1064 except Exception as e:
1065 raise HTTPInputError("Invalid x-www-form-urlencoded body: %s" % e) from e
1066 for name, values in uri_arguments.items():
1067 if values:
1068 arguments.setdefault(name, []).extend(values)
1069 elif content_type.startswith("multipart/form-data"):
1070 if headers and "Content-Encoding" in headers:
1071 raise HTTPInputError(
1072 "Unsupported Content-Encoding: %s" % headers["Content-Encoding"]
1073 )
1074 try:
1075 fields = content_type.split(";")
1076 if fields[0].strip() != "multipart/form-data":
1077 # This catches "Content-Type: multipart/form-dataxyz"
1078 raise HTTPInputError("Invalid content type")
1079 for field in fields:
1080 k, sep, v = field.strip().partition("=")
1081 if k == "boundary" and v:
1082 parse_multipart_form_data(
1083 utf8(v), body, arguments, files, config=config.multipart
1084 )
1085 break
1086 else:
1087 raise HTTPInputError("multipart boundary not found")
1088 except Exception as e:
1089 raise HTTPInputError("Invalid multipart/form-data: %s" % e) from e
1092def parse_multipart_form_data(
1093 boundary: bytes,
1094 data: bytes,
1095 arguments: dict[str, list[bytes]],
1096 files: dict[str, list[HTTPFile]],
1097 *,
1098 config: ParseMultipartConfig | None = None,
1099) -> None:
1100 """Parses a ``multipart/form-data`` body.
1102 The ``boundary`` and ``data`` parameters are both byte strings.
1103 The dictionaries given in the arguments and files parameters
1104 will be updated with the contents of the body.
1106 .. versionchanged:: 5.1
1108 Now recognizes non-ASCII filenames in RFC 2231/5987
1109 (``filename*=``) format.
1110 """
1111 if config is None:
1112 config = _DEFAULT_PARSE_BODY_CONFIG.multipart
1113 if not config.enabled:
1114 raise HTTPInputError("multipart/form-data parsing is disabled")
1115 # The standard allows for the boundary to be quoted in the header,
1116 # although it's rare (it happens at least for google app engine
1117 # xmpp). I think we're also supposed to handle backslash-escapes
1118 # here but I'll save that until we see a client that uses them
1119 # in the wild.
1120 if boundary.startswith(b'"') and boundary.endswith(b'"'):
1121 boundary = boundary[1:-1]
1122 final_boundary_index = data.rfind(b"--" + boundary + b"--")
1123 if final_boundary_index == -1:
1124 raise HTTPInputError("Invalid multipart/form-data: no final boundary found")
1125 parts = data[:final_boundary_index].split(
1126 b"--" + boundary + b"\r\n", config.max_parts + 1
1127 )
1128 if len(parts) > config.max_parts:
1129 raise HTTPInputError("multipart/form-data has too many parts")
1130 for part in parts:
1131 if not part:
1132 continue
1133 eoh = part.find(b"\r\n\r\n")
1134 if eoh == -1:
1135 raise HTTPInputError("multipart/form-data missing headers")
1136 if eoh > config.max_part_header_size:
1137 raise HTTPInputError("multipart/form-data part header too large")
1138 headers = HTTPHeaders.parse(part[:eoh].decode("utf-8"), _chars_are_bytes=False)
1139 disp_header = headers.get("Content-Disposition", "")
1140 disposition, disp_params = _parse_header(disp_header)
1141 if disposition != "form-data" or not part.endswith(b"\r\n"):
1142 raise HTTPInputError("Invalid multipart/form-data")
1143 value = part[eoh + 4 : -2]
1144 if not disp_params.get("name"):
1145 raise HTTPInputError("multipart/form-data missing name")
1146 name = disp_params["name"]
1147 if disp_params.get("filename"):
1148 ctype = headers.get("Content-Type", "application/unknown")
1149 files.setdefault(name, []).append(
1150 HTTPFile(
1151 filename=disp_params["filename"], body=value, content_type=ctype
1152 )
1153 )
1154 else:
1155 arguments.setdefault(name, []).append(value)
1158def format_timestamp(
1159 ts: int | float | tuple | time.struct_time | datetime.datetime,
1160) -> str:
1161 """Formats a timestamp in the format used by HTTP.
1163 The argument may be a numeric timestamp as returned by `time.time`,
1164 a time tuple as returned by `time.gmtime`, or a `datetime.datetime`
1165 object. Naive `datetime.datetime` objects are assumed to represent
1166 UTC; aware objects are converted to UTC before formatting.
1168 >>> format_timestamp(1359312200)
1169 'Sun, 27 Jan 2013 18:43:20 GMT'
1170 """
1171 if isinstance(ts, (int, float)):
1172 time_num = ts
1173 elif isinstance(ts, (tuple, time.struct_time)):
1174 time_num = calendar.timegm(ts)
1175 elif isinstance(ts, datetime.datetime):
1176 time_num = calendar.timegm(ts.utctimetuple())
1177 else:
1178 raise TypeError("unknown timestamp type: %r" % ts)
1179 return email.utils.formatdate(time_num, usegmt=True)
1182class RequestStartLine(typing.NamedTuple):
1183 method: str
1184 path: str
1185 version: str
1188def parse_request_start_line(line: str) -> RequestStartLine:
1189 """Returns a (method, path, version) tuple for an HTTP 1.x request line.
1191 The response is a `typing.NamedTuple`.
1193 >>> parse_request_start_line("GET /foo HTTP/1.1")
1194 RequestStartLine(method='GET', path='/foo', version='HTTP/1.1')
1195 """
1196 match = _ABNF.request_line.fullmatch(line)
1197 if not match:
1198 # https://tools.ietf.org/html/rfc7230#section-3.1.1
1199 # invalid request-line SHOULD respond with a 400 (Bad Request)
1200 raise HTTPInputError("Malformed HTTP request line")
1201 r = RequestStartLine(match.group(1), match.group(2), match.group(3))
1202 if not r.version.startswith("HTTP/1"):
1203 # HTTP/2 and above doesn't use parse_request_start_line.
1204 # This could be folded into the regex but we don't want to deviate
1205 # from the ABNF in the RFCs.
1206 raise HTTPInputError("Unexpected HTTP version %r" % r.version)
1207 return r
1210class ResponseStartLine(typing.NamedTuple):
1211 version: str
1212 code: int
1213 reason: str
1216def parse_response_start_line(line: str) -> ResponseStartLine:
1217 """Returns a (version, code, reason) tuple for an HTTP 1.x response line.
1219 The response is a `typing.NamedTuple`.
1221 >>> parse_response_start_line("HTTP/1.1 200 OK")
1222 ResponseStartLine(version='HTTP/1.1', code=200, reason='OK')
1223 """
1224 match = _ABNF.status_line.fullmatch(line)
1225 if not match:
1226 raise HTTPInputError("Error parsing response start line")
1227 r = ResponseStartLine(match.group(1), int(match.group(2)), match.group(3))
1228 if not r.version.startswith("HTTP/1"):
1229 # HTTP/2 and above doesn't use parse_response_start_line.
1230 raise HTTPInputError("Unexpected HTTP version %r" % r.version)
1231 return r
1234# _parseparam and _parse_header are copied and modified from python2.7's cgi.py
1235# The original 2.7 version of this code did not correctly support some
1236# combinations of semicolons and double quotes.
1237# It has also been modified to support valueless parameters as seen in
1238# websocket extension negotiations, and to support non-ascii values in
1239# RFC 2231/5987 format.
1240#
1241# _parseparam has been further modified with the logic from
1242# https://github.com/python/cpython/pull/136072/files
1243# to avoid quadratic behavior when parsing semicolons in quoted strings.
1244#
1245# TODO: See if we can switch to email.message.Message for this functionality.
1246# This is the suggested replacement for the cgi.py module now that cgi has
1247# been removed from recent versions of Python. We need to verify that
1248# the email module is consistent with our existing behavior (and all relevant
1249# RFCs for multipart/form-data) before making this change.
1252def _parseparam(s: str) -> Generator[str]:
1253 start = 0
1254 while s.find(";", start) == start:
1255 start += 1
1256 end = s.find(";", start)
1257 ind, diff = start, 0
1258 while end > 0:
1259 diff += s.count('"', ind, end) - s.count('\\"', ind, end)
1260 if diff % 2 == 0:
1261 break
1262 end, ind = ind, s.find(";", end + 1)
1263 if end < 0:
1264 end = len(s)
1265 f = s[start:end]
1266 yield f.strip()
1267 start = end
1270def _parse_header(line: str) -> tuple[str, dict[str, str]]:
1271 r"""Parse a Content-type like header.
1273 Return the main content-type and a dictionary of options.
1275 >>> d = "form-data; foo=\"b\\\\a\\\"r\"; file*=utf-8''T%C3%A4st"
1276 >>> ct, d = _parse_header(d)
1277 >>> ct
1278 'form-data'
1279 >>> d['file'] == r'T\u00e4st'.encode('ascii').decode('unicode_escape')
1280 True
1281 >>> d['foo']
1282 'b\\a"r'
1283 """
1284 parts = _parseparam(";" + line)
1285 key = next(parts)
1286 # decode_params treats first argument special, but we already stripped key
1287 params = [("Dummy", "value")]
1288 for p in parts:
1289 i = p.find("=")
1290 if i >= 0:
1291 name = p[:i].strip().lower()
1292 value = p[i + 1 :].strip()
1293 params.append((name, native_str(value)))
1294 decoded_params = email.utils.decode_params(params)
1295 decoded_params.pop(0) # get rid of the dummy again
1296 pdict = {}
1297 for name, decoded_value in decoded_params:
1298 value = email.utils.collapse_rfc2231_value(decoded_value)
1299 if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
1300 value = value[1:-1]
1301 pdict[name] = value
1302 return key, pdict
1305def _encode_header(key: str, pdict: dict[str, str]) -> str:
1306 """Inverse of _parse_header.
1308 >>> _encode_header('permessage-deflate',
1309 ... {'client_max_window_bits': 15, 'client_no_context_takeover': None})
1310 'permessage-deflate; client_max_window_bits=15; client_no_context_takeover'
1311 """
1312 if not pdict:
1313 return key
1314 out = [key]
1315 # Sort the parameters just to make it easy to test.
1316 for k, v in sorted(pdict.items()):
1317 if v is None:
1318 out.append(k)
1319 else:
1320 # TODO: quote if necessary.
1321 out.append(f"{k}={v}")
1322 return "; ".join(out)
1325def encode_username_password(username: str | bytes, password: str | bytes) -> bytes:
1326 """Encodes a username/password pair in the format used by HTTP auth.
1328 The return value is a byte string in the form ``username:password``.
1330 .. versionadded:: 5.1
1331 """
1332 if isinstance(username, unicode_type):
1333 username = unicodedata.normalize("NFC", username)
1334 if isinstance(password, unicode_type):
1335 password = unicodedata.normalize("NFC", password)
1336 return utf8(username) + b":" + utf8(password)
1339def doctests() -> unittest.TestSuite:
1340 import doctest
1342 return doctest.DocTestSuite(optionflags=doctest.ELLIPSIS)
1345_netloc_re = re.compile(r"^(.+):(\d+)$")
1348def split_host_and_port(netloc: str) -> tuple[str, int | None]:
1349 """Returns ``(host, port)`` tuple from ``netloc``.
1351 Returned ``port`` will be ``None`` if not present.
1353 .. versionadded:: 4.1
1354 """
1355 match = _netloc_re.match(netloc)
1356 if match:
1357 host = match.group(1)
1358 port: int | None = int(match.group(2))
1359 else:
1360 host = netloc
1361 port = None
1362 return (host, port)
1365def qs_to_qsl(qs: dict[str, list[AnyStr]]) -> Iterable[tuple[str, AnyStr]]:
1366 """Generator converting a result of ``parse_qs`` back to name-value pairs.
1368 .. versionadded:: 5.0
1369 """
1370 for k, vs in qs.items():
1371 for v in vs:
1372 yield (k, v)
1375_unquote_sub = re.compile(r"\\(?:([0-3][0-7][0-7])|(.))").sub
1378def _unquote_replace(m: re.Match) -> str:
1379 if m[1]:
1380 return chr(int(m[1], 8))
1381 else:
1382 return m[2]
1385def _unquote_cookie(s: str) -> str:
1386 """Handle double quotes and escaping in cookie values.
1388 This method is copied verbatim from the Python 3.13 standard
1389 library (http.cookies._unquote) so we don't have to depend on
1390 non-public interfaces.
1391 """
1392 # If there aren't any doublequotes,
1393 # then there can't be any special characters. See RFC 2109.
1394 if s is None or len(s) < 2:
1395 return s
1396 if s[0] != '"' or s[-1] != '"':
1397 return s
1399 # We have to assume that we must decode this string.
1400 # Down to work.
1402 # Remove the "s
1403 s = s[1:-1]
1405 # Check for special sequences. Examples:
1406 # \012 --> \n
1407 # \" --> "
1408 #
1409 return _unquote_sub(_unquote_replace, s)
1412def parse_cookie(cookie: str) -> dict[str, str]:
1413 """Parse a ``Cookie`` HTTP header into a dict of name/value pairs.
1415 This function attempts to mimic browser cookie parsing behavior;
1416 it specifically does not follow any of the cookie-related RFCs
1417 (because browsers don't either).
1419 The algorithm used is identical to that used by Django version 1.9.10.
1421 .. versionadded:: 4.4.2
1422 """
1423 cookiedict = {}
1424 for chunk in cookie.split(";"):
1425 if "=" in chunk:
1426 key, val = chunk.split("=", 1)
1427 else:
1428 # Assume an empty name per
1429 # https://bugzilla.mozilla.org/show_bug.cgi?id=169091
1430 key, val = "", chunk
1431 key, val = key.strip(), val.strip()
1432 if key or val:
1433 # unquote using Python's algorithm.
1434 cookiedict[key] = _unquote_cookie(val)
1435 return cookiedict