Add ALLOW_INTERNAL_ENDPOINTS config for self-hosted internal network deployments
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
46
app/ui.py
46
app/ui.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user