_oidc_helpers.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. """Pure helper functions for OIDC routes.
  2. Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
  3. and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
  4. than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
  5. allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
  6. reachable on the public internet, so a private address there is an SSRF probe
  7. rather than a configuration.
  8. """
  9. from __future__ import annotations
  10. import ipaddress
  11. from urllib.parse import urlparse
  12. from backend.app.api.routes._url_safety import (
  13. CLOUD_METADATA_HOSTNAMES,
  14. CLOUD_METADATA_IPS,
  15. is_numeric_encoded_ip,
  16. unwrap_ipv4_mapped,
  17. url_hostname,
  18. )
  19. def assert_safe_public_https_url(url: str) -> None:
  20. """Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
  21. Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
  22. than the LAN-service SSRF guard: also rejects loopback, private
  23. (RFC-1918), and link-local addresses because an IdP and its icon
  24. legitimately live only on the public internet.
  25. Checks performed:
  26. - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
  27. - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) is
  28. rejected — libc and browsers parse those as valid addresses while
  29. Python's ``ipaddress`` raises ValueError, so they bypass the IP block
  30. below if not caught first.
  31. - Cloud-provider metadata endpoints (169.254.169.254, 100.100.100.200,
  32. fd00:ec2::254) — classic SSRF credential-exfil targets.
  33. - Loopback (127.0.0.0/8, ::1), private RFC-1918 (10/8, 172.16/12,
  34. 192.168/16) and link-local (169.254/16, fe80::/10) addresses.
  35. - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::).
  36. - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
  37. check so an attacker can't bypass via IPv6 encoding.
  38. Hostname-based addresses are otherwise accepted without DNS resolution —
  39. the operator is trusted to configure a sensible IdP host, and resolving
  40. here would both add a TOCTOU gap (DNS can change between validation and
  41. request) and make the validator issue network requests of its own. The
  42. fixed cloud-metadata hostnames are the exception: matching them is a
  43. literal string comparison, not a resolution.
  44. """
  45. parsed = urlparse(url)
  46. if parsed.scheme.lower() != "https":
  47. raise ValueError("icon URL must use https://")
  48. hostname = url_hostname(parsed)
  49. # "https:///path" parses to an empty hostname; without this it reaches the
  50. # ip_address() ValueError branch and is accepted as a symbolic hostname.
  51. if not hostname:
  52. raise ValueError("icon URL must include a hostname")
  53. # HTTP clients map other dot characters and fullwidth forms to ASCII
  54. # before they connect: a host the checks below cannot read is refused.
  55. if not hostname.isascii():
  56. raise ValueError("icon URL must use an ASCII host name (punycode, xn--..., for international names)")
  57. if hostname in CLOUD_METADATA_HOSTNAMES:
  58. raise ValueError("icon URL must not point to a cloud metadata endpoint")
  59. if is_numeric_encoded_ip(hostname):
  60. raise ValueError("icon URL must not use numeric-encoded IP addresses")
  61. try:
  62. addr = ipaddress.ip_address(hostname)
  63. except ValueError:
  64. return # hostname — out of scope (no DNS check by design)
  65. effective = unwrap_ipv4_mapped(addr)
  66. if effective in CLOUD_METADATA_IPS:
  67. raise ValueError("icon URL must not point to a cloud metadata endpoint")
  68. # Order matters: 0.0.0.0 sets BOTH is_private and is_unspecified — check
  69. # the more-specific is_unspecified first so the error message points at
  70. # the actual misuse. Similarly 127.0.0.1 sets is_loopback and is_private
  71. # (private under IANA's reservation); is_loopback first is clearer.
  72. if effective.is_unspecified:
  73. raise ValueError("icon URL must not point to an unspecified address")
  74. if effective.is_loopback:
  75. raise ValueError("icon URL must not point to a loopback address")
  76. if effective.is_link_local:
  77. raise ValueError("icon URL must not point to a link-local address")
  78. if effective.is_multicast:
  79. raise ValueError("icon URL must not point to a multicast address")
  80. if effective.is_private:
  81. raise ValueError("icon URL must not point to a private (RFC-1918) address")