Files
Azornes-Comfyui-Model-Resolver/core/network_utils.py
T
Dariusz L 48db08ae1a Harden network requests, downloads, and path handling
- Add core/network_utils.py with URL validation, public-IP enforcement,
  redirect validation, and safe request_public_url/validate_public_http_url helpers
- Replace direct requests calls in downloader/type_utils/sources with
  request_public_url so redirects are validated before follow-up
- Replace netloc string checks with host_matches_domain for CivitAI,
  CivArchive, and HuggingFace host matching
- Add HuggingFace Xet download backend with progress reporting and
  cancellation support via hf_xet/huggingface_hub
- Harden aria2 installer: reject symlinks/special files in archives,
  restrict custom aria2c paths to the managed install, and update UI copy
- Normalize paths with realpath and enforce configured model roots in
  local hash/metadata routes, plus remove caller-selected metadata_path
  injection in sidecar writing
- Sanitize download/network errors to strip signed query strings before
  logging or showing them to the user
2026-07-14 01:33:19 +02:00

164 lines
5.7 KiB
Python

"""Network security helpers for outbound HTTP requests."""
from __future__ import annotations
import ipaddress
import socket
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urljoin, urlparse
import requests
REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
SENSITIVE_REDIRECT_HEADERS = {"authorization", "cookie", "proxy-authorization"}
class UnsafeUrlError(ValueError):
"""Raised when an outbound URL could access a non-public network target."""
def host_matches_domain(host: Any, *domains: str) -> bool:
"""Return True for an exact domain or one of its real DNS subdomains."""
normalized_host = str(host or "").strip().lower().rstrip(".")
if not normalized_host:
return False
for domain in domains:
normalized_domain = str(domain or "").strip().lower().rstrip(".")
if normalized_domain and (
normalized_host == normalized_domain
or normalized_host.endswith(f".{normalized_domain}")
):
return True
return False
def _is_public_ip(address: str) -> bool:
try:
parsed = ipaddress.ip_address(str(address).split("%", 1)[0])
except ValueError:
return False
if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped:
parsed = parsed.ipv4_mapped
return bool(parsed.is_global)
def validate_public_http_url(url: Any) -> str:
"""Validate an HTTP(S) URL and reject local, private, or reserved targets."""
text = str(url or "").strip()
if not text or len(text) > 8192:
raise UnsafeUrlError("A valid download URL is required")
try:
parsed = urlparse(text)
port = parsed.port
except ValueError as exc:
raise UnsafeUrlError("The download URL is malformed") from exc
if parsed.scheme.lower() not in {"http", "https"}:
raise UnsafeUrlError("Only http and https download URLs are supported")
if parsed.username is not None or parsed.password is not None:
raise UnsafeUrlError("Credentials embedded in download URLs are not allowed")
host = str(parsed.hostname or "").strip().lower().rstrip(".")
if not host:
raise UnsafeUrlError("The download URL must include a hostname")
if host_matches_domain(host, "localhost"):
raise UnsafeUrlError("Localhost download URLs are not allowed")
try:
direct_ip = ipaddress.ip_address(host.split("%", 1)[0])
except ValueError:
direct_ip = None
if direct_ip is not None:
if not _is_public_ip(str(direct_ip)):
raise UnsafeUrlError("Private, local, and reserved download addresses are not allowed")
return text
try:
ascii_host = host.encode("idna").decode("ascii")
addresses = {
item[4][0]
for item in socket.getaddrinfo(
ascii_host,
port or (443 if parsed.scheme.lower() == "https" else 80),
type=socket.SOCK_STREAM,
)
if item and len(item) > 4 and item[4]
}
except (OSError, UnicodeError) as exc:
raise UnsafeUrlError("The download hostname could not be resolved") from exc
if not addresses:
raise UnsafeUrlError("The download hostname did not resolve to an address")
if any(not _is_public_ip(address) for address in addresses):
raise UnsafeUrlError("Private, local, and reserved download addresses are not allowed")
return text
def _url_origin(url: str) -> Tuple[str, str, int]:
parsed = urlparse(url)
scheme = parsed.scheme.lower()
port = parsed.port or (443 if scheme == "https" else 80)
return scheme, str(parsed.hostname or "").lower().rstrip("."), port
def _redirect_headers(headers: Dict[str, str], source_url: str, target_url: str) -> Dict[str, str]:
if _url_origin(source_url) == _url_origin(target_url):
return dict(headers)
return {
key: value
for key, value in headers.items()
if str(key).lower() not in SENSITIVE_REDIRECT_HEADERS
}
def request_public_url(
method: str,
url: Any,
*,
headers: Optional[Dict[str, str]] = None,
timeout: Any = 30,
stream: bool = True,
max_redirects: int = 5,
) -> Tuple[requests.Response, str, Dict[str, str]]:
"""Perform a GET/HEAD request while validating every redirect target."""
current_url = validate_public_http_url(url)
current_headers = dict(headers or {})
request_method = str(method or "GET").upper()
if request_method not in {"GET", "HEAD"}:
raise ValueError("request_public_url supports only GET and HEAD")
for redirect_count in range(max_redirects + 1):
request_func = requests.head if request_method == "HEAD" else requests.get
response = request_func(
current_url,
headers=current_headers,
allow_redirects=False,
stream=stream,
timeout=timeout,
)
if response.status_code not in REDIRECT_STATUS_CODES:
return response, current_url, current_headers
location = response.headers.get("Location") or response.headers.get("location")
if not location:
return response, current_url, current_headers
if redirect_count >= max_redirects:
response.close()
raise UnsafeUrlError("The download URL has too many redirects")
candidate_url = urljoin(current_url, str(location).strip())
try:
next_url = validate_public_http_url(candidate_url)
except UnsafeUrlError:
response.close()
raise
next_headers = _redirect_headers(current_headers, current_url, next_url)
response.close()
current_url = next_url
current_headers = next_headers
raise UnsafeUrlError("The download URL has too many redirects")