Files
bambuddy/backend/tests/unit/test_oidc_icon_validation.py
Sn0rrii 8a7598f6b5 feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333)

Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
2026-05-15 08:50:37 +02:00

115 lines
4.0 KiB
Python

"""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"))