_url_safety.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. """Shared URL-safety primitives for the SSRF guards in this package.
  2. Bambuddy has exactly two outbound-URL policies, and which one applies is a
  3. property of the *service*, not of the caller:
  4. - **LAN-service** (``assert_safe_lan_service_url`` below) — the service
  5. legitimately lives on the same host or home LAN, so loopback and RFC-1918
  6. must be permitted; blocking them would break the normal topology. Used for
  7. Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
  8. webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
  9. - **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
  10. resource can only sensibly live on the public internet, so a private
  11. address is an SSRF probe rather than a configuration. Used for OIDC issuer
  12. and icon URLs.
  13. Both reject the cases that are dangerous regardless of topology: non-HTTP
  14. schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
  15. unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
  16. The LAN-service policy lives here because it now has several callers; the
  17. public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
  18. """
  19. from __future__ import annotations
  20. import ipaddress
  21. import re
  22. from urllib.parse import ParseResult, urlparse
  23. # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
  24. # targets. Both guards reject these unconditionally. The documented endpoints
  25. # of the major providers, so best effort by nature. Those inside
  26. # 169.254.0.0/16 are also covered by the LAN guard's link-local refusal;
  27. # 100.100.100.200 and the IPv6 ones are refused only through this list.
  28. CLOUD_METADATA_IPS = frozenset(
  29. {
  30. # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
  31. ipaddress.ip_address("169.254.169.254"),
  32. # Alibaba Cloud metadata
  33. ipaddress.ip_address("100.100.100.200"),
  34. # AWS IMDS IPv6
  35. ipaddress.ip_address("fd00:ec2::254"),
  36. # Tencent Cloud
  37. ipaddress.ip_address("169.254.0.23"),
  38. # Scaleway
  39. ipaddress.ip_address("169.254.42.42"),
  40. ipaddress.ip_address("fd00:42::42"),
  41. # Oracle Cloud IMDS IPv6
  42. ipaddress.ip_address("fd00:c1::a9fe:a9fe"),
  43. # GCP metadata server on IPv6 VMs
  44. ipaddress.ip_address("fd20:ce::254"),
  45. # Akamai / Linode metadata service IPv6
  46. ipaddress.ip_address("fd00:a9fe:a9fe::1"),
  47. # AWS EKS Pod Identity Agent IPv6 (its IPv4 address is link-local)
  48. ipaddress.ip_address("fd00:ec2::23"),
  49. # OpenStack metadata service on IPv6 networks
  50. ipaddress.ip_address("fe80::a9fe:a9fe"),
  51. }
  52. )
  53. # The DNS-name form of the same targets. Neither guard resolves hostnames (see
  54. # the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
  55. # literal-string match needs no resolution and costs nothing. These names only
  56. # resolve inside the respective cloud, so there is no legitimate reason for any
  57. # Bambuddy integration to point at one.
  58. CLOUD_METADATA_HOSTNAMES = frozenset(
  59. {
  60. "metadata.google.internal", # GCP
  61. "metadata.goog", # GCP short form
  62. "metadata.google", # GCP
  63. # A bare "metadata" is completed to metadata.google.internal by the
  64. # resolver's search domain on a GCP VM.
  65. "metadata",
  66. "instance-data", # AWS, through the search domain
  67. "instance-data.ec2.internal", # AWS
  68. "metadata.tencentyun.com", # Tencent Cloud
  69. "api.metadata.cloud.ibm.com", # IBM Cloud VPC
  70. }
  71. )
  72. # libc and browsers parse numeric-encoded IP forms (decimal ``2130706433`` for
  73. # 127.0.0.1, hex ``0x7f000001``, dotted hex or octal, short forms such as
  74. # ``127.1``) but Python's ``ipaddress`` raises ValueError on them, so they slip
  75. # past the IP-class checks if not caught first. Used by both guards to reject
  76. # them up front, see ``is_numeric_encoded_ip``. One label of such an address
  77. # is decimal, octal or hex.
  78. _NUMERIC_LABEL_RE = re.compile(r"^(0x[0-9a-f]*|[0-9]+)$", re.I)
  79. def url_hostname(parsed: ParseResult) -> str:
  80. """The URL's hostname as resolvers see it: lower case, no trailing dot."""
  81. return (parsed.hostname or "").lower().rstrip(".")
  82. def is_numeric_encoded_ip(hostname: str) -> bool:
  83. """Whether *hostname* is an IPv4 address in a notation other than plain
  84. dotted decimal: one number, dotted hex or octal, a leading zero, or fewer
  85. than four parts. Resolvers read all of them as an address; the checks on
  86. addresses only see standard notation.
  87. """
  88. labels = hostname.split(".")
  89. if not all(_NUMERIC_LABEL_RE.match(label) for label in labels):
  90. return False
  91. try:
  92. ipaddress.IPv4Address(hostname)
  93. except ValueError:
  94. return True
  95. return False
  96. def unwrap_ipv4_mapped(
  97. addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
  98. ) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
  99. """Return the underlying IPv4 for an IPv4-mapped IPv6 address, *addr* without
  100. its zone id, else *addr*.
  101. ``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
  102. the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
  103. an attacker can encode a blocked IPv4 address as an IPv6 literal to
  104. bypass the guard.
  105. """
  106. if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
  107. return addr.ipv4_mapped
  108. # A zone id is ignored on connect to an address that is not link-local,
  109. # but it makes the address compare unequal to the same one without it.
  110. if isinstance(addr, ipaddress.IPv6Address) and addr.scope_id is not None:
  111. return ipaddress.IPv6Address(str(addr).split("%", 1)[0])
  112. return addr
  113. def assert_safe_lan_service_url(url: str, *, label: str) -> None:
  114. """Raise ValueError if *url* is unsafe for a service that may live on the LAN.
  115. ``label`` names the setting in the error message ("Spoolman URL", "ntfy
  116. server URL", …) so the user sees which field they need to correct.
  117. Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
  118. **permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
  119. Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
  120. same host or home LAN is THE normal topology, not an attack. A blanket
  121. private-address block would break those integrations for most installs.
  122. What is rejected is dangerous under any topology:
  123. - Schemes other than http/https. ``httpx`` already raises
  124. ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
  125. about returning a clear validation error at configuration time rather
  126. than an opaque failure at delivery time.
  127. - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``,
  128. dotted hex or octal, short forms) — libc and browsers resolve these,
  129. but Python's ``ipaddress`` raises ValueError on them, so they would
  130. slip past the checks below. Host names not written in ASCII, which
  131. HTTP clients map to plain ones before connecting, likewise.
  132. - Cloud-provider metadata endpoints — the high-value SSRF target, and
  133. never a legitimate destination for any of these services — and the
  134. whole IPv4 link-local range they live in.
  135. - Multicast and unspecified addresses — pointless as a destination and
  136. indicative of misuse.
  137. - IPv4-mapped IPv6 encodings and zone ids of any of the above.
  138. Symbolic hostnames are otherwise accepted without DNS resolution, matching
  139. the public-internet guard: resolution here would be both a TOCTOU (DNS can
  140. change between validation and request) and a request the validator
  141. shouldn't be making. The one exception is the fixed set of cloud-metadata
  142. hostnames, which is a literal-string match and needs no resolution.
  143. """
  144. parsed = urlparse(url)
  145. if parsed.scheme.lower() not in ("http", "https"):
  146. raise ValueError(f"{label} must use http or https")
  147. hostname = url_hostname(parsed)
  148. # "http:///path" parses to an empty hostname. Never a valid destination,
  149. # and without this it falls through the ip_address() ValueError branch
  150. # below and is accepted as if it were a symbolic hostname.
  151. if not hostname:
  152. raise ValueError(f"{label} must include a hostname")
  153. # HTTP clients map other dot characters, fullwidth digits and similar
  154. # forms to plain ASCII before they connect, so a host the checks below
  155. # cannot read is refused rather than let through as a name.
  156. if not hostname.isascii():
  157. raise ValueError(f"{label} must use an ASCII host name (punycode, xn--..., for international names)")
  158. if hostname in CLOUD_METADATA_HOSTNAMES:
  159. raise ValueError(f"{label} must not point to a cloud metadata endpoint")
  160. if is_numeric_encoded_ip(hostname):
  161. raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
  162. try:
  163. addr = ipaddress.ip_address(hostname)
  164. except ValueError:
  165. return # symbolic hostname — out of scope by design (no DNS check)
  166. effective = unwrap_ipv4_mapped(addr)
  167. if effective in CLOUD_METADATA_IPS:
  168. raise ValueError(f"{label} must not point to a cloud metadata endpoint")
  169. if effective.is_multicast or effective.is_unspecified:
  170. raise ValueError(f"{label} must not point to a multicast or unspecified address")
  171. # IPv4 link-local is where cloud providers put their metadata endpoints,
  172. # listed above or not; a LAN service has no reason to live there. IPv6
  173. # link-local and ULA stay reachable, as real LAN devices use them: the
  174. # metadata addresses there are refused one by one through the list.
  175. if isinstance(effective, ipaddress.IPv4Address) and effective.is_link_local:
  176. raise ValueError(f"{label} must not point to a link-local address (169.254.x.x)")