Files
Unraid-AppOps-Gateway-Public/backend/app/core/network_security.py
T

71 lines
2.7 KiB
Python

from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlsplit, urlunsplit
class UnsafeConnectorUrl(ValueError):
pass
_BLOCKED_HOSTS = {"localhost", "localhost.localdomain", "metadata.google.internal"}
_BLOCKED_IPS = {
ipaddress.ip_address("169.254.169.254"),
ipaddress.ip_address("100.100.100.200"),
}
def _address_is_forbidden(address: ipaddress.IPv4Address | ipaddress.IPv6Address, *, allow_loopback: bool) -> bool:
return (
address in _BLOCKED_IPS
or address.is_link_local
or address.is_multicast
or address.is_unspecified
or (address.is_loopback and not allow_loopback)
)
def validate_connector_url(value: str, *, allow_loopback: bool = False) -> str:
"""Validate a configured LAN connector URL and return a normalized value."""
raw = str(value or "").strip()
if not raw:
return ""
parsed = urlsplit(raw)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
raise UnsafeConnectorUrl("connector_url_requires_http_or_https")
if parsed.username or parsed.password or parsed.fragment:
raise UnsafeConnectorUrl("connector_url_contains_forbidden_components")
try:
port = parsed.port
except ValueError as exc:
raise UnsafeConnectorUrl("connector_url_has_invalid_port") from exc
if port is not None and not 1 <= port <= 65535:
raise UnsafeConnectorUrl("connector_url_has_invalid_port")
hostname = parsed.hostname.rstrip(".").lower()
if hostname in _BLOCKED_HOSTS and not allow_loopback:
raise UnsafeConnectorUrl("connector_url_loopback_blocked")
try:
address = ipaddress.ip_address(hostname)
except ValueError:
address = None
if address is not None:
if _address_is_forbidden(address, allow_loopback=allow_loopback):
raise UnsafeConnectorUrl("connector_url_reserved_address_blocked")
else:
# Inspect currently resolvable names as well as literal IPs. Unresolved LAN
# names remain configurable, but DNS names resolving to local-only system
# addresses cannot bypass the literal-address checks.
try:
resolved = {item[4][0] for item in socket.getaddrinfo(hostname, port or 0, type=socket.SOCK_STREAM)}
except socket.gaierror:
resolved = set()
for resolved_value in resolved:
resolved_address = ipaddress.ip_address(resolved_value)
if _address_is_forbidden(resolved_address, allow_loopback=allow_loopback):
raise UnsafeConnectorUrl("connector_url_resolves_to_reserved_address")
normalized_path = parsed.path.rstrip("/")
return urlunsplit((parsed.scheme.lower(), parsed.netloc, normalized_path, parsed.query, ""))