Format: text/x-python
⬇ Download File (10.0 KB)
   1  import re
   2  from typing import AnyStr, cast, List, overload, Sequence, Tuple, TYPE_CHECKING, Union
   3  
   4  from ._abnf import field_name, field_value
   5  from ._util import bytesify, LocalProtocolError, validate
   6  
   7  if TYPE_CHECKING:
   8      from ._events import Request
   9  
  10  try:
  11      from typing import Literal
  12  except ImportError:
  13      from typing_extensions import Literal  # type: ignore
  14  
  15  
  16  # Facts
  17  # -----
  18  #
  19  # Headers are:
  20  #   keys: case-insensitive ascii
  21  #   values: mixture of ascii and raw bytes
  22  #
  23  # "Historically, HTTP has allowed field content with text in the ISO-8859-1
  24  # charset [ISO-8859-1], supporting other charsets only through use of
  25  # [RFC2047] encoding.  In practice, most HTTP header field values use only a
  26  # subset of the US-ASCII charset [USASCII]. Newly defined header fields SHOULD
  27  # limit their field values to US-ASCII octets.  A recipient SHOULD treat other
  28  # octets in field content (obs-text) as opaque data."
  29  # And it deprecates all non-ascii values
  30  #
  31  # Leading/trailing whitespace in header names is forbidden
  32  #
  33  # Values get leading/trailing whitespace stripped
  34  #
  35  # Content-Disposition actually needs to contain unicode semantically; to
  36  # accomplish this it has a terrifically weird way of encoding the filename
  37  # itself as ascii (and even this still has lots of cross-browser
  38  # incompatibilities)
  39  #
  40  # Order is important:
  41  # "a proxy MUST NOT change the order of these field values when forwarding a
  42  # message"
  43  # (and there are several headers where the order indicates a preference)
  44  #
  45  # Multiple occurences of the same header:
  46  # "A sender MUST NOT generate multiple header fields with the same field name
  47  # in a message unless either the entire field value for that header field is
  48  # defined as a comma-separated list [or the header is Set-Cookie which gets a
  49  # special exception]" - RFC 7230. (cookies are in RFC 6265)
  50  #
  51  # So every header aside from Set-Cookie can be merged by b", ".join if it
  52  # occurs repeatedly. But, of course, they can't necessarily be split by
  53  # .split(b","), because quoting.
  54  #
  55  # Given all this mess (case insensitive, duplicates allowed, order is
  56  # important, ...), there doesn't appear to be any standard way to handle
  57  # headers in Python -- they're almost like dicts, but... actually just
  58  # aren't. For now we punt and just use a super simple representation: headers
  59  # are a list of pairs
  60  #
  61  #   [(name1, value1), (name2, value2), ...]
  62  #
  63  # where all entries are bytestrings, names are lowercase and have no
  64  # leading/trailing whitespace, and values are bytestrings with no
  65  # leading/trailing whitespace. Searching and updating are done via naive O(n)
  66  # methods.
  67  #
  68  # Maybe a dict-of-lists would be better?
  69  
  70  _content_length_re = re.compile(rb"[0-9]+")
  71  _field_name_re = re.compile(field_name.encode("ascii"))
  72  _field_value_re = re.compile(field_value.encode("ascii"))
  73  
  74  
  75  class Headers(Sequence[Tuple[bytes, bytes]]):
  76      """
  77      A list-like interface that allows iterating over headers as byte-pairs
  78      of (lowercased-name, value).
  79  
  80      Internally we actually store the representation as three-tuples,
  81      including both the raw original casing, in order to preserve casing
  82      over-the-wire, and the lowercased name, for case-insensitive comparisions.
  83  
  84      r = Request(
  85          method="GET",
  86          target="/",
  87          headers=[("Host", "example.org"), ("Connection", "keep-alive")],
  88          http_version="1.1",
  89      )
  90      assert r.headers == [
  91          (b"host", b"example.org"),
  92          (b"connection", b"keep-alive")
  93      ]
  94      assert r.headers.raw_items() == [
  95          (b"Host", b"example.org"),
  96          (b"Connection", b"keep-alive")
  97      ]
  98      """
  99  
 100      __slots__ = "_full_items"
 101  
 102      def __init__(self, full_items: List[Tuple[bytes, bytes, bytes]]) -> None:
 103          self._full_items = full_items
 104  
 105      def __bool__(self) -> bool:
 106          return bool(self._full_items)
 107  
 108      def __eq__(self, other: object) -> bool:
 109          return list(self) == list(other)  # type: ignore
 110  
 111      def __len__(self) -> int:
 112          return len(self._full_items)
 113  
 114      def __repr__(self) -> str:
 115          return "<Headers(%s)>" % repr(list(self))
 116  
 117      def __getitem__(self, idx: int) -> Tuple[bytes, bytes]:  # type: ignore[override]
 118          _, name, value = self._full_items[idx]
 119          return (name, value)
 120  
 121      def raw_items(self) -> List[Tuple[bytes, bytes]]:
 122          return [(raw_name, value) for raw_name, _, value in self._full_items]
 123  
 124  
 125  HeaderTypes = Union[
 126      List[Tuple[bytes, bytes]],
 127      List[Tuple[bytes, str]],
 128      List[Tuple[str, bytes]],
 129      List[Tuple[str, str]],
 130  ]
 131  
 132  
 133  @overload
 134  def normalize_and_validate(headers: Headers, _parsed: Literal[True]) -> Headers:
 135      ...
 136  
 137  
 138  @overload
 139  def normalize_and_validate(headers: HeaderTypes, _parsed: Literal[False]) -> Headers:
 140      ...
 141  
 142  
 143  @overload
 144  def normalize_and_validate(
 145      headers: Union[Headers, HeaderTypes], _parsed: bool = False
 146  ) -> Headers:
 147      ...
 148  
 149  
 150  def normalize_and_validate(
 151      headers: Union[Headers, HeaderTypes], _parsed: bool = False
 152  ) -> Headers:
 153      new_headers = []
 154      seen_content_length = None
 155      saw_transfer_encoding = False
 156      for name, value in headers:
 157          # For headers coming out of the parser, we can safely skip some steps,
 158          # because it always returns bytes and has already run these regexes
 159          # over the data:
 160          if not _parsed:
 161              name = bytesify(name)
 162              value = bytesify(value)
 163              validate(_field_name_re, name, "Illegal header name {!r}", name)
 164              validate(_field_value_re, value, "Illegal header value {!r}", value)
 165          assert isinstance(name, bytes)
 166          assert isinstance(value, bytes)
 167  
 168          raw_name = name
 169          name = name.lower()
 170          if name == b"content-length":
 171              lengths = {length.strip() for length in value.split(b",")}
 172              if len(lengths) != 1:
 173                  raise LocalProtocolError("conflicting Content-Length headers")
 174              value = lengths.pop()
 175              validate(_content_length_re, value, "bad Content-Length")
 176              if seen_content_length is None:
 177                  seen_content_length = value
 178                  new_headers.append((raw_name, name, value))
 179              elif seen_content_length != value:
 180                  raise LocalProtocolError("conflicting Content-Length headers")
 181          elif name == b"transfer-encoding":
 182              # "A server that receives a request message with a transfer coding
 183              # it does not understand SHOULD respond with 501 (Not
 184              # Implemented)."
 185              # https://tools.ietf.org/html/rfc7230#section-3.3.1
 186              if saw_transfer_encoding:
 187                  raise LocalProtocolError(
 188                      "multiple Transfer-Encoding headers", error_status_hint=501
 189                  )
 190              # "All transfer-coding names are case-insensitive"
 191              # -- https://tools.ietf.org/html/rfc7230#section-4
 192              value = value.lower()
 193              if value != b"chunked":
 194                  raise LocalProtocolError(
 195                      "Only Transfer-Encoding: chunked is supported",
 196                      error_status_hint=501,
 197                  )
 198              saw_transfer_encoding = True
 199              new_headers.append((raw_name, name, value))
 200          else:
 201              new_headers.append((raw_name, name, value))
 202      return Headers(new_headers)
 203  
 204  
 205  def get_comma_header(headers: Headers, name: bytes) -> List[bytes]:
 206      # Should only be used for headers whose value is a list of
 207      # comma-separated, case-insensitive values.
 208      #
 209      # The header name `name` is expected to be lower-case bytes.
 210      #
 211      # Connection: meets these criteria (including cast insensitivity).
 212      #
 213      # Content-Length: technically is just a single value (1*DIGIT), but the
 214      # standard makes reference to implementations that do multiple values, and
 215      # using this doesn't hurt. Ditto, case insensitivity doesn't things either
 216      # way.
 217      #
 218      # Transfer-Encoding: is more complex (allows for quoted strings), so
 219      # splitting on , is actually wrong. For example, this is legal:
 220      #
 221      #    Transfer-Encoding: foo; options="1,2", chunked
 222      #
 223      # and should be parsed as
 224      #
 225      #    foo; options="1,2"
 226      #    chunked
 227      #
 228      # but this naive function will parse it as
 229      #
 230      #    foo; options="1
 231      #    2"
 232      #    chunked
 233      #
 234      # However, this is okay because the only thing we are going to do with
 235      # any Transfer-Encoding is reject ones that aren't just "chunked", so
 236      # both of these will be treated the same anyway.
 237      #
 238      # Expect: the only legal value is the literal string
 239      # "100-continue". Splitting on commas is harmless. Case insensitive.
 240      #
 241      out: List[bytes] = []
 242      for _, found_name, found_raw_value in headers._full_items:
 243          if found_name == name:
 244              found_raw_value = found_raw_value.lower()
 245              for found_split_value in found_raw_value.split(b","):
 246                  found_split_value = found_split_value.strip()
 247                  if found_split_value:
 248                      out.append(found_split_value)
 249      return out
 250  
 251  
 252  def set_comma_header(headers: Headers, name: bytes, new_values: List[bytes]) -> Headers:
 253      # The header name `name` is expected to be lower-case bytes.
 254      #
 255      # Note that when we store the header we use title casing for the header
 256      # names, in order to match the conventional HTTP header style.
 257      #
 258      # Simply calling `.title()` is a blunt approach, but it's correct
 259      # here given the cases where we're using `set_comma_header`...
 260      #
 261      # Connection, Content-Length, Transfer-Encoding.
 262      new_headers: List[Tuple[bytes, bytes]] = []
 263      for found_raw_name, found_name, found_raw_value in headers._full_items:
 264          if found_name != name:
 265              new_headers.append((found_raw_name, found_raw_value))
 266      for new_value in new_values:
 267          new_headers.append((name.title(), new_value))
 268      return normalize_and_validate(new_headers)
 269  
 270  
 271  def has_expect_100_continue(request: "Request") -> bool:
 272      # https://tools.ietf.org/html/rfc7231#section-5.1.1
 273      # "A server that receives a 100-continue expectation in an HTTP/1.0 request
 274      # MUST ignore that expectation."
 275      if request.http_version < b"1.1":
 276          return False
 277      expect = get_comma_header(request.headers, b"expect")
 278      return b"100-continue" in expect
 279