71 lines
2.7 KiB
Python
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, ""))
|