_urlparse.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. """
  2. An implementation of `urlparse` that provides URL validation and normalization
  3. as described by RFC3986.
  4. We rely on this implementation rather than the one in Python's stdlib, because:
  5. * It provides more complete URL validation.
  6. * It properly differentiates between an empty querystring and an absent querystring,
  7. to distinguish URLs with a trailing '?'.
  8. * It handles scheme, hostname, port, and path normalization.
  9. * It supports IDNA hostnames, normalizing them to their encoded form.
  10. * The API supports passing individual components, as well as the complete URL string.
  11. Previously we relied on the excellent `rfc3986` package to handle URL parsing and
  12. validation, but this module provides a simpler alternative, with less indirection
  13. required.
  14. """
  15. import ipaddress
  16. import re
  17. import typing
  18. import idna
  19. from ._exceptions import InvalidURL
  20. MAX_URL_LENGTH = 65536
  21. # https://datatracker.ietf.org/doc/html/rfc3986.html#section-2.3
  22. UNRESERVED_CHARACTERS = (
  23. "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
  24. )
  25. SUB_DELIMS = "!$&'()*+,;="
  26. PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}")
  27. # {scheme}: (optional)
  28. # //{authority} (optional)
  29. # {path}
  30. # ?{query} (optional)
  31. # #{fragment} (optional)
  32. URL_REGEX = re.compile(
  33. (
  34. r"(?:(?P<scheme>{scheme}):)?"
  35. r"(?://(?P<authority>{authority}))?"
  36. r"(?P<path>{path})"
  37. r"(?:\?(?P<query>{query}))?"
  38. r"(?:#(?P<fragment>{fragment}))?"
  39. ).format(
  40. scheme="([a-zA-Z][a-zA-Z0-9+.-]*)?",
  41. authority="[^/?#]*",
  42. path="[^?#]*",
  43. query="[^#]*",
  44. fragment=".*",
  45. )
  46. )
  47. # {userinfo}@ (optional)
  48. # {host}
  49. # :{port} (optional)
  50. AUTHORITY_REGEX = re.compile(
  51. (
  52. r"(?:(?P<userinfo>{userinfo})@)?" r"(?P<host>{host})" r":?(?P<port>{port})?"
  53. ).format(
  54. userinfo=".*", # Any character sequence.
  55. host="(\\[.*\\]|[^:@]*)", # Either any character sequence excluding ':' or '@',
  56. # or an IPv6 address enclosed within square brackets.
  57. port=".*", # Any character sequence.
  58. )
  59. )
  60. # If we call urlparse with an individual component, then we need to regex
  61. # validate that component individually.
  62. # Note that we're duplicating the same strings as above. Shock! Horror!!
  63. COMPONENT_REGEX = {
  64. "scheme": re.compile("([a-zA-Z][a-zA-Z0-9+.-]*)?"),
  65. "authority": re.compile("[^/?#]*"),
  66. "path": re.compile("[^?#]*"),
  67. "query": re.compile("[^#]*"),
  68. "fragment": re.compile(".*"),
  69. "userinfo": re.compile("[^@]*"),
  70. "host": re.compile("(\\[.*\\]|[^:]*)"),
  71. "port": re.compile(".*"),
  72. }
  73. # We use these simple regexs as a first pass before handing off to
  74. # the stdlib 'ipaddress' module for IP address validation.
  75. IPv4_STYLE_HOSTNAME = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
  76. IPv6_STYLE_HOSTNAME = re.compile(r"^\[.*\]$")
  77. class ParseResult(typing.NamedTuple):
  78. scheme: str
  79. userinfo: str
  80. host: str
  81. port: typing.Optional[int]
  82. path: str
  83. query: typing.Optional[str]
  84. fragment: typing.Optional[str]
  85. @property
  86. def authority(self) -> str:
  87. return "".join(
  88. [
  89. f"{self.userinfo}@" if self.userinfo else "",
  90. f"[{self.host}]" if ":" in self.host else self.host,
  91. f":{self.port}" if self.port is not None else "",
  92. ]
  93. )
  94. @property
  95. def netloc(self) -> str:
  96. return "".join(
  97. [
  98. f"[{self.host}]" if ":" in self.host else self.host,
  99. f":{self.port}" if self.port is not None else "",
  100. ]
  101. )
  102. def copy_with(self, **kwargs: typing.Optional[str]) -> "ParseResult":
  103. if not kwargs:
  104. return self
  105. defaults = {
  106. "scheme": self.scheme,
  107. "authority": self.authority,
  108. "path": self.path,
  109. "query": self.query,
  110. "fragment": self.fragment,
  111. }
  112. defaults.update(kwargs)
  113. return urlparse("", **defaults)
  114. def __str__(self) -> str:
  115. authority = self.authority
  116. return "".join(
  117. [
  118. f"{self.scheme}:" if self.scheme else "",
  119. f"//{authority}" if authority else "",
  120. self.path,
  121. f"?{self.query}" if self.query is not None else "",
  122. f"#{self.fragment}" if self.fragment is not None else "",
  123. ]
  124. )
  125. def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> ParseResult:
  126. # Initial basic checks on allowable URLs.
  127. # ---------------------------------------
  128. # Hard limit the maximum allowable URL length.
  129. if len(url) > MAX_URL_LENGTH:
  130. raise InvalidURL("URL too long")
  131. # If a URL includes any ASCII control characters including \t, \r, \n,
  132. # then treat it as invalid.
  133. if any(char.isascii() and not char.isprintable() for char in url):
  134. raise InvalidURL("Invalid non-printable ASCII character in URL")
  135. # Some keyword arguments require special handling.
  136. # ------------------------------------------------
  137. # Coerce "port" to a string, if it is provided as an integer.
  138. if "port" in kwargs:
  139. port = kwargs["port"]
  140. kwargs["port"] = str(port) if isinstance(port, int) else port
  141. # Replace "netloc" with "host and "port".
  142. if "netloc" in kwargs:
  143. netloc = kwargs.pop("netloc") or ""
  144. kwargs["host"], _, kwargs["port"] = netloc.partition(":")
  145. # Replace "username" and/or "password" with "userinfo".
  146. if "username" in kwargs or "password" in kwargs:
  147. username = quote(kwargs.pop("username", "") or "")
  148. password = quote(kwargs.pop("password", "") or "")
  149. kwargs["userinfo"] = f"{username}:{password}" if password else username
  150. # Replace "raw_path" with "path" and "query".
  151. if "raw_path" in kwargs:
  152. raw_path = kwargs.pop("raw_path") or ""
  153. kwargs["path"], seperator, kwargs["query"] = raw_path.partition("?")
  154. if not seperator:
  155. kwargs["query"] = None
  156. # Ensure that IPv6 "host" addresses are always escaped with "[...]".
  157. if "host" in kwargs:
  158. host = kwargs.get("host") or ""
  159. if ":" in host and not (host.startswith("[") and host.endswith("]")):
  160. kwargs["host"] = f"[{host}]"
  161. # If any keyword arguments are provided, ensure they are valid.
  162. # -------------------------------------------------------------
  163. for key, value in kwargs.items():
  164. if value is not None:
  165. if len(value) > MAX_URL_LENGTH:
  166. raise InvalidURL(f"URL component '{key}' too long")
  167. # If a component includes any ASCII control characters including \t, \r, \n,
  168. # then treat it as invalid.
  169. if any(char.isascii() and not char.isprintable() for char in value):
  170. raise InvalidURL(
  171. f"Invalid non-printable ASCII character in URL component '{key}'"
  172. )
  173. # Ensure that keyword arguments match as a valid regex.
  174. if not COMPONENT_REGEX[key].fullmatch(value):
  175. raise InvalidURL(f"Invalid URL component '{key}'")
  176. # The URL_REGEX will always match, but may have empty components.
  177. url_match = URL_REGEX.match(url)
  178. assert url_match is not None
  179. url_dict = url_match.groupdict()
  180. # * 'scheme', 'authority', and 'path' may be empty strings.
  181. # * 'query' may be 'None', indicating no trailing "?" portion.
  182. # Any string including the empty string, indicates a trailing "?".
  183. # * 'fragment' may be 'None', indicating no trailing "#" portion.
  184. # Any string including the empty string, indicates a trailing "#".
  185. scheme = kwargs.get("scheme", url_dict["scheme"]) or ""
  186. authority = kwargs.get("authority", url_dict["authority"]) or ""
  187. path = kwargs.get("path", url_dict["path"]) or ""
  188. query = kwargs.get("query", url_dict["query"])
  189. fragment = kwargs.get("fragment", url_dict["fragment"])
  190. # The AUTHORITY_REGEX will always match, but may have empty components.
  191. authority_match = AUTHORITY_REGEX.match(authority)
  192. assert authority_match is not None
  193. authority_dict = authority_match.groupdict()
  194. # * 'userinfo' and 'host' may be empty strings.
  195. # * 'port' may be 'None'.
  196. userinfo = kwargs.get("userinfo", authority_dict["userinfo"]) or ""
  197. host = kwargs.get("host", authority_dict["host"]) or ""
  198. port = kwargs.get("port", authority_dict["port"])
  199. # Normalize and validate each component.
  200. # We end up with a parsed representation of the URL,
  201. # with components that are plain ASCII bytestrings.
  202. parsed_scheme: str = scheme.lower()
  203. parsed_userinfo: str = quote(userinfo, safe=SUB_DELIMS + ":")
  204. parsed_host: str = encode_host(host)
  205. parsed_port: typing.Optional[int] = normalize_port(port, scheme)
  206. has_scheme = parsed_scheme != ""
  207. has_authority = (
  208. parsed_userinfo != "" or parsed_host != "" or parsed_port is not None
  209. )
  210. validate_path(path, has_scheme=has_scheme, has_authority=has_authority)
  211. if has_authority:
  212. path = normalize_path(path)
  213. # The GEN_DELIMS set is... : / ? # [ ] @
  214. # These do not need to be percent-quoted unless they serve as delimiters for the
  215. # specific component.
  216. # For 'path' we need to drop ? and # from the GEN_DELIMS set.
  217. parsed_path: str = quote(path, safe=SUB_DELIMS + ":/[]@")
  218. # For 'query' we need to drop '#' from the GEN_DELIMS set.
  219. parsed_query: typing.Optional[str] = (
  220. None if query is None else quote(query, safe=SUB_DELIMS + ":/?[]@")
  221. )
  222. # For 'fragment' we can include all of the GEN_DELIMS set.
  223. parsed_fragment: typing.Optional[str] = (
  224. None if fragment is None else quote(fragment, safe=SUB_DELIMS + ":/?#[]@")
  225. )
  226. # The parsed ASCII bytestrings are our canonical form.
  227. # All properties of the URL are derived from these.
  228. return ParseResult(
  229. parsed_scheme,
  230. parsed_userinfo,
  231. parsed_host,
  232. parsed_port,
  233. parsed_path,
  234. parsed_query,
  235. parsed_fragment,
  236. )
  237. def encode_host(host: str) -> str:
  238. if not host:
  239. return ""
  240. elif IPv4_STYLE_HOSTNAME.match(host):
  241. # Validate IPv4 hostnames like #.#.#.#
  242. #
  243. # From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
  244. #
  245. # IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
  246. try:
  247. ipaddress.IPv4Address(host)
  248. except ipaddress.AddressValueError:
  249. raise InvalidURL(f"Invalid IPv4 address: {host!r}")
  250. return host
  251. elif IPv6_STYLE_HOSTNAME.match(host):
  252. # Validate IPv6 hostnames like [...]
  253. #
  254. # From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
  255. #
  256. # "A host identified by an Internet Protocol literal address, version 6
  257. # [RFC3513] or later, is distinguished by enclosing the IP literal
  258. # within square brackets ("[" and "]"). This is the only place where
  259. # square bracket characters are allowed in the URI syntax."
  260. try:
  261. ipaddress.IPv6Address(host[1:-1])
  262. except ipaddress.AddressValueError:
  263. raise InvalidURL(f"Invalid IPv6 address: {host!r}")
  264. return host[1:-1]
  265. elif host.isascii():
  266. # Regular ASCII hostnames
  267. #
  268. # From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
  269. #
  270. # reg-name = *( unreserved / pct-encoded / sub-delims )
  271. return quote(host.lower(), safe=SUB_DELIMS)
  272. # IDNA hostnames
  273. try:
  274. return idna.encode(host.lower()).decode("ascii")
  275. except idna.IDNAError:
  276. raise InvalidURL(f"Invalid IDNA hostname: {host!r}")
  277. def normalize_port(
  278. port: typing.Optional[typing.Union[str, int]], scheme: str
  279. ) -> typing.Optional[int]:
  280. # From https://tools.ietf.org/html/rfc3986#section-3.2.3
  281. #
  282. # "A scheme may define a default port. For example, the "http" scheme
  283. # defines a default port of "80", corresponding to its reserved TCP
  284. # port number. The type of port designated by the port number (e.g.,
  285. # TCP, UDP, SCTP) is defined by the URI scheme. URI producers and
  286. # normalizers should omit the port component and its ":" delimiter if
  287. # port is empty or if its value would be the same as that of the
  288. # scheme's default."
  289. if port is None or port == "":
  290. return None
  291. try:
  292. port_as_int = int(port)
  293. except ValueError:
  294. raise InvalidURL(f"Invalid port: {port!r}")
  295. # See https://url.spec.whatwg.org/#url-miscellaneous
  296. default_port = {"ftp": 21, "http": 80, "https": 443, "ws": 80, "wss": 443}.get(
  297. scheme
  298. )
  299. if port_as_int == default_port:
  300. return None
  301. return port_as_int
  302. def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
  303. """
  304. Path validation rules that depend on if the URL contains
  305. a scheme or authority component.
  306. See https://datatracker.ietf.org/doc/html/rfc3986.html#section-3.3
  307. """
  308. if has_authority:
  309. # If a URI contains an authority component, then the path component
  310. # must either be empty or begin with a slash ("/") character."
  311. if path and not path.startswith("/"):
  312. raise InvalidURL("For absolute URLs, path must be empty or begin with '/'")
  313. else:
  314. # If a URI does not contain an authority component, then the path cannot begin
  315. # with two slash characters ("//").
  316. if path.startswith("//"):
  317. raise InvalidURL(
  318. "URLs with no authority component cannot have a path starting with '//'"
  319. )
  320. # In addition, a URI reference (Section 4.1) may be a relative-path reference,
  321. # in which case the first path segment cannot contain a colon (":") character.
  322. if path.startswith(":") and not has_scheme:
  323. raise InvalidURL(
  324. "URLs with no scheme component cannot have a path starting with ':'"
  325. )
  326. def normalize_path(path: str) -> str:
  327. """
  328. Drop "." and ".." segments from a URL path.
  329. For example:
  330. normalize_path("/path/./to/somewhere/..") == "/path/to"
  331. """
  332. # https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
  333. components = path.split("/")
  334. output: typing.List[str] = []
  335. for component in components:
  336. if component == ".":
  337. pass
  338. elif component == "..":
  339. if output and output != [""]:
  340. output.pop()
  341. else:
  342. output.append(component)
  343. return "/".join(output)
  344. def percent_encode(char: str) -> str:
  345. """
  346. Replace a single character with the percent-encoded representation.
  347. Characters outside the ASCII range are represented with their a percent-encoded
  348. representation of their UTF-8 byte sequence.
  349. For example:
  350. percent_encode(" ") == "%20"
  351. """
  352. return "".join([f"%{byte:02x}" for byte in char.encode("utf-8")]).upper()
  353. def is_safe(string: str, safe: str = "/") -> bool:
  354. """
  355. Determine if a given string is already quote-safe.
  356. """
  357. NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe + "%"
  358. # All characters must already be non-escaping or '%'
  359. for char in string:
  360. if char not in NON_ESCAPED_CHARS:
  361. return False
  362. return True
  363. def percent_encoded(string: str, safe: str = "/") -> str:
  364. """
  365. Use percent-encoding to quote a string.
  366. """
  367. if is_safe(string, safe=safe):
  368. return string
  369. NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
  370. return "".join(
  371. [char if char in NON_ESCAPED_CHARS else percent_encode(char) for char in string]
  372. )
  373. def quote(string: str, safe: str = "/") -> str:
  374. """
  375. Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
  376. See: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
  377. * `string`: The string to be percent-escaped.
  378. * `safe`: A string containing characters that may be treated as safe, and do not
  379. need to be escaped. Unreserved characters are always treated as safe.
  380. See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
  381. """
  382. parts = []
  383. current_position = 0
  384. for match in re.finditer(PERCENT_ENCODED_REGEX, string):
  385. start_position, end_position = match.start(), match.end()
  386. matched_text = match.group(0)
  387. # Add any text up to the '%xx' escape sequence.
  388. if start_position != current_position:
  389. leading_text = string[current_position:start_position]
  390. parts.append(percent_encoded(leading_text, safe=safe))
  391. # Add the '%xx' escape sequence.
  392. parts.append(matched_text)
  393. current_position = end_position
  394. # Add any text after the final '%xx' escape sequence.
  395. if current_position != len(string):
  396. trailing_text = string[current_position:]
  397. parts.append(percent_encoded(trailing_text, safe=safe))
  398. return "".join(parts)
  399. def urlencode(items: typing.List[typing.Tuple[str, str]]) -> str:
  400. """
  401. We can use a much simpler version of the stdlib urlencode here because
  402. we don't need to handle a bunch of different typing cases, such as bytes vs str.
  403. https://github.com/python/cpython/blob/b2f7b2ef0b5421e01efb8c7bee2ef95d3bab77eb/Lib/urllib/parse.py#L926
  404. Note that we use '%20' encoding for spaces. and '%2F for '/'.
  405. This is slightly different than `requests`, but is the behaviour that browsers use.
  406. See
  407. - https://github.com/encode/httpx/issues/2536
  408. - https://github.com/encode/httpx/issues/2721
  409. - https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode
  410. """
  411. return "&".join(
  412. [
  413. percent_encoded(k, safe="") + "=" + percent_encoded(v, safe="")
  414. for k, v in items
  415. ]
  416. )