test_oidc_icon_helpers.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. """Unit tests for assert_safe_public_https_url (#1333).
  2. Stricter than the Spoolman SSRF guard — explicitly verifies that Spoolman-
  3. allowed cases (loopback, RFC-1918, hostname "localhost") are REJECTED here.
  4. Run alongside test_ssrf_guard.py to confirm both guards keep their distinct
  5. semantics.
  6. """
  7. import pytest
  8. from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
  9. # ─── Accepts ────────────────────────────────────────────────────────────────
  10. def test_accepts_public_https():
  11. assert_safe_public_https_url("https://accounts.google.com/icon.png")
  12. def test_accepts_hostname_no_dns_resolution():
  13. # By design — DNS resolution is intentionally not performed (consistent
  14. # with _validate_issuer_url policy). Hostnames are out of scope here.
  15. assert_safe_public_https_url("https://idp.internal.corp/icon.png")
  16. # ─── Rejects: non-HTTPS schemes ─────────────────────────────────────────────
  17. @pytest.mark.parametrize(
  18. "url",
  19. [
  20. "http://example.com/icon.png",
  21. "ftp://example.com/icon.png",
  22. "file:///etc/passwd",
  23. "gopher://example.com",
  24. ],
  25. )
  26. def test_rejects_non_https(url):
  27. with pytest.raises(ValueError, match="https"):
  28. assert_safe_public_https_url(url)
  29. # ─── Rejects: numeric-encoded IP addresses ──────────────────────────────────
  30. @pytest.mark.parametrize(
  31. "url",
  32. [
  33. "https://2130706433/icon.png", # decimal-encoded 127.0.0.1
  34. "https://0x7f000001/icon.png", # hex-encoded 127.0.0.1
  35. ],
  36. )
  37. def test_rejects_numeric_encoded_ip(url):
  38. with pytest.raises(ValueError, match="numeric-encoded"):
  39. assert_safe_public_https_url(url)
  40. # ─── Rejects: cases that Spoolman INTENTIONALLY allows ──────────────────────
  41. # These tests are deliberately structured to mirror test_ssrf_guard.py — every
  42. # URL here is a green case for the Spoolman guard and must be a red case here.
  43. def test_rejects_loopback_127():
  44. with pytest.raises(ValueError, match="loopback"):
  45. assert_safe_public_https_url("https://127.0.0.1/icon.png")
  46. @pytest.mark.parametrize(
  47. "url",
  48. [
  49. "https://192.168.1.50/icon.png",
  50. "https://10.0.0.5/icon.png",
  51. "https://172.16.0.1/icon.png",
  52. ],
  53. )
  54. def test_rejects_rfc1918_private(url):
  55. with pytest.raises(ValueError, match="private"):
  56. assert_safe_public_https_url(url)
  57. # ─── Rejects: link-local, cloud-metadata, multicast, unspecified ────────────
  58. def test_rejects_link_local():
  59. with pytest.raises(ValueError, match="link-local|cloud metadata"):
  60. # 169.254.169.254 is BOTH link-local AND cloud-metadata — both
  61. # rejections are correct; we accept either message.
  62. assert_safe_public_https_url("https://169.254.169.254/icon.png")
  63. def test_rejects_cloud_metadata_alibaba():
  64. with pytest.raises(ValueError, match="cloud metadata"):
  65. assert_safe_public_https_url("https://100.100.100.200/icon.png")
  66. def test_rejects_multicast():
  67. with pytest.raises(ValueError, match="multicast"):
  68. assert_safe_public_https_url("https://224.0.0.1/icon.png")
  69. def test_rejects_unspecified():
  70. with pytest.raises(ValueError, match="unspecified"):
  71. assert_safe_public_https_url("https://0.0.0.0/icon.png")
  72. # ─── IPv6 ──────────────────────────────────────────────────────────────────
  73. def test_rejects_ipv4_mapped_private():
  74. # ::ffff:192.168.1.1 unwraps to 192.168.1.1 → private
  75. with pytest.raises(ValueError, match="private"):
  76. assert_safe_public_https_url("https://[::ffff:192.168.1.1]/icon.png")
  77. def test_rejects_ipv4_mapped_cloud_metadata():
  78. with pytest.raises(ValueError, match="cloud metadata|link-local"):
  79. # ::ffff:169.254.169.254 unwraps and triggers cloud-metadata block
  80. # (the cloud-metadata frozenset is checked first; link-local catches
  81. # 169.254/16 if it slips through, hence the regex alternation).
  82. assert_safe_public_https_url("https://[::ffff:169.254.169.254]/icon.png")
  83. def test_rejects_ipv6_loopback():
  84. with pytest.raises(ValueError, match="loopback"):
  85. assert_safe_public_https_url("https://[::1]/icon.png")