diff --git a/app/security.py b/app/security.py index 38259d7..b8bcaba 100644 --- a/app/security.py +++ b/app/security.py @@ -2,53 +2,48 @@ import asyncio import ipaddress import socket from collections.abc import Awaitable, Callable -from urllib.parse import SplitResult, urlsplit, urlunsplit +from urllib.parse import urlsplit, urlunsplit Resolver = Callable[[str, int], Awaitable[list[str]]] -class UnsafeTargetError(ValueError): +class DestinationRejected(ValueError): pass -async def resolve_addresses(host: str, port: int) -> list[str]: - def lookup() -> list[str]: - records = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) - return list({record[4][0] for record in records}) - - return await asyncio.to_thread(lookup) +async def system_resolver(host: str, port: int) -> list[str]: + loop = asyncio.get_running_loop() + records = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) + return sorted({record[4][0] for record in records}) def redacted_url(url: str) -> str: - parsed = urlsplit(url) - host = parsed.hostname or "invalid-host" - if ":" in host: - host = f"[{host}]" - port = f":{parsed.port}" if parsed.port else "" - return urlunsplit((parsed.scheme, f"{host}{port}", parsed.path or "/", "", "")) + parts = urlsplit(url) + host = parts.hostname or "" + authority = f"{host}:{parts.port}" if parts.port else host + query = "" if parts.query else "" + return urlunsplit((parts.scheme, authority, parts.path or "/", query, "")) -async def validate_target(url: str, resolver: Resolver) -> SplitResult: - parsed = urlsplit(url) - if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise UnsafeTargetError("only absolute HTTP(S) URLs are allowed") - if parsed.username is not None or parsed.password is not None: - raise UnsafeTargetError("URL credentials are not allowed") +async def require_public_destination(url: str, resolver: Resolver = system_resolver) -> None: + parts = urlsplit(url) + if parts.scheme not in {"http", "https"} or not parts.hostname: + raise DestinationRejected("only HTTP(S) destinations are allowed") + if parts.username is not None or parts.password is not None: + raise DestinationRejected("URL user information is not allowed") try: - port = parsed.port or (443 if parsed.scheme == "https" else 80) + port = parts.port or (443 if parts.scheme == "https" else 80) except ValueError as exc: - raise UnsafeTargetError("invalid port") from exc + raise DestinationRejected("invalid destination port") from exc try: - addresses = await resolver(parsed.hostname, port) - except (OSError, socket.gaierror) as exc: - raise UnsafeTargetError("DNS resolution failed") from exc + addresses = await resolver(parts.hostname, port) + except (OSError, UnicodeError) as exc: + raise DestinationRejected("destination DNS resolution failed") from exc if not addresses: - raise UnsafeTargetError("DNS resolution returned no addresses") - for address in addresses: - try: - ip = ipaddress.ip_address(address) - except ValueError as exc: - raise UnsafeTargetError("DNS returned an invalid address") from exc - if not ip.is_global: - raise UnsafeTargetError("target resolves to a non-public address") - return parsed + raise DestinationRejected("destination DNS resolution returned no addresses") + try: + parsed = [ipaddress.ip_address(address) for address in addresses] + except ValueError as exc: + raise DestinationRejected("destination DNS returned an invalid address") from exc + if any(not address.is_global for address in parsed): + raise DestinationRejected("destination resolves to a non-public address")