Files
rays cc31b1433d fix(security): restrict LLM requests to operator-approved destinations
The analyze, chat, verify-key and model-list routes sent server-side
requests to a caller-supplied base URL guarded only by a pre-flight
denylist. Callers could choose arbitrary hosts and paths, reach shared
address space and metadata ranges, drive local LLM write APIs, and use
DNS rebinding to deliver server-held keys to loopback services.

Requests now select an entry of an immutable destination registry built
at startup from the built-in provider catalog, local Ollama/LM Studio,
and operator environment variables (DOCTOR_LLM_ALLOWED_BASE_URLS and the
existing base URL variables). Outbound URLs are built from the approved
entry, resolved addresses are checked at connect time inside the shared
connector, redirects are never followed or treated as success, and error
responses no longer reflect upstream bodies or network details. The UI
shows localized operator guidance, a static rule enforces disabled
redirects and policy-managed sessions, and .comfyignore keeps tests and
tooling out of the published archive.
2026-10-13 12:22:39 +08:00

250 lines
7.4 KiB
Python

"""
Security utilities for ComfyUI-Doctor.
Includes SSRF protection helpers that can be imported without ComfyUI context.
"""
from typing import Tuple, Optional, Dict, List
import ipaddress
import os
import socket
import threading
import time
from urllib.parse import urlparse
LOCAL_LLM_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
LOCAL_LLM_PORTS = {11434, 1234}
DEFAULT_DNS_TIMEOUT_SECONDS = 1.5
_SSRF_METRICS = {
"ssrf_block_count": 0,
"last_block_timestamp": None,
"last_block_reason": None,
}
def _record_ssrf_block(reason: str) -> None:
_SSRF_METRICS["ssrf_block_count"] += 1
_SSRF_METRICS["last_block_timestamp"] = time.time()
_SSRF_METRICS["last_block_reason"] = reason
def _dns_timeout_seconds() -> float:
raw = (os.getenv("DOCTOR_SSRF_DNS_TIMEOUT_SECONDS", "") or "").strip()
if not raw:
return DEFAULT_DNS_TIMEOUT_SECONDS
try:
value = float(raw)
except Exception:
return DEFAULT_DNS_TIMEOUT_SECONDS
if value <= 0:
return DEFAULT_DNS_TIMEOUT_SECONDS
return min(value, 10.0)
def _resolve_hostname_ips(hostname: str, timeout_seconds: Optional[float] = None) -> List[str]:
"""
Resolve hostname to IP addresses with bounded timeout.
Raises RuntimeError when DNS resolution fails/times out/returns empty.
"""
result: Dict[str, List[str]] = {}
error: Dict[str, Exception] = {}
def _worker() -> None:
try:
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
ips: List[str] = []
for info in infos:
sockaddr = info[4]
if not isinstance(sockaddr, tuple) or not sockaddr:
continue
ip_text = str(sockaddr[0]).strip()
if ip_text:
ips.append(ip_text)
# Keep deterministic order and unique values.
deduped = list(dict.fromkeys(ips))
result["ips"] = deduped
except Exception as exc:
error["exc"] = exc
timeout = timeout_seconds if timeout_seconds is not None else _dns_timeout_seconds()
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join(timeout)
if thread.is_alive():
raise RuntimeError(f"DNS resolution timeout ({timeout:.1f}s)")
if "exc" in error:
raise RuntimeError(f"DNS resolution failed: {error['exc']}")
ips = result.get("ips") or []
if not ips:
raise RuntimeError("DNS resolution returned no addresses")
return ips
def record_ssrf_block(reason: str) -> None:
"""Count an outbound-destination refusal in the /doctor/health SSRF metrics."""
_record_ssrf_block(reason)
def _classify_restricted_ip(ip) -> Optional[str]:
# Keep metadata endpoint reasons explicit for audits.
if str(ip) in {"169.254.169.254", "100.100.100.200", "fd00:ec2::254"}:
return "metadata endpoint"
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None:
ip = mapped
if ip.is_private:
return "private IP"
if ip.is_loopback:
return "loopback IP"
if ip.is_link_local:
return "link-local IP"
if ip.is_multicast:
return "multicast IP"
if ip.is_unspecified:
return "unspecified IP"
if ip.is_reserved:
return "reserved IP"
# IMPORTANT: deny unless global. is_private/is_reserved miss 100.64.0.0/10 (CGNAT, incl. the
# Alibaba Cloud metadata address) and other special-purpose ranges.
if not ip.is_global:
return "non-global IP"
return None
def parse_base_url(base_url: str) -> Optional[Dict[str, str]]:
"""
Parse base URL into normalized components.
Returns:
dict with scheme, hostname, port, path, netloc or None if invalid.
"""
if not base_url:
return None
parsed = urlparse(base_url)
if not parsed.scheme or not parsed.hostname:
return None
scheme = parsed.scheme.lower()
port = parsed.port
if port is None:
if scheme == "https":
port = 443
elif scheme == "http":
port = 80
return {
"scheme": scheme,
"hostname": parsed.hostname,
"port": port,
"path": parsed.path or "",
"netloc": parsed.netloc,
}
def is_local_llm_url(base_url: str) -> bool:
"""
Check if the base URL is a local LLM service (LMStudio, Ollama, etc.).
These typically don't require an API key.
"""
if not base_url:
return False
info = parse_base_url(base_url)
if not info:
return False
hostname = (info.get("hostname") or "").lower()
port = info.get("port")
if hostname in LOCAL_LLM_HOSTS and port in LOCAL_LLM_PORTS:
return True
return False
def validate_ssrf_url(base_url: str, allow_local_llm: bool = True) -> Tuple[bool, str]:
"""
Validate base URL to prevent SSRF attacks.
Blocks:
- Private IP ranges (10.x, 172.16-31.x, 192.168.x)
- Localhost (127.x.x.x, localhost, ::1)
- Link-local addresses (169.254.x.x)
- Non-HTTP protocols (file://, ftp://, etc.)
- Metadata endpoints (169.254.169.254)
- DNS-rebinding patterns (domain names resolving to restricted/local addresses)
Args:
base_url: The URL to validate
allow_local_llm: If True, allows known local LLM patterns (LMStudio, Ollama)
Returns:
(is_valid, error_message) tuple
"""
def _block(reason: str):
_record_ssrf_block(reason)
return False, reason
if not base_url:
return _block("Empty URL")
try:
parsed = urlparse(base_url)
except Exception as e:
return _block(f"Invalid URL format: {e}")
# Check protocol
if parsed.scheme not in ("http", "https"):
return _block(f"Invalid protocol: {parsed.scheme}. Only HTTP/HTTPS allowed.")
# Allow known local LLM patterns if enabled
if allow_local_llm and is_local_llm_url(base_url):
return True, ""
hostname = parsed.hostname
if not hostname:
return _block("Missing hostname")
hostname_lower = hostname.lower()
# Block localhost patterns
if hostname_lower in LOCAL_LLM_HOSTS:
return _block(f"Blocked: localhost access ({hostname})")
# Check if hostname is an IP address
try:
ip = ipaddress.ip_address(hostname)
restricted_reason = _classify_restricted_ip(ip)
if restricted_reason:
return _block(f"Blocked: {restricted_reason} ({hostname})")
except ValueError:
# Not an IP address (domain name)
blocked_domains = [".local", ".internal", ".corp", ".lan"]
if any(hostname_lower.endswith(d) for d in blocked_domains):
return _block(f"Blocked: internal domain ({hostname})")
# CRITICAL: DNS check must fail-closed to prevent DNS rebinding SSRF bypasses.
try:
resolved_ips = _resolve_hostname_ips(hostname)
except RuntimeError as exc:
return _block(f"Blocked: DNS resolution error ({exc})")
for resolved_ip in resolved_ips:
try:
ip = ipaddress.ip_address(resolved_ip)
except ValueError:
return _block(f"Blocked: DNS returned invalid IP ({resolved_ip})")
restricted_reason = _classify_restricted_ip(ip)
if restricted_reason:
return _block(f"Blocked: DNS resolved to {restricted_reason} ({resolved_ip})")
return True, ""
def get_ssrf_metrics() -> Dict[str, Optional[str]]:
return dict(_SSRF_METRICS)