mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
* 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.
115 lines
4.0 KiB
Python
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"))
|