Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Comment thread
aditeyabaral marked this conversation as resolved.
"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]:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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),
)


Expand Down
13 changes: 13 additions & 0 deletions app/docs/authenticate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -46,6 +53,7 @@
response_examples={
200: {
"description": "Successful Authentication",
"headers": CACHE_CONTROL_HEADER,
"model": ResponseModel,
"content": {
"application/json": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand All @@ -275,6 +286,7 @@
},
500: {
"description": "Internal Server Error",
"headers": CACHE_CONTROL_HEADER,
"model": ResponseModel,
"content": {
"application/json": {
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
87 changes: 87 additions & 0 deletions tests/integration/test_app_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi.testclient import TestClient

from app.app import app
from app.exceptions.authentication import AuthenticationError

unhandled_router = APIRouter()

Expand Down Expand Up @@ -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"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading