Adds slowapi two-bucket rate limit on /auth/login — 10 attempts per 5 minutes per-IP AND per-username, tripping either → 429. Per-IP catches botnets hitting one account; per-username catches distributed credential stuffing against one account. In-memory storage: dashboard API is single-process, Redis is disproportionate for v1. X-Forwarded-For is deliberately NOT trusted (spoofable); reverse-proxy deployments get one shared bucket per proxy IP. Logged in the threat model as accepted risk DA-08, to be revisited when a verified-proxy config lands. Also scaffolds development/THREAT_MODEL.md with STRIDE-per-element methodology, system-context DFD, and Dashboard↔API as the first fully worked component (7 sub-flows, ~50 threat entries). F1 Authn ships with 3 threats mitigated: rate limit (new), uniform 401 (verified already in place), bcrypt length clamp (verified already in place via Pydantic max_length=72).
74 lines
2.8 KiB
Python
74 lines
2.8 KiB
Python
"""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]
|