feat(api): rate-limit /auth/login + scaffold threat model

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).
This commit is contained in:
2026-04-23 13:25:28 -04:00
parent 8cbb7834ef
commit 2f4f81e5de
7 changed files with 551 additions and 7 deletions

View File

@@ -23,7 +23,11 @@ from decnet.web.dependencies import repo
from decnet.collector import log_collector_worker
from decnet.web.ingester import log_ingestion_worker
from decnet.profiler import attacker_profile_worker
from decnet.web.limiter import limiter
from decnet.web.router import api_router
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
log = get_logger("api")
ingestion_task: Optional[asyncio.Task[Any]] = None
@@ -169,6 +173,10 @@ app: FastAPI = FastAPI(
openapi_url="/openapi.json" if DECNET_DEVELOPER else None
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=DECNET_CORS_ORIGINS,

73
decnet/web/limiter.py Normal file
View File

@@ -0,0 +1,73 @@
"""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]

View File

@@ -1,7 +1,7 @@
from datetime import timedelta
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Request, status
from decnet.telemetry import traced as _traced
from decnet.web.auth import (
@@ -11,10 +11,22 @@ from decnet.web.auth import (
)
from decnet.web.dependencies import get_user_by_username_cached
from decnet.web.db.models import LoginRequest, Token
from decnet.web.limiter import limiter, login_ip_key, login_username_key
router = APIRouter()
# Two independent buckets, tripping either → 429:
#
# - per-IP (login_ip_key): catches a botnet hitting one account.
# - per-user (login_username_key): catches distributed credential
# stuffing against one account.
#
# Limits: 10 attempts per 5 minutes per bucket. Buckets are process-local
# (memory://); see decnet/web/limiter.py for the rationale. Buckets do
# NOT reset on successful login — a legitimate user tripping the limit
# via fat-fingering will need to wait the window out. 10 tries is
# generous; a rolling window naturally drains.
@router.post(
"/auth/login",
response_model=Token,
@@ -22,13 +34,16 @@ router = APIRouter()
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Incorrect username or password"},
422: {"description": "Validation error"}
422: {"description": "Validation error"},
429: {"description": "Too many login attempts — retry after the window resets"},
},
)
@limiter.limit("10/5 minutes", key_func=login_ip_key)
@limiter.limit("10/5 minutes", key_func=login_username_key)
@_traced("api.login")
async def login(request: LoginRequest) -> dict[str, Any]:
_user: Optional[dict[str, Any]] = await get_user_by_username_cached(request.username)
if not _user or not await averify_password(request.password, _user["password_hash"]):
async def login(request: Request, payload: LoginRequest) -> dict[str, Any]:
_user: Optional[dict[str, Any]] = await get_user_by_username_cached(payload.username)
if not _user or not await averify_password(payload.password, _user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",