"""Rate-limiting infra for the dashboard API. Uses slowapi (which wraps the `limits` library) with in-memory storage. In-memory is intentional for v1: - The dashboard API runs on a single process per host (the `decnet api` worker). Swarm agents do not serve the dashboard; there is no need for cross-process shared state. - Adding Redis as a hard dependency of the master for this one feature is disproportionate. Trust boundary note: `get_remote_address` uses `request.client.host`, i.e. the TCP peer's IP. We deliberately do NOT trust `X-Forwarded-For` because it is trivially spoofable by any client. Operators running DECNET behind a reverse proxy get one shared bucket for the whole proxy — that is an accepted limitation recorded in the threat model (see `development/THREAT_MODEL.md` §Dashboard↔API, DA-08). Revisit when we introduce a verified-proxy config. """ from __future__ import annotations import json from typing import Any, Awaitable, Callable from fastapi import Request from slowapi import Limiter from slowapi.util import get_remote_address # Single process-wide limiter. Importing modules pull this instance to # apply `@limiter.limit(...)` decorators on their routes. Default # headers off: FastAPI response_model handlers return dicts, not # Starlette Response objects, and slowapi's header injection only # supports the latter. Legit clients can back off on their own from # the 429 body; attackers ignore Retry-After anyway. limiter: Limiter = Limiter( key_func=get_remote_address, storage_uri="memory://", ) def login_ip_key(request: Request) -> str: """Per-IP bucket key for the login endpoint. Thin wrapper around slowapi's default so tests can monkey-patch this module attribute without reaching into slowapi internals. """ return f"login-ip:{get_remote_address(request)}" async def login_username_key(request: Request) -> str: """Per-username bucket key for the login endpoint. Reads the request body to extract the claimed username. The body is cached by Starlette, so FastAPI's subsequent Pydantic parsing still sees the same bytes. Malformed bodies all collapse to a single bucket — that is intentional; garbage traffic gets throttled as one bad actor rather than offered an escape hatch. """ try: body: bytes = await request.body() data: Any = json.loads(body or b"{}") username = data.get("username") if isinstance(data, dict) else None if isinstance(username, str) and username: return f"login-user:{username}" except (json.JSONDecodeError, UnicodeDecodeError, ValueError): pass return "login-user:__unparseable__" # Exported so tests can monkey-patch a synchronous counterpart if they # need deterministic keys without parsing bodies. LoginKeyFunc = Callable[[Request], Awaitable[str] | str]