_oidc_helpers.py 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. """Pure helper functions for OIDC routes.
  2. Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
  3. ``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
  4. loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
  5. the public internet (IdP-hosted), so private addresses there are SSRF probes.
  6. """
  7. from __future__ import annotations
  8. import ipaddress
  9. from urllib.parse import urlparse
  10. from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
  11. def assert_safe_public_https_url(url: str) -> None:
  12. """Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
  13. Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
  14. guard: also rejects loopback, private (RFC-1918), and link-local addresses
  15. because an OIDC icon legitimately lives only on the public internet.
  16. Checks performed:
  17. - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
  18. - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) is
  19. rejected — libc and browsers parse those as valid addresses while
  20. Python's ``ipaddress`` raises ValueError, so they bypass the IP block
  21. below if not caught first.
  22. - Cloud-provider metadata endpoints (169.254.169.254, 100.100.100.200,
  23. fd00:ec2::254) — classic SSRF credential-exfil targets.
  24. - Loopback (127.0.0.0/8, ::1), private RFC-1918 (10/8, 172.16/12,
  25. 192.168/16) and link-local (169.254/16, fe80::/10) addresses.
  26. - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::).
  27. - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
  28. check so an attacker can't bypass via IPv6 encoding.
  29. Hostname-based addresses are accepted without DNS resolution (consistent
  30. with ``_validate_issuer_url`` policy — the operator is trusted to
  31. configure a sensible IdP host).
  32. """
  33. parsed = urlparse(url)
  34. if parsed.scheme.lower() != "https":
  35. raise ValueError("icon URL must use https://")
  36. hostname = (parsed.hostname or "").lower()
  37. if NUMERIC_IP_RE.match(hostname):
  38. raise ValueError("icon URL must not use numeric-encoded IP addresses")
  39. try:
  40. addr = ipaddress.ip_address(hostname)
  41. except ValueError:
  42. return # hostname — out of scope (no DNS check by design)
  43. effective = unwrap_ipv4_mapped(addr)
  44. if effective in CLOUD_METADATA_IPS:
  45. raise ValueError("icon URL must not point to a cloud metadata endpoint")
  46. # Order matters: 0.0.0.0 sets BOTH is_private and is_unspecified — check
  47. # the more-specific is_unspecified first so the error message points at
  48. # the actual misuse. Similarly 127.0.0.1 sets is_loopback and is_private
  49. # (private under IANA's reservation); is_loopback first is clearer.
  50. if effective.is_unspecified:
  51. raise ValueError("icon URL must not point to an unspecified address")
  52. if effective.is_loopback:
  53. raise ValueError("icon URL must not point to a loopback address")
  54. if effective.is_link_local:
  55. raise ValueError("icon URL must not point to a link-local address")
  56. if effective.is_multicast:
  57. raise ValueError("icon URL must not point to a multicast address")
  58. if effective.is_private:
  59. raise ValueError("icon URL must not point to a private (RFC-1918) address")