test_oidc_icon_validation.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. """Unit tests for the icon_url Pydantic validator (#1333).
  2. Mirrors the issuer_url validator pattern: HTTPS-only + private/loopback/
  3. link-local IP literals rejected. Hostname-based URLs are accepted without
  4. DNS resolution (deliberate, see _validate_issuer_url).
  5. """
  6. import pytest
  7. from backend.app.schemas.auth import OIDCProviderCreate
  8. def _make_payload(icon_url: str | None) -> dict:
  9. """Minimal valid OIDCProviderCreate payload with the given icon_url."""
  10. return {
  11. "name": "Test",
  12. "issuer_url": "https://idp.example.com",
  13. "client_id": "client",
  14. "client_secret": "secret",
  15. "icon_url": icon_url,
  16. }
  17. def test_icon_url_none_accepted():
  18. OIDCProviderCreate(**_make_payload(None))
  19. def test_icon_url_valid_https_accepted():
  20. OIDCProviderCreate(**_make_payload("https://example.com/icon.png"))
  21. def test_icon_url_http_rejected():
  22. with pytest.raises(ValueError, match="icon_url must start with https"):
  23. OIDCProviderCreate(**_make_payload("http://example.com/icon.png"))
  24. def test_icon_url_empty_string_rejected():
  25. # Pydantic doesn't coerce "" to None — the validator runs on the raw value.
  26. with pytest.raises(ValueError, match="icon_url must start with https"):
  27. OIDCProviderCreate(**_make_payload(""))
  28. @pytest.mark.parametrize(
  29. "url",
  30. [
  31. "https://192.168.1.1/icon.png",
  32. "https://10.0.0.5/icon.png",
  33. "https://172.16.0.1/icon.png",
  34. ],
  35. )
  36. def test_icon_url_private_ip_rejected(url):
  37. with pytest.raises(ValueError, match="private"):
  38. OIDCProviderCreate(**_make_payload(url))
  39. def test_icon_url_loopback_ip_rejected():
  40. with pytest.raises(ValueError, match="loopback"):
  41. OIDCProviderCreate(**_make_payload("https://127.0.0.1/icon.png"))
  42. def test_icon_url_link_local_or_cloud_metadata_rejected():
  43. # 169.254.169.254 is BOTH link-local AND a cloud-metadata IP — the guard
  44. # checks cloud-metadata first (per intentional ordering), so either
  45. # rejection message is correct. Mirrors the same pattern in
  46. # test_oidc_icon_helpers.test_rejects_link_local.
  47. with pytest.raises(ValueError, match="cloud metadata|link-local"):
  48. OIDCProviderCreate(**_make_payload("https://169.254.169.254/icon.png"))
  49. def test_icon_url_hostname_accepted_no_dns():
  50. # "localhost" is a hostname, not a bare IP — DNS resolution is deliberately
  51. # not performed here (matches _validate_issuer_url policy). The runtime
  52. # SSRF guard (assert_safe_public_https_url) handles the bare-IP cases
  53. # again; hostnames are caught only by the IDP-itself-misconfigured path.
  54. OIDCProviderCreate(**_make_payload("https://idp.internal.corp/icon.png"))
  55. # ─── I1: schema now delegates to runtime SSRF guard ──────────────────────
  56. # Verifies that the wider allowlist (numeric IPs, cloud-meta, multicast,
  57. # IPv4-mapped IPv6) is enforced at Pydantic-parse-time too, not just at
  58. # fetch time.
  59. @pytest.mark.parametrize(
  60. "url",
  61. [
  62. "https://2130706433/icon.png", # decimal-encoded 127.0.0.1
  63. "https://0x7f000001/icon.png", # hex-encoded 127.0.0.1
  64. ],
  65. )
  66. def test_icon_url_numeric_encoded_ip_rejected(url):
  67. with pytest.raises(ValueError, match="numeric-encoded"):
  68. OIDCProviderCreate(**_make_payload(url))
  69. def test_icon_url_cloud_metadata_alibaba_rejected():
  70. with pytest.raises(ValueError, match="cloud metadata"):
  71. OIDCProviderCreate(**_make_payload("https://100.100.100.200/icon.png"))
  72. def test_icon_url_unspecified_rejected():
  73. with pytest.raises(ValueError, match="unspecified"):
  74. OIDCProviderCreate(**_make_payload("https://0.0.0.0/icon.png"))
  75. def test_icon_url_multicast_rejected():
  76. with pytest.raises(ValueError, match="multicast"):
  77. OIDCProviderCreate(**_make_payload("https://224.0.0.1/icon.png"))
  78. def test_icon_url_ipv4_mapped_ipv6_private_rejected():
  79. # ::ffff:192.168.1.1 unwraps to 192.168.1.1 → private
  80. with pytest.raises(ValueError, match="private"):
  81. OIDCProviderCreate(**_make_payload("https://[::ffff:192.168.1.1]/icon.png"))