From c554210f98d2ab41f79f622b8cc2d73b3a42cadd Mon Sep 17 00:00:00 2001 From: demo-bot Date: Sun, 9 Aug 2026 15:57:40 +0000 Subject: [PATCH] decomposer: generate deliverable files for Define the service contract and project architecture for the FastAPI endpoint monitoring service.; Implement the typed monitor CRUD API and concurrency-safe in-memory state according to the service design.; Implement secure on-demand endpoint checks with status updates, latency measurement, robust error handling, and redacted structured logs.; Add operational API endpoints and environment-driven runtime configuration to the monitoring service.; Create automated tests for the monitoring service.; Package the service with Docker and developer documentation.; Validate the complete project. --- app/security.py | 62 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/app/security.py b/app/security.py index 364dce5..a057c81 100644 --- a/app/security.py +++ b/app/security.py @@ -1,29 +1,55 @@ import asyncio import ipaddress import socket -from urllib.parse import urlsplit +from collections.abc import Awaitable, Callable +from urllib.parse import urlsplit, urlunsplit + +Resolver = Callable[[str, int], Awaitable[list[str]]] -class UnsafeDestination(ValueError): +class UnsafeTarget(ValueError): pass -async def validate_destination(url: str) -> None: - parsed = urlsplit(url) - if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise UnsafeDestination("only HTTP(S) URLs with a hostname are allowed") - if parsed.username or parsed.password: - raise UnsafeDestination("URL credentials are not allowed") +def redact_url(url: str) -> str: + parts = urlsplit(url) + host = parts.hostname or "invalid" + if ":" in host: + host = f"[{host}]" + port = f":{parts.port}" if parts.port else "" + return urlunsplit((parts.scheme, f"{host}{port}", parts.path, "", "")) + + +async def system_resolver(host: str, port: int) -> list[str]: + def resolve() -> list[str]: + rows = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + return list({row[4][0] for row in rows}) + + return await asyncio.to_thread(resolve) + + +async def validate_target(url: str, resolver: Resolver = system_resolver) -> None: + parts = urlsplit(url) + if parts.scheme not in {"http", "https"} or not parts.hostname: + raise UnsafeTarget("invalid_scheme_or_host") + if parts.username is not None or parts.password is not None: + raise UnsafeTarget("credentials_not_allowed") + host = parts.hostname.rstrip(".").lower() + if host == "localhost" or host.endswith(".localhost"): + raise UnsafeTarget("non_global_target") + port = parts.port or (443 if parts.scheme == "https" else 80) try: - literal = ipaddress.ip_address(parsed.hostname) - addresses = [literal] + literal = ipaddress.ip_address(host) + addresses = [str(literal)] except ValueError: - loop = asyncio.get_running_loop() try: - infos = await loop.getaddrinfo(parsed.hostname, parsed.port, - type=socket.SOCK_STREAM) - except socket.gaierror as exc: - raise UnsafeDestination("destination DNS resolution failed") from exc - addresses = list({ipaddress.ip_address(info[4][0]) for info in infos}) - if not addresses or any(not address.is_global for address in addresses): - raise UnsafeDestination("destination resolves to a non-public address") + addresses = await resolver(host, port) + except (OSError, socket.gaierror) as exc: + raise UnsafeTarget("dns_resolution_failed") from exc + if not addresses: + raise UnsafeTarget("dns_resolution_failed") + try: + if any(not ipaddress.ip_address(address).is_global for address in addresses): + raise UnsafeTarget("non_global_target") + except ValueError as exc: + raise UnsafeTarget("invalid_dns_answer") from exc