Files
gpt-image-panel/backend/app/core/validators.py
T
Z1rconium 8b686f2a6f feat: add ADMIN_KEY for enhanced security and access management
- Introduced ADMIN_KEY in .env.example and README.md for settings management step-up.
- Updated app_state.py to enforce ADMIN_KEY validation and log warnings if it matches ACCESS_KEY.
- Refactored access.py to utilize database operations for access failure tracking and lockout management.
- Enhanced prompt.py and settings.py to support asynchronous operations and validation.
- Implemented async validation for R2 endpoint URLs and upstream URLs across various modules.
- Added database schema for access failures and corresponding migration.
- Updated tests to cover new ADMIN_KEY functionality and access lockout persistence.
- Adjusted docker-compose.yml to include ADMIN_KEY environment variable.
2026-08-12 13:47:14 +08:00

524 lines
17 KiB
Python

import asyncio
import ipaddress
import os
import re
import socket
from urllib.parse import urlparse, urlsplit, urlunsplit
from . import settings as config
ENV_VAR_REF_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
def get_env_var_ref_name(value: str | None) -> str | None:
match = ENV_VAR_REF_RE.match(str(value or "").strip())
return match.group(1) if match else None
def is_malformed_env_var_ref(value: str | None) -> bool:
normalized = str(value or "").strip()
return bool(normalized) and ("${" in normalized or "}" in normalized) and not get_env_var_ref_name(normalized)
def resolve_env_var_ref(value: str | None) -> str:
normalized = str(value or "").strip()
env_var = get_env_var_ref_name(normalized)
if env_var:
return os.getenv(env_var, "").strip()
return normalized
def normalize_secret_env_ref_or_plaintext(
value: str | None,
*,
field_name: str,
normalizer=None,
) -> str:
from .secrets import configured_secret_ids, normalize_secret_id
normalized = str(value or "").strip()
if not normalized:
return ""
if is_malformed_env_var_ref(normalized):
raise ValueError(
f"{field_name} env ref must be formatted as ${{ENV_VAR_NAME}}."
)
env_var = get_env_var_ref_name(normalized)
if env_var:
return f"${{{env_var}}}"
if normalized in configured_secret_ids():
return normalize_secret_id(normalized, field_name=field_name)
if not config.ALLOW_PLAINTEXT_SECRETS:
raise ValueError(
f"{field_name} must use ${{ENV_VAR_NAME}} unless "
"ALLOW_PLAINTEXT_SECRETS=true."
)
return normalizer(normalized) if normalizer is not None else normalized
def _get_private_ip_ranges() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
return (
ipaddress.IPv4Network("127.0.0.0/8"), # loopback
ipaddress.IPv4Network("10.0.0.0/8"), # private Class A
ipaddress.IPv4Network("172.16.0.0/12"), # private Class B
ipaddress.IPv4Network("192.168.0.0/16"), # private Class C
ipaddress.IPv4Network("169.254.0.0/16"), # link-local
ipaddress.IPv4Network("0.0.0.0/8"), # current network
ipaddress.IPv4Network("224.0.0.0/4"), # multicast IPv4
ipaddress.IPv4Network("255.255.255.255/32"), # broadcast
ipaddress.IPv6Network("::1/128"), # loopback
ipaddress.IPv6Network("fc00::/7"), # unique local
ipaddress.IPv6Network("fe80::/10"), # link-local
ipaddress.IPv6Network("::ffff:0:0/96"), # IPv4-mapped
ipaddress.IPv6Network("ff00::/8"), # multicast IPv6
)
PRIVATE_RANGES = _get_private_ip_ranges()
R2_ENDPOINT_HOST_SUFFIX = ".r2.cloudflarestorage.com"
BLOCKED_HOSTNAMES = {
"localhost",
"localhost.localdomain",
"metadata.google.internal",
"metadata.google.com",
"169.254.169.254",
"metadata.azure.com",
"metadata.internal",
"detectportal.safari.com",
"captive.apple.com",
}
def _parse_host_allowlist(allowlist: str | None) -> list[str]:
return [
h.strip().lower().rstrip(".")
for h in str(allowlist or "").split(",")
if h.strip()
]
def _ip_literal(hostname: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
try:
return ipaddress.ip_address(hostname)
except ValueError:
return None
def is_private_ip(ip_str: str) -> bool:
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return True
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
ip = ip.ipv4_mapped
# SSRF destinations must be globally routable. This also covers shared,
# benchmark, documentation, reserved, multicast and otherwise non-global
# address space that is not represented by the historical private ranges.
return not ip.is_global
def resolve_hostname(hostname: str) -> tuple[str, list[str]]:
try:
family = socket.AF_UNSPEC
addrs = socket.getaddrinfo(hostname, None, family, socket.SOCK_STREAM)
except socket.gaierror:
return hostname, []
resolved_ips = []
for addr in addrs:
ip = addr[4][0]
resolved_ips.append(ip)
return hostname, list(dict.fromkeys(resolved_ips))
async def resolve_hostname_async(hostname: str) -> tuple[str, list[str]]:
return await asyncio.to_thread(resolve_hostname, hostname)
def response_peer_ip(response: object) -> str | None:
connection = getattr(response, "connection", None)
transport = getattr(connection, "transport", None)
if transport is None:
protocol = getattr(response, "_protocol", None)
transport = getattr(protocol, "transport", None)
if transport is None:
return None
peername = transport.get_extra_info("peername")
if isinstance(peername, tuple) and peername:
return str(peername[0])
if isinstance(peername, str):
return peername
return None
def validate_response_peer_ip(response: object, context: str) -> None:
peer_ip = response_peer_ip(response)
if peer_ip and is_private_ip(peer_ip):
raise ValueError(f"{context} connected to private/internal IP: {peer_ip}")
def _validate_public_dns_resolution(
hostname: str,
*,
private_ip_error: str,
) -> None:
_, resolved_ips = resolve_hostname(hostname)
for ip in resolved_ips:
if is_private_ip(ip):
resolved_info = ", ".join(f"'{resolved_ip}'" for resolved_ip in resolved_ips)
raise ValueError(
private_ip_error.format(
hostname=hostname,
resolved_info=resolved_info,
)
)
def _validate_url_base(
url: str,
*,
allowed_schemes: set[str],
scheme_error: str,
missing_hostname_error: str,
blocked_hostname_error: str,
private_ip_error: str,
allowlist: str = "",
allowlist_error_prefix: str = "Hostname",
reject_userinfo: bool = False,
reject_query_fragment: bool = False,
) -> None:
parsed = urlparse(url)
if parsed.scheme not in allowed_schemes:
raise ValueError(scheme_error)
if not parsed.hostname:
raise ValueError(missing_hostname_error)
if reject_userinfo and (parsed.username is not None or parsed.password is not None):
raise ValueError("URL must not include username or password")
if reject_query_fragment and (parsed.query or parsed.fragment):
raise ValueError("URL must not include query strings or fragments")
hostname = parsed.hostname.lower()
if hostname in BLOCKED_HOSTNAMES:
raise ValueError(blocked_hostname_error.format(hostname=hostname))
if allowlist:
allowed_hosts = _parse_host_allowlist(allowlist)
if hostname not in allowed_hosts:
raise ValueError(
f"{allowlist_error_prefix} '{hostname}' is not in the allowlist. Allowed: {', '.join(allowed_hosts)}"
)
_validate_public_dns_resolution(hostname, private_ip_error=private_ip_error)
def validate_upstream_url(url: str, allowlist: str) -> None:
_validate_url_base(
url,
allowed_schemes={"https"},
scheme_error="Only HTTPS URLs are allowed for upstream API",
missing_hostname_error="Invalid URL: no hostname",
blocked_hostname_error="Hostname '{hostname}' is not allowed",
private_ip_error="Hostname '{hostname}' resolves to private/internal IP(s): {resolved_info}",
allowlist=allowlist,
reject_userinfo=True,
reject_query_fragment=True,
)
def normalize_upstream_base_url(url: str | None) -> str:
value = str(url or "").strip()
if not value:
raise ValueError("API URL must not be empty")
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as e:
raise ValueError("API URL must include a valid port") from e
if parsed.scheme.lower() != "https":
raise ValueError("API URL must use https://")
if not parsed.hostname:
raise ValueError("API URL must include a hostname")
if parsed.username is not None or parsed.password is not None:
raise ValueError("API URL must not include username or password")
if parsed.query or parsed.fragment:
raise ValueError("API URL must not include query strings or fragments")
hostname = parsed.hostname.lower()
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
if port is not None:
host = f"{host}:{port}"
path = parsed.path.rstrip("/")
return urlunsplit(("https", host, path, "", ""))
def _is_default_r2_endpoint_hostname(hostname: str) -> bool:
normalized = hostname.lower().rstrip(".")
account_label = normalized.removesuffix(R2_ENDPOINT_HOST_SUFFIX)
return (
bool(account_label)
and normalized.endswith(R2_ENDPOINT_HOST_SUFFIX)
and "." not in account_label
)
def validate_r2_endpoint_url(
url: str,
allowlist: str | None = None,
*,
resolve_dns: bool = True,
) -> None:
try:
parsed = urlsplit(url)
_ = parsed.port
except ValueError as e:
raise ValueError("R2 endpoint URL must include a valid port") from e
if parsed.scheme.lower() != "https":
raise ValueError("R2 endpoint URL must use https://")
if not parsed.hostname:
raise ValueError("R2 endpoint URL must include a hostname")
if parsed.username is not None or parsed.password is not None:
raise ValueError("R2 endpoint URL must not include username or password")
if parsed.query or parsed.fragment:
raise ValueError("R2 endpoint URL must not include query strings or fragments")
hostname = parsed.hostname.lower().rstrip(".")
if hostname in BLOCKED_HOSTNAMES:
raise ValueError(f"R2 endpoint hostname '{hostname}' is not allowed")
if _ip_literal(hostname) is not None:
raise ValueError("R2 endpoint URL must use a hostname, not an IP address")
allowed_hosts = _parse_host_allowlist(allowlist)
if not _is_default_r2_endpoint_hostname(hostname) and hostname not in allowed_hosts:
allowed_message = ", ".join(allowed_hosts) if allowed_hosts else "none"
raise ValueError(
"R2 endpoint hostname "
f"'{hostname}' must be a Cloudflare R2 hostname "
f"(*{R2_ENDPOINT_HOST_SUFFIX}) or be listed in "
f"R2_ENDPOINT_HOST_ALLOWLIST. Allowed: {allowed_message}"
)
if resolve_dns:
_validate_public_dns_resolution(
hostname,
private_ip_error=(
"R2 endpoint hostname '{hostname}' resolves to private/internal "
"IP(s): {resolved_info}"
),
)
def normalize_r2_endpoint_url(url: str | None) -> str:
value = str(url or "").strip()
if not value:
return ""
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as e:
raise ValueError("R2 endpoint URL must include a valid port") from e
if parsed.scheme.lower() != "https":
raise ValueError("R2 endpoint URL must use https://")
if not parsed.hostname:
raise ValueError("R2 endpoint URL must include a hostname")
if parsed.username is not None or parsed.password is not None:
raise ValueError("R2 endpoint URL must not include username or password")
if parsed.query or parsed.fragment:
raise ValueError("R2 endpoint URL must not include query strings or fragments")
hostname = parsed.hostname.lower()
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
if port is not None:
host = f"{host}:{port}"
path = parsed.path.rstrip("/")
normalized = urlunsplit(("https", host, path, "", ""))
# Pydantic validators and synchronous settings normalization must not do DNS.
validate_r2_endpoint_url(
normalized,
config.R2_ENDPOINT_HOST_ALLOWLIST,
resolve_dns=False,
)
return normalized
def redact_url(url: str | None) -> str:
value = str(url or "").strip()
if not value:
return ""
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError:
return "***"
if not parsed.scheme or not parsed.hostname:
return "***"
hostname = parsed.hostname
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
port_part = f":{port}" if port is not None else ""
userinfo = "***@" if parsed.username is not None or parsed.password is not None else ""
path = "/***" if parsed.path and parsed.path != "/" else parsed.path
query = "***" if parsed.query else ""
fragment = "***" if parsed.fragment else ""
return urlunsplit((parsed.scheme, f"{userinfo}{host}{port_part}", path, query, fragment))
def validate_image_url(url: str) -> None:
_validate_url_base(
url,
allowed_schemes={"https"},
scheme_error="Only HTTPS URLs are allowed for image URLs",
missing_hostname_error="Invalid URL: no hostname",
blocked_hostname_error="Hostname '{hostname}' is not allowed",
private_ip_error="Image URL hostname '{hostname}' resolves to private/internal IP(s): {resolved_info}",
)
def validate_webhook_url(url: str, allowlist: str = "") -> None:
_validate_url_base(
url,
allowed_schemes={"https"},
scheme_error="Only HTTPS URLs are allowed for webhook callbacks",
missing_hostname_error="Invalid webhook URL: no hostname",
blocked_hostname_error="Webhook hostname '{hostname}' is not allowed",
private_ip_error="Webhook hostname '{hostname}' resolves to private/internal IP(s): {resolved_info}",
allowlist=allowlist,
allowlist_error_prefix="Webhook hostname",
)
async def validate_upstream_url_async(url: str, allowlist: str) -> None:
await asyncio.to_thread(validate_upstream_url, url, allowlist)
async def validate_r2_endpoint_url_async(
url: str,
allowlist: str | None = None,
) -> None:
await asyncio.to_thread(validate_r2_endpoint_url, url, allowlist)
async def validate_image_url_async(url: str) -> None:
await asyncio.to_thread(validate_image_url, url)
async def validate_webhook_url_async(url: str, allowlist: str = "") -> None:
await asyncio.to_thread(validate_webhook_url, url, allowlist)
def normalize_webhook_url(url: str | None) -> str:
value = str(url or "").strip()
if not value:
return ""
parsed = urlsplit(value)
if parsed.scheme.lower() != "https":
raise ValueError("Webhook URL must use https://")
if not parsed.hostname:
raise ValueError("Webhook URL must include a hostname")
return urlunsplit(("https", parsed.netloc, parsed.path, parsed.query, parsed.fragment))
def mask_webhook_url(url: str | None) -> str:
value = str(url or "").strip()
if not value:
return ""
from .secrets import configured_secret_ids
if value in configured_secret_ids() or get_env_var_ref_name(value):
return value
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError:
return "***"
if parsed.scheme.lower() != "https" or not parsed.hostname:
return "***"
hostname = parsed.hostname
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
port_part = f":{port}" if port is not None else ""
user_info = "***@" if parsed.username is not None else ""
path = "/***" if parsed.path and parsed.path != "/" else parsed.path
query = "***" if parsed.query else ""
fragment = "***" if parsed.fragment else ""
return urlunsplit((parsed.scheme, f"{user_info}{host}{port_part}", path, query, fragment))
def normalize_socks5_proxy_url(url: str | None) -> str:
value = str(url or "").strip()
if not value:
return ""
parsed = urlsplit(value)
if parsed.scheme.lower() != "socks5":
raise ValueError("SOCKS5 proxy URL must use socks5://")
if not parsed.hostname:
raise ValueError("SOCKS5 proxy URL must include a hostname")
try:
port = parsed.port
except ValueError as e:
raise ValueError("SOCKS5 proxy URL must include a valid port") from e
if port is None:
raise ValueError("SOCKS5 proxy URL must include a port")
if parsed.query or parsed.fragment:
raise ValueError("SOCKS5 proxy URL must not include query strings or fragments")
if parsed.path not in {"", "/"}:
raise ValueError("SOCKS5 proxy URL must not include a path")
return urlunsplit(("socks5", parsed.netloc, "", "", ""))
def mask_socks5_proxy_url(url: str | None) -> str:
value = str(url or "").strip()
if not value:
return ""
from .secrets import configured_secret_ids
if value in configured_secret_ids() or get_env_var_ref_name(value):
return value
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError:
return value
if parsed.scheme.lower() != "socks5" or not parsed.hostname or port is None:
return value
hostname = parsed.hostname
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
user_info = ""
if parsed.username is not None:
user_info = parsed.username
if parsed.password is not None:
user_info += ":***"
user_info += "@"
return f"socks5://{user_info}{host}:{port}"