From 45d21cce21a9299797d459a260aee9f842ba48c2 Mon Sep 17 00:00:00 2001 From: kqjy Date: Sun, 1 Feb 2026 18:26:14 +0800 Subject: [PATCH] Add ALLOW_INTERNAL_ENDPOINTS config for self-hosted internal network deployments --- app/__init__.py | 5 ++++- app/admin_api.py | 25 ++++++++++++++++++------ app/config.py | 6 +++++- app/notifications.py | 27 +++++++++++++++++++------- app/ui.py | 46 +++++++++++++++++++++++++++----------------- 5 files changed, 76 insertions(+), 33 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index d301c83..ef13ad4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -189,7 +189,10 @@ def create_app( acl_service = AclService(storage_root) object_lock_service = ObjectLockService(storage_root) - notification_service = NotificationService(storage_root) + notification_service = NotificationService( + storage_root, + allow_internal_endpoints=app.config.get("ALLOW_INTERNAL_ENDPOINTS", False), + ) access_logging_service = AccessLoggingService(storage_root) access_logging_service.set_storage(storage) diff --git a/app/admin_api.py b/app/admin_api.py index e8d8609..8ebc76f 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -18,21 +18,33 @@ from .replication import ReplicationManager from .site_registry import PeerSite, SiteInfo, SiteRegistry -def _is_safe_url(url: str) -> bool: - """Check if a URL is safe to make requests to (not internal/private).""" +def _is_safe_url(url: str, allow_internal: bool = False) -> bool: + """Check if a URL is safe to make requests to (not internal/private). + + Args: + url: The URL to check. + allow_internal: If True, allows internal/private IP addresses. + Use for self-hosted deployments on internal networks. + """ try: parsed = urlparse(url) hostname = parsed.hostname if not hostname: return False + cloud_metadata_hosts = { + "metadata.google.internal", + "169.254.169.254", + } + if hostname.lower() in cloud_metadata_hosts: + return False + if allow_internal: + return True blocked_hosts = { "localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]", - "metadata.google.internal", - "169.254.169.254", } if hostname.lower() in blocked_hosts: return False @@ -539,10 +551,11 @@ def check_bidirectional_status(site_id: str): }) return jsonify(result) - if not _is_safe_url(peer.endpoint): + allow_internal = current_app.config.get("ALLOW_INTERNAL_ENDPOINTS", False) + if not _is_safe_url(peer.endpoint, allow_internal=allow_internal): result["issues"].append({ "code": "ENDPOINT_NOT_ALLOWED", - "message": "Peer endpoint points to internal or private address", + "message": "Peer endpoint points to cloud metadata service (SSRF protection)", "severity": "error", }) return jsonify(result) diff --git a/app/config.py b/app/config.py index 1b8d083..bc03850 100644 --- a/app/config.py +++ b/app/config.py @@ -148,6 +148,7 @@ class AppConfig: ratelimit_admin: str num_trusted_proxies: int allowed_redirect_hosts: list[str] + allow_internal_endpoints: bool @classmethod def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig": @@ -315,6 +316,7 @@ class AppConfig: num_trusted_proxies = int(_get("NUM_TRUSTED_PROXIES", 0)) allowed_redirect_hosts_raw = _get("ALLOWED_REDIRECT_HOSTS", "") allowed_redirect_hosts = [h.strip() for h in str(allowed_redirect_hosts_raw).split(",") if h.strip()] + allow_internal_endpoints = str(_get("ALLOW_INTERNAL_ENDPOINTS", "0")).lower() in {"1", "true", "yes", "on"} return cls(storage_root=storage_root, max_upload_size=max_upload_size, @@ -400,7 +402,8 @@ class AppConfig: site_priority=site_priority, ratelimit_admin=ratelimit_admin, num_trusted_proxies=num_trusted_proxies, - allowed_redirect_hosts=allowed_redirect_hosts) + allowed_redirect_hosts=allowed_redirect_hosts, + allow_internal_endpoints=allow_internal_endpoints) def validate_and_report(self) -> list[str]: """Validate configuration and return a list of warnings/issues. @@ -607,4 +610,5 @@ class AppConfig: "RATE_LIMIT_ADMIN": self.ratelimit_admin, "NUM_TRUSTED_PROXIES": self.num_trusted_proxies, "ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts, + "ALLOW_INTERNAL_ENDPOINTS": self.allow_internal_endpoints, } diff --git a/app/notifications.py b/app/notifications.py index 46eb165..6951095 100644 --- a/app/notifications.py +++ b/app/notifications.py @@ -17,21 +17,33 @@ from urllib.parse import urlparse import requests -def _is_safe_url(url: str) -> bool: - """Check if a URL is safe to make requests to (not internal/private).""" +def _is_safe_url(url: str, allow_internal: bool = False) -> bool: + """Check if a URL is safe to make requests to (not internal/private). + + Args: + url: The URL to check. + allow_internal: If True, allows internal/private IP addresses. + Use for self-hosted deployments on internal networks. + """ try: parsed = urlparse(url) hostname = parsed.hostname if not hostname: return False + cloud_metadata_hosts = { + "metadata.google.internal", + "169.254.169.254", + } + if hostname.lower() in cloud_metadata_hosts: + return False + if allow_internal: + return True blocked_hosts = { "localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]", - "metadata.google.internal", - "169.254.169.254", } if hostname.lower() in blocked_hosts: return False @@ -197,8 +209,9 @@ class NotificationConfiguration: class NotificationService: - def __init__(self, storage_root: Path, worker_count: int = 2): + def __init__(self, storage_root: Path, worker_count: int = 2, allow_internal_endpoints: bool = False): self.storage_root = storage_root + self._allow_internal_endpoints = allow_internal_endpoints self._configs: Dict[str, List[NotificationConfiguration]] = {} self._queue: queue.Queue[tuple[NotificationEvent, WebhookDestination]] = queue.Queue() self._workers: List[threading.Thread] = [] @@ -331,8 +344,8 @@ class NotificationService: self._queue.task_done() def _send_notification(self, event: NotificationEvent, destination: WebhookDestination) -> None: - if not _is_safe_url(destination.url): - raise RuntimeError(f"Blocked request to internal/private URL: {destination.url}") + if not _is_safe_url(destination.url, allow_internal=self._allow_internal_endpoints): + raise RuntimeError(f"Blocked request to cloud metadata service (SSRF protection): {destination.url}") payload = event.to_s3_event() headers = {"Content-Type": "application/json", **destination.headers} diff --git a/app/ui.py b/app/ui.py index 5297c24..334ba89 100644 --- a/app/ui.py +++ b/app/ui.py @@ -3010,24 +3010,34 @@ def check_peer_bidirectional_status(site_id: str): parsed = urlparse(peer.endpoint) hostname = parsed.hostname or "" import ipaddress - try: - ip = ipaddress.ip_address(hostname) - if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local: - result["issues"].append({ - "code": "ENDPOINT_NOT_ALLOWED", - "message": "Peer endpoint points to internal or private address", - "severity": "error", - }) - return jsonify(result) - except ValueError: - blocked_patterns = ["localhost", "127.", "10.", "192.168.", "172.16.", "169.254."] - if any(hostname.startswith(p) or hostname == p.rstrip(".") for p in blocked_patterns): - result["issues"].append({ - "code": "ENDPOINT_NOT_ALLOWED", - "message": "Peer endpoint points to internal or private address", - "severity": "error", - }) - return jsonify(result) + cloud_metadata_hosts = {"metadata.google.internal", "169.254.169.254"} + if hostname.lower() in cloud_metadata_hosts: + result["issues"].append({ + "code": "ENDPOINT_NOT_ALLOWED", + "message": "Peer endpoint points to cloud metadata service (SSRF protection)", + "severity": "error", + }) + return jsonify(result) + allow_internal = current_app.config.get("ALLOW_INTERNAL_ENDPOINTS", False) + if not allow_internal: + try: + ip = ipaddress.ip_address(hostname) + if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local: + result["issues"].append({ + "code": "ENDPOINT_NOT_ALLOWED", + "message": "Peer endpoint points to internal or private address (set ALLOW_INTERNAL_ENDPOINTS=true for self-hosted deployments)", + "severity": "error", + }) + return jsonify(result) + except ValueError: + blocked_patterns = ["localhost", "127.", "10.", "192.168.", "172.16."] + if any(hostname.startswith(p) or hostname == p.rstrip(".") for p in blocked_patterns): + result["issues"].append({ + "code": "ENDPOINT_NOT_ALLOWED", + "message": "Peer endpoint points to internal or private address (set ALLOW_INTERNAL_ENDPOINTS=true for self-hosted deployments)", + "severity": "error", + }) + return jsonify(result) except Exception: pass