| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114 |
- """Unit tests for the icon_url Pydantic validator (#1333).
- Mirrors the issuer_url validator pattern: HTTPS-only + private/loopback/
- link-local IP literals rejected. Hostname-based URLs are accepted without
- DNS resolution (deliberate, see _validate_issuer_url).
- """
- import pytest
- from backend.app.schemas.auth import OIDCProviderCreate
- def _make_payload(icon_url: str | None) -> dict:
- """Minimal valid OIDCProviderCreate payload with the given icon_url."""
- return {
- "name": "Test",
- "issuer_url": "https://idp.example.com",
- "client_id": "client",
- "client_secret": "secret",
- "icon_url": icon_url,
- }
- def test_icon_url_none_accepted():
- OIDCProviderCreate(**_make_payload(None))
- def test_icon_url_valid_https_accepted():
- OIDCProviderCreate(**_make_payload("https://example.com/icon.png"))
- def test_icon_url_http_rejected():
- with pytest.raises(ValueError, match="icon_url must start with https"):
- OIDCProviderCreate(**_make_payload("http://example.com/icon.png"))
- def test_icon_url_empty_string_rejected():
- # Pydantic doesn't coerce "" to None — the validator runs on the raw value.
- with pytest.raises(ValueError, match="icon_url must start with https"):
- OIDCProviderCreate(**_make_payload(""))
- @pytest.mark.parametrize(
- "url",
- [
- "https://192.168.1.1/icon.png",
- "https://10.0.0.5/icon.png",
- "https://172.16.0.1/icon.png",
- ],
- )
- def test_icon_url_private_ip_rejected(url):
- with pytest.raises(ValueError, match="private"):
- OIDCProviderCreate(**_make_payload(url))
- def test_icon_url_loopback_ip_rejected():
- with pytest.raises(ValueError, match="loopback"):
- OIDCProviderCreate(**_make_payload("https://127.0.0.1/icon.png"))
- def test_icon_url_link_local_or_cloud_metadata_rejected():
- # 169.254.169.254 is BOTH link-local AND a cloud-metadata IP — the guard
- # checks cloud-metadata first (per intentional ordering), so either
- # rejection message is correct. Mirrors the same pattern in
- # test_oidc_icon_helpers.test_rejects_link_local.
- with pytest.raises(ValueError, match="cloud metadata|link-local"):
- OIDCProviderCreate(**_make_payload("https://169.254.169.254/icon.png"))
- def test_icon_url_hostname_accepted_no_dns():
- # "localhost" is a hostname, not a bare IP — DNS resolution is deliberately
- # not performed here (matches _validate_issuer_url policy). The runtime
- # SSRF guard (assert_safe_public_https_url) handles the bare-IP cases
- # again; hostnames are caught only by the IDP-itself-misconfigured path.
- OIDCProviderCreate(**_make_payload("https://idp.internal.corp/icon.png"))
- # ─── I1: schema now delegates to runtime SSRF guard ──────────────────────
- # Verifies that the wider allowlist (numeric IPs, cloud-meta, multicast,
- # IPv4-mapped IPv6) is enforced at Pydantic-parse-time too, not just at
- # fetch time.
- @pytest.mark.parametrize(
- "url",
- [
- "https://2130706433/icon.png", # decimal-encoded 127.0.0.1
- "https://0x7f000001/icon.png", # hex-encoded 127.0.0.1
- ],
- )
- def test_icon_url_numeric_encoded_ip_rejected(url):
- with pytest.raises(ValueError, match="numeric-encoded"):
- OIDCProviderCreate(**_make_payload(url))
- def test_icon_url_cloud_metadata_alibaba_rejected():
- with pytest.raises(ValueError, match="cloud metadata"):
- OIDCProviderCreate(**_make_payload("https://100.100.100.200/icon.png"))
- def test_icon_url_unspecified_rejected():
- with pytest.raises(ValueError, match="unspecified"):
- OIDCProviderCreate(**_make_payload("https://0.0.0.0/icon.png"))
- def test_icon_url_multicast_rejected():
- with pytest.raises(ValueError, match="multicast"):
- OIDCProviderCreate(**_make_payload("https://224.0.0.1/icon.png"))
- def test_icon_url_ipv4_mapped_ipv6_private_rejected():
- # ::ffff:192.168.1.1 unwraps to 192.168.1.1 → private
- with pytest.raises(ValueError, match="private"):
- OIDCProviderCreate(**_make_payload("https://[::ffff:192.168.1.1]/icon.png"))
|