feat(auth): rate-limit the unauthenticated /auth/* edge
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s

Blanket per-endpoint limits would have been the wrong shape here. Every
REST/WS/MCP surface is owner-only — an unauthenticated request is rejected by
resolve_bearer/is_owner before any handler runs — so limiting them would
mostly throttle the single legitimate operator, and real spend control for
outbound calls is already max_concurrent_calls in gateway.make_call.

What is genuinely exposed is the handful of /auth/* routes that must answer
before an identity exists. /auth/callback and /auth/refresh-callback each make
an outbound token exchange with Casdoor on every request; /auth/me opens a DB
session and runs a token lookup. All are free to trigger and none are cheap to
serve. /auth/logout is left unlimited — it builds a redirect URL and does no
I/O.

Not a defence against credential guessing: PATs are secrets.token_urlsafe(32)
(256 bits) compared by SHA-256 digest, so brute force was never the threat.
This is about unauthenticated work an attacker controls.

Fixed-window, in-process, no new dependency — one operator and one process
make a shared counter store infrastructure without a purpose. The bucket store
is bounded and evicts oldest-first, since an unbounded map keyed by source
address would itself be the exhaustion vector.

The limiter keys on the socket peer and deliberately ignores X-Forwarded-For.
That header is attacker-controlled unless a trusted proxy overwrites it, and
this app establishes no such trust; keying on it would let one client present
as thousands and make the limiter worse than useless. Behind the estate's
reverse proxy the limit is therefore per-proxy, not per-caller — correct for
exhaustion and honest about what it can enforce. Per-caller limits need an
explicit trusted-proxy config, noted in CLAUDE.md so it isn't added silently.

Verified against a real server: exactly 30 requests pass, then 429 with
Retry-After: 60, while an owner-gated route serves 40/40. The 429s appear in
the JSON access log with queryable status_code and client_addr, so an attack
is visible in Loki. The wiring test identifies the dependency by qualname
rather than string search, and was mutation-checked by removing the limit from
/auth/me.

Also documents 401/403/429 in the API reference — 401 and 403 have existed
since auth landed but were never in the status-code table. Phase 4 is now
complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:46:54 -04:00
parent 59f0136370
commit 5c178bb7bd
6 changed files with 356 additions and 10 deletions

View File

@@ -15,15 +15,25 @@ non-owner sees an "access denied" screen instead of a bare 401.
import secrets
from urllib.parse import urlencode
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from auth import get_sdk, is_owner, resolve_from_header_or_query
from config import get_settings
from core.rate_limit import rate_limit
from db.database import session_scope
router = APIRouter(prefix="/auth", tags=["auth"])
# These are the only routes that must answer before an identity exists, so they
# are the only ones worth limiting — everything else is already owner-gated.
# `/callback` and `/refresh-callback` each trigger an outbound token exchange
# with Casdoor, and `/me` opens a DB session per call; all three are
# unauthenticated work an attacker controls. See core/rate_limit.py.
_limit_callback = [Depends(rate_limit("auth:callback", limit=10))]
_limit_me = [Depends(rate_limit("auth:me"))]
_limit_redirect = [Depends(rate_limit("auth:redirect"))]
def _build_casdoor_auth_url(
callback: str,
@@ -50,7 +60,7 @@ def _build_casdoor_auth_url(
return f"{c.endpoint.rstrip('/')}/login/oauth/authorize?{urlencode(params)}"
@router.get("/login")
@router.get("/login", dependencies=_limit_redirect)
async def login(request: Request, redirect_uri: str = Query(None)):
"""Redirect the browser to the Casdoor authorization page.
@@ -64,7 +74,7 @@ async def login(request: Request, redirect_uri: str = Query(None)):
return RedirectResponse(url=_build_casdoor_auth_url(callback))
@router.get("/callback")
@router.get("/callback", dependencies=_limit_callback)
async def callback(
code: str = Query(...),
state: str = Query(None),
@@ -89,7 +99,7 @@ async def callback(
return RedirectResponse(url=f"/#token={access_token}")
@router.get("/silent-refresh")
@router.get("/silent-refresh", dependencies=_limit_redirect)
async def silent_refresh(request: Request):
"""Start a silent token refresh via hidden iframe (``prompt=none``).
@@ -104,7 +114,7 @@ async def silent_refresh(request: Request):
return RedirectResponse(url=_build_casdoor_auth_url(callback, prompt="none"))
@router.get("/refresh-callback")
@router.get("/refresh-callback", dependencies=_limit_callback)
async def refresh_callback(
code: str = Query(None),
error: str = Query(None),
@@ -140,7 +150,7 @@ async def refresh_callback(
)
@router.get("/me")
@router.get("/me", dependencies=_limit_me)
async def me(request: Request):
"""Return the current authenticated user's profile + ``is_owner``.