mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
94 lines
4.1 KiB
Python
94 lines
4.1 KiB
Python
"""Pure helper functions for OIDC routes.
|
|
|
|
Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
|
|
and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
|
|
than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
|
|
allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
|
|
reachable on the public internet, so a private address there is an SSRF probe
|
|
rather than a configuration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
from urllib.parse import urlparse
|
|
|
|
from backend.app.api.routes._url_safety import (
|
|
CLOUD_METADATA_HOSTNAMES,
|
|
CLOUD_METADATA_IPS,
|
|
NUMERIC_IP_RE,
|
|
unwrap_ipv4_mapped,
|
|
)
|
|
|
|
|
|
def assert_safe_public_https_url(url: str) -> None:
|
|
"""Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
|
|
|
|
Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
|
|
than the LAN-service SSRF guard: also rejects loopback, private
|
|
(RFC-1918), and link-local addresses because an IdP and its icon
|
|
legitimately live only on the public internet.
|
|
|
|
Checks performed:
|
|
- Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
|
|
- Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) is
|
|
rejected — libc and browsers parse those as valid addresses while
|
|
Python's ``ipaddress`` raises ValueError, so they bypass the IP block
|
|
below if not caught first.
|
|
- Cloud-provider metadata endpoints (169.254.169.254, 100.100.100.200,
|
|
fd00:ec2::254) — classic SSRF credential-exfil targets.
|
|
- Loopback (127.0.0.0/8, ::1), private RFC-1918 (10/8, 172.16/12,
|
|
192.168/16) and link-local (169.254/16, fe80::/10) addresses.
|
|
- Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::).
|
|
- IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
|
|
check so an attacker can't bypass via IPv6 encoding.
|
|
|
|
Hostname-based addresses are otherwise accepted without DNS resolution —
|
|
the operator is trusted to configure a sensible IdP host, and resolving
|
|
here would both add a TOCTOU gap (DNS can change between validation and
|
|
request) and make the validator issue network requests of its own. The
|
|
fixed cloud-metadata hostnames are the exception: matching them is a
|
|
literal string comparison, not a resolution.
|
|
"""
|
|
parsed = urlparse(url)
|
|
if parsed.scheme.lower() != "https":
|
|
raise ValueError("icon URL must use https://")
|
|
|
|
hostname = (parsed.hostname or "").lower()
|
|
|
|
# "https:///path" parses to an empty hostname; without this it reaches the
|
|
# ip_address() ValueError branch and is accepted as a symbolic hostname.
|
|
if not hostname:
|
|
raise ValueError("icon URL must include a hostname")
|
|
|
|
if hostname in CLOUD_METADATA_HOSTNAMES:
|
|
raise ValueError("icon URL must not point to a cloud metadata endpoint")
|
|
|
|
if NUMERIC_IP_RE.match(hostname):
|
|
raise ValueError("icon URL must not use numeric-encoded IP addresses")
|
|
|
|
try:
|
|
addr = ipaddress.ip_address(hostname)
|
|
except ValueError:
|
|
return # hostname — out of scope (no DNS check by design)
|
|
|
|
effective = unwrap_ipv4_mapped(addr)
|
|
|
|
if effective in CLOUD_METADATA_IPS:
|
|
raise ValueError("icon URL must not point to a cloud metadata endpoint")
|
|
|
|
# Order matters: 0.0.0.0 sets BOTH is_private and is_unspecified — check
|
|
# the more-specific is_unspecified first so the error message points at
|
|
# the actual misuse. Similarly 127.0.0.1 sets is_loopback and is_private
|
|
# (private under IANA's reservation); is_loopback first is clearer.
|
|
if effective.is_unspecified:
|
|
raise ValueError("icon URL must not point to an unspecified address")
|
|
if effective.is_loopback:
|
|
raise ValueError("icon URL must not point to a loopback address")
|
|
if effective.is_link_local:
|
|
raise ValueError("icon URL must not point to a link-local address")
|
|
if effective.is_multicast:
|
|
raise ValueError("icon URL must not point to a multicast address")
|
|
if effective.is_private:
|
|
raise ValueError("icon URL must not point to a private (RFC-1918) address")
|