Add API anti-abuse hardening: disable docs in prod, origin validator, exception handler

- Disable OpenAPI docs/redoc/openapi.json when APP_ENV=production
- Strip uvicorn Server header with --no-server-header in Dockerfile and docker-compose.yml
- Add OriginValidatorMiddleware to reject state-changing requests from disallowed origins
- Add global exception handler to prevent stack trace leakage on unhandled errors
- Add tests for all new security features (OpenAPI, origin validation, exception handler, server header)
This commit is contained in:
Viktor Barzin 2026-02-08 20:06:46 +00:00
parent 162d9a886d
commit 1ace45353a
No known key found for this signature in database
GPG key ID: 0EB088298288D958
8 changed files with 252 additions and 4 deletions

View file

@ -13,9 +13,11 @@ from api.rate_limiter import RateLimitMiddleware
from api.audit_middleware import AuditLogMiddleware
from api.metrics_guard import MetricsGuardMiddleware
from api.security_headers import SecurityHeadersMiddleware
from api.origin_validator import OriginValidatorMiddleware
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.requests import Request
from api.auth import User
from models.listing import QueryParameters, ListingType, FurnishType
from notifications import send_notification
@ -85,7 +87,11 @@ def get_query_parameters(
)
app = FastAPI()
app = FastAPI(
docs_url=None if APP_ENV == "production" else "/docs",
redoc_url=None if APP_ENV == "production" else "/redoc",
openapi_url=None if APP_ENV == "production" else "/openapi.json",
)
app.include_router(passkey_router)
app.include_router(poi_router)
app.mount("/metrics", metrics_app)
@ -108,6 +114,11 @@ app.add_middleware(
allow_headers=["Authorization", "Content-Type"],
)
app.add_middleware(
OriginValidatorMiddleware,
allowed_origins=[*DEV_TIER_ORIGINS, *PROD_TIER_ORIGINS],
)
# Security middleware (added bottom-to-top; last added = outermost)
# 3. Rate limiting — enforces per-user limits
app.add_middleware(RateLimitMiddleware, config=_rate_limit_config)
@ -119,6 +130,15 @@ app.add_middleware(AuditLogMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled exception")
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
@app.get("/api/status")
async def get_status() -> dict[str, str]:
request_counter.add(1, {"method": "GET", "path": "/status"})

34
api/origin_validator.py Normal file
View file

@ -0,0 +1,34 @@
"""Origin validation middleware for state-changing requests."""
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
logger = logging.getLogger("uvicorn")
STATE_CHANGING_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
class OriginValidatorMiddleware(BaseHTTPMiddleware):
"""Reject state-changing requests with mismatched Origin header."""
def __init__(self, app, allowed_origins: list[str] | None = None) -> None:
super().__init__(app)
self._allowed = {o.rstrip("/") for o in (allowed_origins or [])}
async def dispatch(self, request: Request, call_next) -> Response:
if request.method not in STATE_CHANGING_METHODS:
return await call_next(request)
origin = request.headers.get("origin")
if origin is None:
return await call_next(request)
if origin.rstrip("/") not in self._allowed:
logger.warning(f"Rejected request from origin: {origin}")
return JSONResponse(
status_code=403,
content={"detail": "Origin not allowed"},
)
return await call_next(request)