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.
Some checks failed
ci / validate (push) Has been cancelled
Some checks failed
ci / validate (push) Has been cancelled
This commit is contained in:
54
app/security.py
Normal file
54
app/security.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||||
|
|
||||||
|
|
||||||
|
class UnsafeTargetError(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)
|
||||||
|
|
||||||
|
|
||||||
|
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 "/", "", ""))
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
try:
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise UnsafeTargetError("invalid port") from exc
|
||||||
|
try:
|
||||||
|
addresses = await resolver(parsed.hostname, port)
|
||||||
|
except (OSError, socket.gaierror) as exc:
|
||||||
|
raise UnsafeTargetError("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
|
||||||
Reference in New Issue
Block a user