From 63fda77cc5f003502e50233829f071846be8ef61 Mon Sep 17 00:00:00 2001 From: demo-bot Date: Sun, 9 Aug 2026 16:09:56 +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 | 81 +++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/app/security.py b/app/security.py index 900ce2a..c71c976 100644 --- a/app/security.py +++ b/app/security.py @@ -4,63 +4,58 @@ import socket from collections.abc import Awaitable, Callable from urllib.parse import urlsplit, urlunsplit -Resolver = Callable[[str, int], Awaitable[list[str]]] - -class UnsafeTarget(ValueError): +class UnsafeTargetError(ValueError): pass -async def system_resolver(host: str, port: int) -> list[str]: - def resolve() -> list[str]: - records = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) - return sorted({record[4][0] for record in records}) - - try: - return await asyncio.to_thread(resolve) - except socket.gaierror as exc: - raise OSError(f"DNS resolution failed: {exc}") from exc +Resolver = Callable[[str, int], Awaitable[list[str]]] -def _require_global(address: str) -> None: - try: - ip = ipaddress.ip_address(address) - except ValueError as exc: - raise UnsafeTarget("DNS returned an invalid address") from exc - if not ip.is_global: - raise UnsafeTarget("target resolves to a non-public address") +async def resolve_addresses(host: str, port: int) -> list[str]: + loop = asyncio.get_running_loop() + records = await loop.run_in_executor( + None, lambda: socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + ) + return sorted({record[4][0] for record in records}) -async def validate_target(url: str, resolver: Resolver = system_resolver) -> None: +async def assert_safe_url( + url: str, allowed_ports: frozenset[int], resolver: Resolver = resolve_addresses +) -> str: parsed = urlsplit(url) - if parsed.scheme not in {"http", "https"}: - raise UnsafeTarget("only HTTP(S) targets are allowed") + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise UnsafeTargetError("only HTTP(S) targets with a hostname are allowed") if parsed.username is not None or parsed.password is not None: - raise UnsafeTarget("URL credentials are not allowed") - if not parsed.hostname: - raise UnsafeTarget("target has no hostname") + raise UnsafeTargetError("target credentials are not allowed") try: - literal = ipaddress.ip_address(parsed.hostname) - except ValueError: - addresses = await resolver(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) - if not addresses: - raise OSError("DNS resolution returned no addresses") - for address in addresses: - _require_global(address) - else: - _require_global(str(literal)) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except ValueError as exc: + raise UnsafeTargetError("target port is invalid") from exc + if port not in allowed_ports: + raise UnsafeTargetError("target port is not allowed") + try: + addresses = await resolver(parsed.hostname, port) + except (OSError, UnicodeError) as exc: + raise UnsafeTargetError("target hostname could not be resolved") from exc + if not addresses: + raise UnsafeTargetError("target hostname returned no addresses") + for value in addresses: + try: + address = ipaddress.ip_address(value) + except ValueError as exc: + raise UnsafeTargetError("resolver returned an invalid address") from exc + if not address.is_global: + raise UnsafeTargetError("target resolves to a non-public address") + return url -def redact_url(url: str) -> str: - """Retain scheme/host/port/path, never credentials, query, or fragment.""" +def redacted_url(url: str) -> str: parsed = urlsplit(url) - host = parsed.hostname or "" - if ":" in host and not host.startswith("["): - host = f"[{host}]" - netloc = host + host = parsed.hostname or "invalid" try: - if parsed.port is not None: - netloc = f"{host}:{parsed.port}" + port = parsed.port except ValueError: - netloc = host + port = None + netloc = f"{host}:{port}" if port else host return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))