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:
@@ -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
73
decnet/web/limiter.py
Normal 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]
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user