From d6ebbf75d05947db1286e1db937d9c9db781d146 Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Sun, 15 Mar 2026 18:07:53 -0400 Subject: [PATCH] security: brute-force protection on auth endpoints (v1.1.0) - Nginx rate limit: 20 req/min per IP on /api/auth/login and /register - slowapi rate limit: 10/min on login, 5/hour on register (Redis-backed) - Real client IP extracted from X-Forwarded-For for accurate per-IP limiting Authored by: Jack Levy --- backend/app/api/auth.py | 9 ++++++--- backend/app/core/limiter.py | 13 +++++++++++++ backend/app/main.py | 6 ++++++ backend/requirements.txt | 4 ++++ nginx/nginx.conf | 15 +++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 backend/app/core/limiter.py diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index c2d1cd2..8cbcbda 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.dependencies import get_current_user +from app.core.limiter import limiter from app.core.security import create_access_token, hash_password, verify_password from app.database import get_db from app.models.user import User @@ -12,7 +13,8 @@ router = APIRouter() @router.post("/register", response_model=TokenResponse, status_code=201) -async def register(body: UserCreate, db: AsyncSession = Depends(get_db)): +@limiter.limit("5/hour") +async def register(request: Request, body: UserCreate, db: AsyncSession = Depends(get_db)): if len(body.password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") if "@" not in body.email: @@ -40,7 +42,8 @@ async def register(body: UserCreate, db: AsyncSession = Depends(get_db)): @router.post("/login", response_model=TokenResponse) -async def login(body: UserCreate, db: AsyncSession = Depends(get_db)): +@limiter.limit("10/minute") +async def login(request: Request, body: UserCreate, db: AsyncSession = Depends(get_db)): result = await db.execute(select(User).where(User.email == body.email.lower())) user = result.scalar_one_or_none() diff --git a/backend/app/core/limiter.py b/backend/app/core/limiter.py new file mode 100644 index 0000000..cc511b0 --- /dev/null +++ b/backend/app/core/limiter.py @@ -0,0 +1,13 @@ +from slowapi import Limiter + + +def _get_real_ip(request) -> str: + """Extract real client IP, respecting X-Forwarded-For from trusted proxies.""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +# Redis DB 1 keeps rate-limit counters separate from Celery (DB 0) +limiter = Limiter(key_func=_get_real_ip, storage_uri="redis://redis:6379/1") diff --git a/backend/app/main.py b/backend/app/main.py index 168d870..d255768 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes, collections, share, alignment from app.config import settings as config +from app.core.limiter import limiter app = FastAPI( title="PocketVeto", @@ -10,6 +13,9 @@ app = FastAPI( version="1.0.0", ) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + app.add_middleware( CORSMiddleware, allow_origins=[o for o in [config.LOCAL_URL, config.PUBLIC_URL] if o], diff --git a/backend/requirements.txt b/backend/requirements.txt index f40f701..8503a0e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -44,6 +44,10 @@ python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 bcrypt==4.0.1 +# Rate limiting +slowapi==0.1.9 +limits==3.13.0 + # Utilities python-dateutil==2.9.0 tiktoken==0.8.0 diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2180f89..085c71d 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -22,6 +22,10 @@ http { '"$http_user_agent"'; access_log /var/log/nginx/access.log main; + # Rate limiting — cap auth endpoints at 20 req/min per IP at the proxy layer + limit_req_zone $binary_remote_addr zone=auth:10m rate=20r/m; + limit_req_status 429; + # Use Docker's internal DNS; valid=10s forces re-resolution after container restarts. # Variables in proxy_pass activate this resolver (upstream blocks do not). resolver 127.0.0.11 valid=10s ipv6=off; @@ -32,6 +36,17 @@ http { client_max_body_size 10M; + # Auth endpoints — rate limited at proxy layer + location ~ ^/api/auth/(login|register)$ { + set $api http://api:8000; + limit_req zone=auth burst=5 nodelay; + proxy_pass $api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # API — variable forces re-resolution via resolver on each request cycle location /api/ { set $api http://api:8000;