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

@ -0,0 +1,65 @@
"""Unit tests for the unhandled exception handler in api/app.py."""
from unittest import mock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from starlette.requests import Request
from starlette.testclient import TestClient
def _build_app() -> FastAPI:
"""Build a minimal FastAPI app with the unhandled exception handler."""
app = FastAPI()
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
@app.get("/ok")
async def ok() -> dict[str, str]:
return {"status": "ok"}
@app.get("/crash")
async def crash() -> None:
raise RuntimeError("something unexpected")
@app.get("/http-error")
async def http_error() -> None:
raise HTTPException(status_code=403, detail="forbidden")
return app
class TestUnhandledExceptionHandler:
"""Tests for the global exception handler."""
def test_normal_request_unaffected(self) -> None:
client = TestClient(_build_app())
resp = client.get("/ok")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
def test_unhandled_exception_returns_generic_500(self) -> None:
client = TestClient(_build_app(), raise_server_exceptions=False)
resp = client.get("/crash")
assert resp.status_code == 500
assert resp.json() == {"detail": "Internal server error"}
def test_http_exception_not_caught(self) -> None:
"""HTTPException should still be handled by FastAPI's built-in handler."""
client = TestClient(_build_app())
resp = client.get("/http-error")
assert resp.status_code == 403
assert resp.json() == {"detail": "forbidden"}
def test_error_body_does_not_leak_details(self) -> None:
"""The response must not contain the actual exception message."""
client = TestClient(_build_app(), raise_server_exceptions=False)
resp = client.get("/crash")
body = resp.text
assert "something unexpected" not in body
assert "RuntimeError" not in body