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:
parent
162d9a886d
commit
1ace45353a
8 changed files with 252 additions and 4 deletions
65
tests/unit/test_unhandled_exception.py
Normal file
65
tests/unit/test_unhandled_exception.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue