_urlparse.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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. from __future__ import annotations
  16. import ipaddress
  17. import re
  18. import typing
  19. import idna
  20. from ._exceptions import InvalidURL
  21. MAX_URL_LENGTH = 65536
  22. # https://datatracker.ietf.org/doc/html/rfc3986.html#section-2.3
  23. UNRESERVED_CHARACTERS = (
  24. "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
  25. )
  26. SUB_DELIMS = "!$&'()*+,;="
  27. PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}")
  28. # https://url.spec.whatwg.org/#percent-encoded-bytes
  29. # The fragment percent-encode set is the C0 control percent-encode set
  30. # and U+0020 SPACE, U+0022 ("), U+003C (<), U+003E (>), and U+0060 (`).
  31. FRAG_SAFE = "".join(
  32. [chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x3C, 0x3E, 0x60)]
  33. )
  34. # The query percent-encode set is the C0 control percent-encode set
  35. # and U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>).
  36. QUERY_SAFE = "".join(
  37. [chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E)]
  38. )
  39. # The path percent-encode set is the query percent-encode set
  40. # and U+003F (?), U+0060 (`), U+007B ({), and U+007D (}).
  41. PATH_SAFE = "".join(
  42. [
  43. chr(i)
  44. for i in range(0x20, 0x7F)
  45. if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + (0x3F, 0x60, 0x7B, 0x7D)
  46. ]
  47. )
  48. # The userinfo percent-encode set is the path percent-encode set
  49. # and U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@),
  50. # U+005B ([) to U+005E (^), inclusive, and U+007C (|).
  51. USERNAME_SAFE = "".join(
  52. [
  53. chr(i)
  54. for i in range(0x20, 0x7F)
  55. if i
  56. not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
  57. + (0x3F, 0x60, 0x7B, 0x7D)
  58. + (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
  59. ]
  60. )
  61. PASSWORD_SAFE = "".join(
  62. [
  63. chr(i)
  64. for i in range(0x20, 0x7F)
  65. if i
  66. not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
  67. + (0x3F, 0x60, 0x7B, 0x7D)
  68. + (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
  69. ]
  70. )
  71. # Note... The terminology 'userinfo' percent-encode set in the WHATWG document
  72. # is used for the username and password quoting. For the joint userinfo component
  73. # we remove U+003A (:) from the safe set.
  74. USERINFO_SAFE = "".join(
  75. [
  76. chr(i)
  77. for i in range(0x20, 0x7F)
  78. if i
  79. not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
  80. + (0x3F, 0x60, 0x7B, 0x7D)
  81. + (0x2F, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
  82. ]
  83. )
  84. # {scheme}: (optional)
  85. # //{authority} (optional)
  86. # {path}
  87. # ?{query} (optional)
  88. # #{fragment} (optional)
  89. URL_REGEX = re.compile(
  90. (
  91. r"(?:(?P<scheme>{scheme}):)?"
  92. r"(?://(?P<authority>{authority}))?"
  93. r"(?P<path>{path})"
  94. r"(?:\?(?P<query>{query}))?"
  95. r"(?:#(?P<fragment>{fragment}))?"
  96. ).format(
  97. scheme="([a-zA-Z][a-zA-Z0-9+.-]*)?",
  98. authority="[^/?#]*",
  99. path="[^?#]*",
  100. query="[^#]*",
  101. fragment=".*",
  102. )
  103. )
  104. # {userinfo}@ (optional)
  105. # {host}
  106. # :{port} (optional)
  107. AUTHORITY_REGEX = re.compile(
  108. (
  109. r"(?:(?P<userinfo>{userinfo})@)?" r"(?P<host>{host})" r":?(?P<port>{port})?"
  110. ).format(
  111. userinfo=".*", # Any character sequence.
  112. host="(\\[.*\\]|[^:@]*)", # Either any character sequence excluding ':' or '@',
  113. # or an IPv6 address enclosed within square brackets.
  114. port=".*", # Any character sequence.
  115. )
  116. )
  117. # If we call urlparse with an individual component, then we need to regex
  118. # validate that component individually.
  119. # Note that we're duplicating the same strings as above. Shock! Horror!!
  120. COMPONENT_REGEX = {
  121. "scheme": re.compile("([a-zA-Z][a-zA-Z0-9+.-]*)?"),
  122. "authority": re.compile("[^/?#]*"),
  123. "path": re.compile("[^?#]*"),
  124. "query": re.compile("[^#]*"),
  125. "fragment": re.compile(".*"),
  126. "userinfo": re.compile("[^@]*"),
  127. "host": re.compile("(\\[.*\\]|[^:]*)"),
  128. "port": re.compile(".*"),
  129. }
  130. # We use these simple regexs as a first pass before handing off to
  131. # the stdlib 'ipaddress' module for IP address validation.
  132. IPv4_STYLE_HOSTNAME = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
  133. IPv6_STYLE_HOSTNAME = re.compile(r"^\[.*\]$")
  134. class ParseResult(typing.NamedTuple):
  135. scheme: str
  136. userinfo: str
  137. host: str
  138. port: int | None
  139. path: str
  140. query: str | None
  141. fragment: str | None
  142. @property
  143. def authority(self) -> str:
  144. return "".join(
  145. [
  146. f"{self.userinfo}@" if self.userinfo else "",
  147. f"[{self.host}]" if ":" in self.host else self.host,
  148. f":{self.port}" if self.port is not None else "",
  149. ]
  150. )
  151. @property
  152. def netloc(self) -> str:
  153. return "".join(
  154. [
  155. f"[{self.host}]" if ":" in self.host else self.host,
  156. f":{self.port}" if self.port is not None else "",
  157. ]
  158. )
  159. def copy_with(self, **kwargs: str | None) -> ParseResult:
  160. if not kwargs:
  161. return self
  162. defaults = {
  163. "scheme": self.scheme,
  164. "authority": self.authority,
  165. "path": self.path,
  166. "query": self.query,
  167. "fragment": self.fragment,
  168. }
  169. defaults.update(kwargs)
  170. return urlparse("", **defaults)
  171. def __str__(self) -> str:
  172. authority = self.authority
  173. return "".join(
  174. [
  175. f"{self.scheme}:" if self.scheme else "",
  176. f"//{authority}" if authority else "",
  177. self.path,
  178. f"?{self.query}" if self.query is not None else "",
  179. f"#{self.fragment}" if self.fragment is not None else "",
  180. ]
  181. )
  182. def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
  183. # Initial basic checks on allowable URLs.
  184. # ---------------------------------------
  185. # Hard limit the maximum allowable URL length.
  186. if len(url) > MAX_URL_LENGTH:
  187. raise InvalidURL("URL too long")
  188. # If a URL includes any ASCII control characters including \t, \r, \n,
  189. # then treat it as invalid.
  190. if any(char.isascii() and not char.isprintable() for char in url):
  191. char = next(char for char in url if char.isascii() and not char.isprintable())
  192. idx = url.find(char)
  193. error = (
  194. f"Invalid non-printable ASCII character in URL, {char!r} at position {idx}."
  195. )
  196. raise InvalidURL(error)
  197. # Some keyword arguments require special handling.
  198. # ------------------------------------------------
  199. # Coerce "port" to a string, if it is provided as an integer.
  200. if "port" in kwargs:
  201. port = kwargs["port"]
  202. kwargs["port"] = str(port) if isinstance(port, int) else port
  203. # Replace "netloc" with "host and "port".
  204. if "netloc" in kwargs:
  205. netloc = kwargs.pop("netloc") or ""
  206. kwargs["host"], _, kwargs["port"] = netloc.partition(":")
  207. # Replace "username" and/or "password" with "userinfo".
  208. if "username" in kwargs or "password" in kwargs:
  209. username = quote(kwargs.pop("username", "") or "", safe=USERNAME_SAFE)
  210. password = quote(kwargs.pop("password", "") or "", safe=PASSWORD_SAFE)
  211. kwargs["userinfo"] = f"{username}:{password}" if password else username
  212. # Replace "raw_path" with "path" and "query".
  213. if "raw_path" in kwargs:
  214. raw_path = kwargs.pop("raw_path") or ""
  215. kwargs["path"], seperator, kwargs["query"] = raw_path.partition("?")
  216. if not seperator:
  217. kwargs["query"] = None
  218. # Ensure that IPv6 "host" addresses are always escaped with "[...]".
  219. if "host" in kwargs:
  220. host = kwargs.get("host") or ""
  221. if ":" in host and not (host.startswith("[") and host.endswith("]")):
  222. kwargs["host"] = f"[{host}]"
  223. # If any keyword arguments are provided, ensure they are valid.
  224. # -------------------------------------------------------------
  225. for key, value in kwargs.items():
  226. if value is not None:
  227. if len(value) > MAX_URL_LENGTH:
  228. raise InvalidURL(f"URL component '{key}' too long")
  229. # If a component includes any ASCII control characters including \t, \r, \n,
  230. # then treat it as invalid.
  231. if any(char.isascii() and not char.isprintable() for char in value):
  232. char = next(
  233. char for char in value if char.isascii() and not char.isprintable()
  234. )
  235. idx = value.find(char)
  236. error = (
  237. f"Invalid non-printable ASCII character in URL {key} component, "
  238. f"{char!r} at position {idx}."
  239. )
  240. raise InvalidURL(error)
  241. # Ensure that keyword arguments match as a valid regex.
  242. if not COMPONENT_REGEX[key].fullmatch(value):
  243. raise InvalidURL(f"Invalid URL component '{key}'")
  244. # The URL_REGEX will always match, but may have empty components.
  245. url_match = URL_REGEX.match(url)
  246. assert url_match is not None
  247. url_dict = url_match.groupdict()
  248. # * 'scheme', 'authority', and 'path' may be empty strings.
  249. # * 'query' may be 'None', indicating no trailing "?" portion.
  250. # Any string including the empty string, indicates a trailing "?".
  251. # * 'fragment' may be 'None', indicating no trailing "#" portion.
  252. # Any string including the empty string, indicates a trailing "#".
  253. scheme = kwargs.get("scheme", url_dict["scheme"]) or ""
  254. authority = kwargs.get("authority", url_dict["authority"]) or ""
  255. path = kwargs.get("path", url_dict["path"]) or ""
  256. query = kwargs.get("query", url_dict["query"])
  257. frag = kwargs.get("fragment", url_dict["fragment"])
  258. # The AUTHORITY_REGEX will always match, but may have empty components.
  259. authority_match = AUTHORITY_REGEX.match(authority)
  260. assert authority_match is not None
  261. authority_dict = authority_match.groupdict()
  262. # * 'userinfo' and 'host' may be empty strings.
  263. # * 'port' may be 'None'.
  264. userinfo = kwargs.get("userinfo", authority_dict["userinfo"]) or ""
  265. host = kwargs.get("host", authority_dict["host"]) or ""
  266. port = kwargs.get("port", authority_dict["port"])
  267. # Normalize and validate each component.
  268. # We end up with a parsed representation of the URL,
  269. # with components that are plain ASCII bytestrings.
  270. parsed_scheme: str = scheme.lower()
  271. parsed_userinfo: str = quote(userinfo, safe=USERINFO_SAFE)
  272. parsed_host: str = encode_host(host)
  273. parsed_port: int | None = normalize_port(port, scheme)
  274. has_scheme = parsed_scheme != ""
  275. has_authority = (
  276. parsed_userinfo != "" or parsed_host != "" or parsed_port is not None
  277. )
  278. validate_path(path, has_scheme=has_scheme, has_authority=has_authority)
  279. if has_scheme or has_authority:
  280. path = normalize_path(path)
  281. parsed_path: str = quote(path, safe=PATH_SAFE)
  282. parsed_query: str | None = None if query is None else quote(query, safe=QUERY_SAFE)
  283. parsed_frag: str | None = None if frag is None else quote(frag, safe=FRAG_SAFE)
  284. # The parsed ASCII bytestrings are our canonical form.
  285. # All properties of the URL are derived from these.
  286. return ParseResult(
  287. parsed_scheme,
  288. parsed_userinfo,
  289. parsed_host,
  290. parsed_port,
  291. parsed_path,
  292. parsed_query,
  293. parsed_frag,
  294. )
  295. def encode_host(host: str) -> str:
  296. if not host:
  297. return ""
  298. elif IPv4_STYLE_HOSTNAME.match(host):
  299. # Validate IPv4 hostnames like #.#.#.#
  300. #
  301. # From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
  302. #
  303. # IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
  304. try:
  305. ipaddress.IPv4Address(host)
  306. except ipaddress.AddressValueError:
  307. raise InvalidURL(f"Invalid IPv4 address: {host!r}")
  308. return host
  309. elif IPv6_STYLE_HOSTNAME.match(host):
  310. # Validate IPv6 hostnames like [...]
  311. #
  312. # From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
  313. #
  314. # "A host identified by an Internet Protocol literal address, version 6
  315. # [RFC3513] or later, is distinguished by enclosing the IP literal
  316. # within square brackets ("[" and "]"). This is the only place where
  317. # square bracket characters are allowed in the URI syntax."
  318. try:
  319. ipaddress.IPv6Address(host[1:-1])
  320. except ipaddress.AddressValueError:
  321. raise InvalidURL(f"Invalid IPv6 address: {host!r}")
  322. return host[1:-1]
  323. elif host.isascii():
  324. # Regular ASCII hostnames
  325. #
  326. # From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
  327. #
  328. # reg-name = *( unreserved / pct-encoded / sub-delims )
  329. WHATWG_SAFE = '"`{}%|\\'
  330. return quote(host.lower(), safe=SUB_DELIMS + WHATWG_SAFE)
  331. # IDNA hostnames
  332. try:
  333. return idna.encode(host.lower()).decode("ascii")
  334. except idna.IDNAError:
  335. raise InvalidURL(f"Invalid IDNA hostname: {host!r}")
  336. def normalize_port(port: str | int | None, scheme: str) -> int | None:
  337. # From https://tools.ietf.org/html/rfc3986#section-3.2.3
  338. #
  339. # "A scheme may define a default port. For example, the "http" scheme
  340. # defines a default port of "80", corresponding to its reserved TCP
  341. # port number. The type of port designated by the port number (e.g.,
  342. # TCP, UDP, SCTP) is defined by the URI scheme. URI producers and
  343. # normalizers should omit the port component and its ":" delimiter if
  344. # port is empty or if its value would be the same as that of the
  345. # scheme's default."
  346. if port is None or port == "":
  347. return None
  348. try:
  349. port_as_int = int(port)
  350. except ValueError:
  351. raise InvalidURL(f"Invalid port: {port!r}")
  352. # See https://url.spec.whatwg.org/#url-miscellaneous
  353. default_port = {"ftp": 21, "http": 80, "https": 443, "ws": 80, "wss": 443}.get(
  354. scheme
  355. )
  356. if port_as_int == default_port:
  357. return None
  358. return port_as_int
  359. def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
  360. """
  361. Path validation rules that depend on if the URL contains
  362. a scheme or authority component.
  363. See https://datatracker.ietf.org/doc/html/rfc3986.html#section-3.3
  364. """
  365. if has_authority:
  366. # If a URI contains an authority component, then the path component
  367. # must either be empty or begin with a slash ("/") character."
  368. if path and not path.startswith("/"):
  369. raise InvalidURL("For absolute URLs, path must be empty or begin with '/'")
  370. if not has_scheme and not has_authority:
  371. # If a URI does not contain an authority component, then the path cannot begin
  372. # with two slash characters ("//").
  373. if path.startswith("//"):
  374. raise InvalidURL("Relative URLs cannot have a path starting with '//'")
  375. # In addition, a URI reference (Section 4.1) may be a relative-path reference,
  376. # in which case the first path segment cannot contain a colon (":") character.
  377. if path.startswith(":"):
  378. raise InvalidURL("Relative URLs cannot have a path starting with ':'")
  379. def normalize_path(path: str) -> str:
  380. """
  381. Drop "." and ".." segments from a URL path.
  382. For example:
  383. normalize_path("/path/./to/somewhere/..") == "/path/to"
  384. """
  385. # Fast return when no '.' characters in the path.
  386. if "." not in path:
  387. return path
  388. components = path.split("/")
  389. # Fast return when no '.' or '..' components in the path.
  390. if "." not in components and ".." not in components:
  391. return path
  392. # https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
  393. output: list[str] = []
  394. for component in components:
  395. if component == ".":
  396. pass
  397. elif component == "..":
  398. if output and output != [""]:
  399. output.pop()
  400. else:
  401. output.append(component)
  402. return "/".join(output)
  403. def PERCENT(string: str) -> str:
  404. return "".join([f"%{byte:02X}" for byte in string.encode("utf-8")])
  405. def percent_encoded(string: str, safe: str) -> str:
  406. """
  407. Use percent-encoding to quote a string.
  408. """
  409. NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
  410. # Fast path for strings that don't need escaping.
  411. if not string.rstrip(NON_ESCAPED_CHARS):
  412. return string
  413. return "".join(
  414. [char if char in NON_ESCAPED_CHARS else PERCENT(char) for char in string]
  415. )
  416. def quote(string: str, safe: str) -> str:
  417. """
  418. Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
  419. See: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
  420. * `string`: The string to be percent-escaped.
  421. * `safe`: A string containing characters that may be treated as safe, and do not
  422. need to be escaped. Unreserved characters are always treated as safe.
  423. See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
  424. """
  425. parts = []
  426. current_position = 0
  427. for match in re.finditer(PERCENT_ENCODED_REGEX, string):
  428. start_position, end_position = match.start(), match.end()
  429. matched_text = match.group(0)
  430. # Add any text up to the '%xx' escape sequence.
  431. if start_position != current_position:
  432. leading_text = string[current_position:start_position]
  433. parts.append(percent_encoded(leading_text, safe=safe))
  434. # Add the '%xx' escape sequence.
  435. parts.append(matched_text)
  436. current_position = end_position
  437. # Add any text after the final '%xx' escape sequence.
  438. if current_position != len(string):
  439. trailing_text = string[current_position:]
  440. parts.append(percent_encoded(trailing_text, safe=safe))
  441. return "".join(parts)