_url_safety.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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.
  25. CLOUD_METADATA_IPS = frozenset(
  26. {
  27. # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
  28. ipaddress.ip_address("169.254.169.254"),
  29. # Alibaba Cloud metadata
  30. ipaddress.ip_address("100.100.100.200"),
  31. # AWS IMDS IPv6
  32. ipaddress.ip_address("fd00:ec2::254"),
  33. }
  34. )
  35. # The DNS-name form of the same targets. Neither guard resolves hostnames (see
  36. # the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
  37. # literal-string match needs no resolution and costs nothing. These names only
  38. # resolve inside the respective cloud, so there is no legitimate reason for any
  39. # Bambuddy integration to point at one.
  40. CLOUD_METADATA_HOSTNAMES = frozenset(
  41. {
  42. "metadata.google.internal", # GCP
  43. "metadata.goog", # GCP short form
  44. }
  45. )
  46. # libc and browsers parse numeric-encoded IP forms (decimal ``2130706433`` for
  47. # 127.0.0.1, hex ``0x7f000001``, dotted hex or octal, short forms such as
  48. # ``127.1``) but Python's ``ipaddress`` raises ValueError on them, so they slip
  49. # past the IP-class checks if not caught first. Used by both guards to reject
  50. # them up front, see ``is_numeric_encoded_ip``. One label of such an address
  51. # is decimal, octal or hex.
  52. _NUMERIC_LABEL_RE = re.compile(r"^(0x[0-9a-f]*|[0-9]+)$", re.I)
  53. def url_hostname(parsed: ParseResult) -> str:
  54. """The URL's hostname as resolvers see it: lower case, no trailing dot."""
  55. return (parsed.hostname or "").lower().rstrip(".")
  56. def is_numeric_encoded_ip(hostname: str) -> bool:
  57. """Whether *hostname* is an IPv4 address in a notation other than plain
  58. dotted decimal: one number, dotted hex or octal, a leading zero, or fewer
  59. than four parts. Resolvers read all of them as an address; the checks on
  60. addresses only see standard notation.
  61. """
  62. labels = hostname.split(".")
  63. if not all(_NUMERIC_LABEL_RE.match(label) for label in labels):
  64. return False
  65. try:
  66. ipaddress.IPv4Address(hostname)
  67. except ValueError:
  68. return True
  69. return False
  70. def unwrap_ipv4_mapped(
  71. addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
  72. ) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
  73. """Return the underlying IPv4 for an IPv4-mapped IPv6 address, *addr* without
  74. its zone id, else *addr*.
  75. ``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
  76. the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
  77. an attacker can encode a blocked IPv4 address as an IPv6 literal to
  78. bypass the guard.
  79. """
  80. if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
  81. return addr.ipv4_mapped
  82. # A zone id is ignored on connect to an address that is not link-local,
  83. # but it makes the address compare unequal to the same one without it.
  84. if isinstance(addr, ipaddress.IPv6Address) and addr.scope_id is not None:
  85. return ipaddress.IPv6Address(str(addr).split("%", 1)[0])
  86. return addr
  87. def assert_safe_lan_service_url(url: str, *, label: str) -> None:
  88. """Raise ValueError if *url* is unsafe for a service that may live on the LAN.
  89. ``label`` names the setting in the error message ("Spoolman URL", "ntfy
  90. server URL", …) so the user sees which field they need to correct.
  91. Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
  92. **permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
  93. Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
  94. same host or home LAN is THE normal topology, not an attack. A blanket
  95. private-address block would break those integrations for most installs.
  96. What is rejected is dangerous under any topology:
  97. - Schemes other than http/https. ``httpx`` already raises
  98. ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
  99. about returning a clear validation error at configuration time rather
  100. than an opaque failure at delivery time.
  101. - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
  102. libc and browsers resolve these, but Python's ``ipaddress`` raises
  103. ValueError on them, so they would slip past the checks below.
  104. - Cloud-provider metadata endpoints — the high-value SSRF target, and
  105. never a legitimate destination for any of these services.
  106. - Multicast and unspecified addresses — pointless as a destination and
  107. indicative of misuse.
  108. - IPv4-mapped IPv6 encodings of any of the above.
  109. Symbolic hostnames are otherwise accepted without DNS resolution, matching
  110. the public-internet guard: resolution here would be both a TOCTOU (DNS can
  111. change between validation and request) and a request the validator
  112. shouldn't be making. The one exception is the fixed set of cloud-metadata
  113. hostnames, which is a literal-string match and needs no resolution.
  114. """
  115. parsed = urlparse(url)
  116. if parsed.scheme.lower() not in ("http", "https"):
  117. raise ValueError(f"{label} must use http or https")
  118. hostname = url_hostname(parsed)
  119. # "http:///path" parses to an empty hostname. Never a valid destination,
  120. # and without this it falls through the ip_address() ValueError branch
  121. # below and is accepted as if it were a symbolic hostname.
  122. if not hostname:
  123. raise ValueError(f"{label} must include a hostname")
  124. # HTTP clients map other dot characters, fullwidth digits and similar
  125. # forms to plain ASCII before they connect, so a host the checks below
  126. # cannot read is refused rather than let through as a name.
  127. if not hostname.isascii():
  128. raise ValueError(f"{label} must use an ASCII host name (punycode, xn--..., for international names)")
  129. if hostname in CLOUD_METADATA_HOSTNAMES:
  130. raise ValueError(f"{label} must not point to a cloud metadata endpoint")
  131. if is_numeric_encoded_ip(hostname):
  132. raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
  133. try:
  134. addr = ipaddress.ip_address(hostname)
  135. except ValueError:
  136. return # symbolic hostname — out of scope by design (no DNS check)
  137. effective = unwrap_ipv4_mapped(addr)
  138. if effective in CLOUD_METADATA_IPS:
  139. raise ValueError(f"{label} must not point to a cloud metadata endpoint")
  140. if effective.is_multicast or effective.is_unspecified:
  141. raise ValueError(f"{label} must not point to a multicast or unspecified address")