From ba3aadd5931574a8a2ab766de2985b877df2b18c Mon Sep 17 00:00:00 2001 From: nutellasando Date: Sun, 18 Oct 2026 11:53:57 +0000 Subject: [PATCH 1/3] feat: add security headers and no-store to API responses --- README.md | 12 ++++ app/app.py | 21 +++++++ pyproject.toml | 2 +- tests/integration/test_app_integration.py | 71 +++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a6210ca..481e40f 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,18 @@ only the fields not asked for in `fields` are, and fields come back in the order Everything else in PESU Academy's profile information, such as the photo, blood group, addresses, parents' details and marks, is discarded and never returned, and cannot be requested in `fields`. +### 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` + ### `/health` Answers `200` whenever the process is serving; a `500` would come from the catch-all handler, as on any other endpoint. diff --git a/app/app.py b/app/app.py index e2e2e18..61becc9 100644 --- a/app/app.py +++ b/app/app.py @@ -53,6 +53,12 @@ # Enforced only when METRICS_TOKEN is set in the environment METRICS_DEPENDENCIES = [Depends(require_metrics_token)] +SECURITY_HEADERS: dict[str, str] = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", +} + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: @@ -172,6 +178,21 @@ 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 responses and prevent caching for /authenticate. + + Handled responses (like 400, 401, 502 ...) pass through the middleware as they become normal responses + below it. But unhandled 500 exceptions are rendered above the middleware, and thus not seen by it. + """ + response = await call_next(request) + for header, value in SECURITY_HEADERS.items(): + response.headers[header] = value + if request.url.path == "/authenticate": + response.headers["Cache-Control"] = "no-store" + return response + + @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """Handler for request validation errors.""" 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..617a2f6 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.base import PESUAcademyError unhandled_router = APIRouter() @@ -516,3 +517,73 @@ 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 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.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert response.headers["X-Frame-Options"] == "DENY" + # Since Cache-Control is not set on these routes, verify key absence or that it's not no-store + assert "Cache-Control" not in response.headers or response.headers["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 response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["Cache-Control"] == "no-store" + + +def test_integration_root_playground_loads(client): + """The root playground endpoint loads successfully with standard security headers.""" + response = client.get("/") + assert response.status_code == 200 + 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_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 response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert response.headers["X-Frame-Options"] == "DENY" + 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 PESUAcademyError(message="Invalid credentials", status_code=401) + + 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 response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert response.headers["X-Frame-Options"] == "DENY" + 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" }, From 84832ae7f672f0452c898794260278d8d94363d0 Mon Sep 17 00:00:00 2001 From: nutellasando Date: Mon, 19 Oct 2026 05:39:15 +0000 Subject: [PATCH 2/3] fix: set security headers on unhandled 500 responses and document Cache-Control - Attach security headers and Cache-Control: no-store directly in unhandled_exception_handler so 500 responses from ServerErrorMiddleware carry them. - Document Cache-Control: no-store in authenticate OpenAPI schema. - Add integration tests for unhandled 500 responses on general routes and /authenticate. - Relocate Response headers section in README above endpoint descriptions. --- README.md | 24 ++++----- app/app.py | 19 +++++-- app/docs/authenticate.py | 13 +++++ tests/integration/test_app_integration.py | 66 ++++++++++++++--------- 4 files changed, 80 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 481e40f..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 @@ -236,18 +248,6 @@ only the fields not asked for in `fields` are, and fields come back in the order Everything else in PESU Academy's profile information, such as the photo, blood group, addresses, parents' details and marks, is discarded and never returned, and cannot be requested in `fields`. -### 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` - ### `/health` Answers `200` whenever the process is serving; a `500` would come from the catch-all handler, as on any other endpoint. diff --git a/app/app.py b/app/app.py index 61becc9..712ae9b 100644 --- a/app/app.py +++ b/app/app.py @@ -53,6 +53,7 @@ # 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", @@ -60,6 +61,14 @@ } +def _security_headers(request: Request) -> dict[str, str]: + """Build security headers for a response, preventing caching 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]: """Lifespan event handler for startup and shutdown events.""" @@ -180,16 +189,15 @@ async def metrics_middleware(request: Request, call_next: RequestResponseEndpoin @app.middleware("http") async def security_headers_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response: - """Add security headers to all responses and prevent caching for /authenticate. + """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. But unhandled 500 exceptions are rendered above the middleware, and thus not seen by it. + 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.items(): + for header, value in _security_headers(request).items(): response.headers[header] = value - if request.url.path == "/authenticate": - response.headers["Cache-Control"] = "no-store" return response @@ -299,6 +307,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/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index 617a2f6..8a448ab 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from app.app import app -from app.exceptions.base import PESUAcademyError +from app.exceptions.authentication import AuthenticationError unhandled_router = APIRouter() @@ -519,36 +519,30 @@ def test_integration_authenticate_over_long_values_not_logged(client, caplog, ov 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.headers["X-Content-Type-Options"] == "nosniff" - assert response.headers["Referrer-Policy"] == "no-referrer" - assert response.headers["X-Frame-Options"] == "DENY" - # Since Cache-Control is not set on these routes, verify key absence or that it's not no-store - assert "Cache-Control" not in response.headers or response.headers["Cache-Control"] != "no-store" + 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 response.headers["X-Content-Type-Options"] == "nosniff" - assert response.headers["Referrer-Policy"] == "no-referrer" - assert response.headers["X-Frame-Options"] == "DENY" + assert_security_headers(response) assert response.headers["Cache-Control"] == "no-store" -def test_integration_root_playground_loads(client): - """The root playground endpoint loads successfully with standard security headers.""" - response = client.get("/") - assert response.status_code == 200 - 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_authenticate_success_headers(client, monkeypatch): """Test 200 OK has all security headers and no-store.""" @@ -563,9 +557,7 @@ async def fake_success(*args, **kwargs): ) assert response.status_code == 200 - assert response.headers["X-Content-Type-Options"] == "nosniff" - assert response.headers["Referrer-Policy"] == "no-referrer" - assert response.headers["X-Frame-Options"] == "DENY" + assert_security_headers(response) assert response.headers["Cache-Control"] == "no-store" @@ -573,7 +565,7 @@ 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 PESUAcademyError(message="Invalid credentials", status_code=401) + raise AuthenticationError("Invalid credentials") monkeypatch.setattr("app.app.pesu_academy.authenticate", fake_unauthorized) @@ -583,7 +575,31 @@ async def fake_unauthorized(*args, **kwargs): ) assert response.status_code == 401 - assert response.headers["X-Content-Type-Options"] == "nosniff" - assert response.headers["Referrer-Policy"] == "no-referrer" - assert response.headers["X-Frame-Options"] == "DENY" + 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" From 15e8f9d4697876a48a8a1717f3d70a32100a2b7d Mon Sep 17 00:00:00 2001 From: nutellasando <79259699+nutellasando@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:28:43 +0530 Subject: [PATCH 3/3] Update app/app.py Co-authored-by: Aditeya Baral --- app/app.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/app.py b/app/app.py index 712ae9b..30f258c 100644 --- a/app/app.py +++ b/app/app.py @@ -62,7 +62,17 @@ def _security_headers(request: Request) -> dict[str, str]: - """Build security headers for a response, preventing caching for /authenticate.""" + """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"