diff --git a/README.md b/README.md index a6210ca..de187e5 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,18 @@ API. The response's **Copy** button copies only the body, without its status or The Explorer's HTML, styles, and scripts live in `app/templates/playground.html` and are served directly from that file. It requires no frontend build step or third-party UI dependencies. +### Response headers + +Every response includes the following baseline security headers: + +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: no-referrer` +- `X-Frame-Options: DENY` + +Responses to `/authenticate` also include: + +- `Cache-Control: no-store` + ### `/authenticate` You can send a request to the `/authenticate` endpoint with the user's credentials and the API will return a JSON diff --git a/app/app.py b/app/app.py index e2e2e18..30f258c 100644 --- a/app/app.py +++ b/app/app.py @@ -53,6 +53,31 @@ # Enforced only when METRICS_TOKEN is set in the environment METRICS_DEPENDENCIES = [Depends(require_metrics_token)] +# Sent on every response, as the OWASP REST Security Cheat Sheet recommends +SECURITY_HEADERS: dict[str, str] = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", +} + + +def _security_headers(request: Request) -> dict[str, str]: + """Build the security headers for a response. + + Used by both the middleware and the unhandled-exception handler, since ServerErrorMiddleware renders an + unhandled 500 above the middleware. + + Args: + request (Request): The request being answered. + + Returns: + dict[str, str]: The general security headers, plus `Cache-Control: no-store` for /authenticate. + """ + headers = dict(SECURITY_HEADERS) + if request.url.path == "/authenticate": + headers["Cache-Control"] = "no-store" + return headers + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: @@ -172,6 +197,20 @@ async def metrics_middleware(request: Request, call_next: RequestResponseEndpoin return await record_request_metrics(metrics, request, call_next) +@app.middleware("http") +async def security_headers_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response: + """Add security headers to all handled responses and prevent caching for /authenticate. + + Handled responses (like 400, 401, 502 ...) pass through the middleware as they become normal responses + below it. Unhandled 500 exceptions are rendered above this middleware by ServerErrorMiddleware, so + unhandled_exception_handler attaches these headers directly to its response. + """ + response = await call_next(request) + for header, value in _security_headers(request).items(): + response.headers[header] = value + return response + + @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """Handler for request validation errors.""" @@ -278,6 +317,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR "message": "Internal Server Error. Please try again later.", "timestamp": datetime.datetime.now(IST).isoformat(), }, + headers=_security_headers(request), ) diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index 3a66cf5..fd48eec 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -3,6 +3,13 @@ from app.docs.base import ApiDocs from app.models import ResponseModel +CACHE_CONTROL_HEADER = { + "Cache-Control": { + "description": "Directs clients and proxies not to store any part of the response.", + "schema": {"type": "string", "example": "no-store"}, + } +} + authenticate_docs = ApiDocs( request_examples={ "requestBody": { @@ -46,6 +53,7 @@ response_examples={ 200: { "description": "Successful Authentication", + "headers": CACHE_CONTROL_HEADER, "model": ResponseModel, "content": { "application/json": { @@ -151,6 +159,7 @@ "profile field, or an empty fields list. The message names each field that failed and why. A body " "that cannot be read at all, such as JSON that is not valid UTF-8, gets a fixed message instead." ), + "headers": CACHE_CONTROL_HEADER, "model": ResponseModel, "content": { "application/json": { @@ -246,6 +255,7 @@ "description": ( "Unauthorized - PESU Academy rejected the credentials: a wrong password, or a user that does not exist." ), + "headers": CACHE_CONTROL_HEADER, "model": ResponseModel, "content": { "application/json": { @@ -262,6 +272,7 @@ "Unprocessable Entity - PESU Academy's profile response could not be parsed, or reported success " "without any STUDENT_INFO, which means their API changed. Only when the profile was requested." ), + "headers": CACHE_CONTROL_HEADER, "model": ResponseModel, "content": { "application/json": { @@ -275,6 +286,7 @@ }, 500: { "description": "Internal Server Error", + "headers": CACHE_CONTROL_HEADER, "model": ResponseModel, "content": { "application/json": { @@ -291,6 +303,7 @@ "Bad Gateway - PESU Academy could not be reached, timed out, or answered the login or profile request " "unexpectedly, or declined to serve the profile." ), + "headers": CACHE_CONTROL_HEADER, "model": ResponseModel, "content": { "application/json": { diff --git a/pyproject.toml b/pyproject.toml index f15b515..09b5114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pesu-auth" -version = "5.3.0" +version = "5.4.0" description = "A simple API to authenticate PESU credentials using PESU Academy." readme = "README.md" requires-python = ">=3.14" diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index d2af2d1..8a448ab 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -5,6 +5,7 @@ from fastapi.testclient import TestClient from app.app import app +from app.exceptions.authentication import AuthenticationError unhandled_router = APIRouter() @@ -516,3 +517,89 @@ def test_integration_authenticate_over_long_values_not_logged(client, caplog, ov assert "Request data could not be validated" in caplog.text assert over_long_password not in caplog.text assert over_long_username not in caplog.text + + +def assert_security_headers(response): + """Verify standard security headers are present on a response.""" + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert response.headers["X-Frame-Options"] == "DENY" + + +def test_integration_security_headers_on_general_endpoints(client): + """General endpoints must carry standard security headers and not carry Cache-Control: no-store.""" + for path in ["/", "/health", "/metrics"]: + response = client.get(path) + assert response.status_code == 200 + assert_security_headers(response) + assert response.headers.get("Cache-Control") != "no-store" + + +def test_integration_authenticate_headers_on_validation_error(client): + """The /authenticate endpoint must return security headers and Cache-Control: no-store on error.""" + response = client.post("/authenticate", json={}) + assert response.status_code == 400 + assert_security_headers(response) + assert response.headers["Cache-Control"] == "no-store" + + +def test_integration_authenticate_success_headers(client, monkeypatch): + """Test 200 OK has all security headers and no-store.""" + + async def fake_success(*args, **kwargs): + return {"status": True, "message": "Login successful."} + + monkeypatch.setattr("app.app.pesu_academy.authenticate", fake_success) + + response = client.post( + "/authenticate", + json={"username": "dummy_user", "password": "dummy_password"}, + ) + + assert response.status_code == 200 + assert_security_headers(response) + assert response.headers["Cache-Control"] == "no-store" + + +def test_integration_authenticate_401_headers(client, monkeypatch): + """Test 401 Unauthorized has all security headers and no-store.""" + + async def fake_unauthorized(*args, **kwargs): + raise AuthenticationError("Invalid credentials") + + monkeypatch.setattr("app.app.pesu_academy.authenticate", fake_unauthorized) + + response = client.post( + "/authenticate", + json={"username": "dummy_user", "password": "wrong_password"}, + ) + + assert response.status_code == 401 + assert_security_headers(response) + assert response.headers["Cache-Control"] == "no-store" + + +def test_unhandled_exception_headers(client): + """Unhandled exceptions on general endpoints return 500 with general security headers.""" + response = client.get("/raiseUnhandled") + assert response.status_code == 500 + assert_security_headers(response) + assert response.headers.get("Cache-Control") != "no-store" + + +def test_unhandled_exception_authenticate_headers(client, monkeypatch): + """Unhandled exceptions on /authenticate return 500 with all security headers and no-store.""" + + async def fake_crash(*args, **kwargs): + raise RuntimeError("Unexpected failure") + + monkeypatch.setattr("app.app.pesu_academy.authenticate", fake_crash) + + response = client.post( + "/authenticate", + json={"username": "dummy_user", "password": "dummy_password"}, + ) + + assert response.status_code == 500 + assert_security_headers(response) + assert response.headers["Cache-Control"] == "no-store" diff --git a/uv.lock b/uv.lock index 4ce3ae1..96c43e6 100644 --- a/uv.lock +++ b/uv.lock @@ -899,7 +899,7 @@ wheels = [ [[package]] name = "pesu-auth" -version = "5.3.0" +version = "5.4.0" source = { editable = "." } dependencies = [ { name = "fastapi" },