288 lines
9.6 KiB
Python
288 lines
9.6 KiB
Python
"""Network security helpers for outbound HTTP requests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import socket
|
|
from typing import Any, Dict, Iterable, Optional, Set, 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 _normalize_hosts(hosts: Optional[Iterable[str]]) -> Set[str]:
|
|
return {
|
|
str(host or "").strip().lower().rstrip(".")
|
|
for host in (hosts or ())
|
|
if str(host or "").strip()
|
|
}
|
|
|
|
|
|
def _redirect_headers(
|
|
headers: Dict[str, str],
|
|
source_url: str,
|
|
target_url: str,
|
|
*,
|
|
trusted_sensitive_redirect_hosts: Optional[Iterable[str]] = None,
|
|
trusted_sensitive_redirect_headers: Optional[Iterable[str]] = None,
|
|
) -> Dict[str, str]:
|
|
if _url_origin(source_url) == _url_origin(target_url):
|
|
return dict(headers)
|
|
|
|
target_host = _url_origin(target_url)[1]
|
|
trusted_hosts = _normalize_hosts(trusted_sensitive_redirect_hosts)
|
|
allowed_sensitive_headers = {
|
|
str(header or "").strip().lower()
|
|
for header in (trusted_sensitive_redirect_headers or ())
|
|
if str(header or "").strip()
|
|
}
|
|
preserve_for_target = target_host in trusted_hosts
|
|
return {
|
|
key: value
|
|
for key, value in headers.items()
|
|
if (
|
|
str(key).lower() not in SENSITIVE_REDIRECT_HEADERS
|
|
or (
|
|
preserve_for_target
|
|
and str(key).lower() in allowed_sensitive_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,
|
|
trusted_sensitive_redirect_hosts: Optional[Iterable[str]] = None,
|
|
trusted_sensitive_redirect_headers: Optional[Iterable[str]] = None,
|
|
) -> 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,
|
|
trusted_sensitive_redirect_hosts=trusted_sensitive_redirect_hosts,
|
|
trusted_sensitive_redirect_headers=trusted_sensitive_redirect_headers,
|
|
)
|
|
response.close()
|
|
current_url = next_url
|
|
current_headers = next_headers
|
|
|
|
raise UnsafeUrlError("The download URL has too many redirects")
|
|
|
|
|
|
def request_source_response(
|
|
url: str,
|
|
method: str = "GET",
|
|
headers: Optional[Dict[str, str]] = None,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
timeout: int = 20,
|
|
max_attempts: int = 2,
|
|
log_name: str = "Source API",
|
|
) -> Optional[requests.Response]:
|
|
"""Perform a GET/POST request with retry logic for rate limit status (HTTP 429)."""
|
|
import time
|
|
|
|
from .log_system import create_module_logger
|
|
log = create_module_logger(__name__)
|
|
|
|
request_params = {k: v for k, v in (params or {}).items() if v is not None}
|
|
response = None
|
|
|
|
for attempt in range(max_attempts):
|
|
try:
|
|
if method.upper() == "POST":
|
|
response = requests.post(url, json=request_params, headers=headers, timeout=timeout)
|
|
else:
|
|
response = requests.get(url, params=request_params, headers=headers, timeout=timeout)
|
|
except Exception as e:
|
|
log.warning(f"{log_name} request failed: url={url}, error={e}")
|
|
return None
|
|
|
|
if response.status_code != 429 or attempt == max_attempts - 1:
|
|
break
|
|
|
|
retry_after = response.headers.get("Retry-After")
|
|
try:
|
|
delay = float(retry_after) if retry_after else 1.2
|
|
except (TypeError, ValueError):
|
|
delay = 1.2
|
|
time.sleep(max(0.5, min(delay, 3.0)))
|
|
|
|
return response
|
|
|
|
|
|
def request_source_json(
|
|
url: str,
|
|
method: str = "GET",
|
|
headers: Optional[Dict[str, str]] = None,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
timeout: int = 20,
|
|
max_attempts: int = 2,
|
|
log_name: str = "Source API",
|
|
raise_on_error: bool = False,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Perform a GET/POST request and return JSON with retry logic for HTTP 429."""
|
|
response = request_source_response(
|
|
url,
|
|
method=method,
|
|
headers=headers,
|
|
params=params,
|
|
timeout=timeout,
|
|
max_attempts=max_attempts,
|
|
log_name=log_name,
|
|
)
|
|
if response is None:
|
|
if raise_on_error:
|
|
raise ValueError(f"{log_name} request failed: no response")
|
|
return None
|
|
|
|
if response.status_code != 200:
|
|
from .log_system import create_module_logger
|
|
log = create_module_logger(__name__)
|
|
log.debug(f"{log_name} returned HTTP {response.status_code}: url={url}")
|
|
if raise_on_error:
|
|
response.raise_for_status()
|
|
return None
|
|
|
|
try:
|
|
return response.json()
|
|
except Exception as e:
|
|
from .log_system import create_module_logger
|
|
log = create_module_logger(__name__)
|
|
log.warning(f"{log_name} JSON parse failed: url={url}, error={e}")
|
|
if raise_on_error:
|
|
raise
|
|
return None
|
|
|
|
|