Add ALLOW_INTERNAL_ENDPOINTS config for self-hosted internal network deployments

This commit is contained in:
2026-02-01 18:26:14 +08:00
parent 9629507acd
commit 45d21cce21
5 changed files with 76 additions and 33 deletions

View File

@@ -189,7 +189,10 @@ def create_app(
acl_service = AclService(storage_root) acl_service = AclService(storage_root)
object_lock_service = ObjectLockService(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 = AccessLoggingService(storage_root)
access_logging_service.set_storage(storage) access_logging_service.set_storage(storage)

View File

@@ -18,21 +18,33 @@ from .replication import ReplicationManager
from .site_registry import PeerSite, SiteInfo, SiteRegistry from .site_registry import PeerSite, SiteInfo, SiteRegistry
def _is_safe_url(url: str) -> bool: def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
"""Check if a URL is safe to make requests to (not internal/private).""" """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: try:
parsed = urlparse(url) parsed = urlparse(url)
hostname = parsed.hostname hostname = parsed.hostname
if not hostname: if not hostname:
return False 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 = { blocked_hosts = {
"localhost", "localhost",
"127.0.0.1", "127.0.0.1",
"0.0.0.0", "0.0.0.0",
"::1", "::1",
"[::1]", "[::1]",
"metadata.google.internal",
"169.254.169.254",
} }
if hostname.lower() in blocked_hosts: if hostname.lower() in blocked_hosts:
return False return False
@@ -539,10 +551,11 @@ def check_bidirectional_status(site_id: str):
}) })
return jsonify(result) 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({ result["issues"].append({
"code": "ENDPOINT_NOT_ALLOWED", "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", "severity": "error",
}) })
return jsonify(result) return jsonify(result)

View File

@@ -148,6 +148,7 @@ class AppConfig:
ratelimit_admin: str ratelimit_admin: str
num_trusted_proxies: int num_trusted_proxies: int
allowed_redirect_hosts: list[str] allowed_redirect_hosts: list[str]
allow_internal_endpoints: bool
@classmethod @classmethod
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig": 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)) num_trusted_proxies = int(_get("NUM_TRUSTED_PROXIES", 0))
allowed_redirect_hosts_raw = _get("ALLOWED_REDIRECT_HOSTS", "") 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()] 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, return cls(storage_root=storage_root,
max_upload_size=max_upload_size, max_upload_size=max_upload_size,
@@ -400,7 +402,8 @@ class AppConfig:
site_priority=site_priority, site_priority=site_priority,
ratelimit_admin=ratelimit_admin, ratelimit_admin=ratelimit_admin,
num_trusted_proxies=num_trusted_proxies, 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]: def validate_and_report(self) -> list[str]:
"""Validate configuration and return a list of warnings/issues. """Validate configuration and return a list of warnings/issues.
@@ -607,4 +610,5 @@ class AppConfig:
"RATE_LIMIT_ADMIN": self.ratelimit_admin, "RATE_LIMIT_ADMIN": self.ratelimit_admin,
"NUM_TRUSTED_PROXIES": self.num_trusted_proxies, "NUM_TRUSTED_PROXIES": self.num_trusted_proxies,
"ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts, "ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts,
"ALLOW_INTERNAL_ENDPOINTS": self.allow_internal_endpoints,
} }

View File

@@ -17,21 +17,33 @@ from urllib.parse import urlparse
import requests import requests
def _is_safe_url(url: str) -> bool: def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
"""Check if a URL is safe to make requests to (not internal/private).""" """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: try:
parsed = urlparse(url) parsed = urlparse(url)
hostname = parsed.hostname hostname = parsed.hostname
if not hostname: if not hostname:
return False 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 = { blocked_hosts = {
"localhost", "localhost",
"127.0.0.1", "127.0.0.1",
"0.0.0.0", "0.0.0.0",
"::1", "::1",
"[::1]", "[::1]",
"metadata.google.internal",
"169.254.169.254",
} }
if hostname.lower() in blocked_hosts: if hostname.lower() in blocked_hosts:
return False return False
@@ -197,8 +209,9 @@ class NotificationConfiguration:
class NotificationService: 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.storage_root = storage_root
self._allow_internal_endpoints = allow_internal_endpoints
self._configs: Dict[str, List[NotificationConfiguration]] = {} self._configs: Dict[str, List[NotificationConfiguration]] = {}
self._queue: queue.Queue[tuple[NotificationEvent, WebhookDestination]] = queue.Queue() self._queue: queue.Queue[tuple[NotificationEvent, WebhookDestination]] = queue.Queue()
self._workers: List[threading.Thread] = [] self._workers: List[threading.Thread] = []
@@ -331,8 +344,8 @@ class NotificationService:
self._queue.task_done() self._queue.task_done()
def _send_notification(self, event: NotificationEvent, destination: WebhookDestination) -> None: def _send_notification(self, event: NotificationEvent, destination: WebhookDestination) -> None:
if not _is_safe_url(destination.url): if not _is_safe_url(destination.url, allow_internal=self._allow_internal_endpoints):
raise RuntimeError(f"Blocked request to internal/private URL: {destination.url}") raise RuntimeError(f"Blocked request to cloud metadata service (SSRF protection): {destination.url}")
payload = event.to_s3_event() payload = event.to_s3_event()
headers = {"Content-Type": "application/json", **destination.headers} headers = {"Content-Type": "application/json", **destination.headers}

View File

@@ -3010,24 +3010,34 @@ def check_peer_bidirectional_status(site_id: str):
parsed = urlparse(peer.endpoint) parsed = urlparse(peer.endpoint)
hostname = parsed.hostname or "" hostname = parsed.hostname or ""
import ipaddress import ipaddress
try: cloud_metadata_hosts = {"metadata.google.internal", "169.254.169.254"}
ip = ipaddress.ip_address(hostname) if hostname.lower() in cloud_metadata_hosts:
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local: result["issues"].append({
result["issues"].append({ "code": "ENDPOINT_NOT_ALLOWED",
"code": "ENDPOINT_NOT_ALLOWED", "message": "Peer endpoint points to cloud metadata service (SSRF protection)",
"message": "Peer endpoint points to internal or private address", "severity": "error",
"severity": "error", })
}) return jsonify(result)
return jsonify(result) allow_internal = current_app.config.get("ALLOW_INTERNAL_ENDPOINTS", False)
except ValueError: if not allow_internal:
blocked_patterns = ["localhost", "127.", "10.", "192.168.", "172.16.", "169.254."] try:
if any(hostname.startswith(p) or hostname == p.rstrip(".") for p in blocked_patterns): ip = ipaddress.ip_address(hostname)
result["issues"].append({ if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
"code": "ENDPOINT_NOT_ALLOWED", result["issues"].append({
"message": "Peer endpoint points to internal or private address", "code": "ENDPOINT_NOT_ALLOWED",
"severity": "error", "message": "Peer endpoint points to internal or private address (set ALLOW_INTERNAL_ENDPOINTS=true for self-hosted deployments)",
}) "severity": "error",
return jsonify(result) })
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: except Exception:
pass pass