diff --git a/README.md b/README.md index 0f767f0..3cb46ef 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ Passwords are hashed with Argon2 rather than stored as plain text. Secret values The API decrypts a secret when returning it to its authenticated owner. This is server-side encryption, not end-to-end encryption. +Authentication and secret-management responses include `Cache-Control: no-store` so clients and intermediaries are instructed not to cache access tokens or decrypted secret values. + Successful secret creation, listing, retrieval, updates, and deletion are recorded in `audit_events` with the action, user ID, secret ID when applicable, and timestamp. Secret values are not included in these events. Authentication attempts are not currently audited. The Docker Compose configuration contains credentials and an encryption key intended only for local development. Do not reuse these values in a deployed environment. Use unique secrets supplied through an appropriate secret manager or deployment configuration. diff --git a/app/main.py b/app/main.py index 3500830..432ed43 100644 --- a/app/main.py +++ b/app/main.py @@ -35,6 +35,17 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(SlowAPIMiddleware) + + +@app.middleware("http") +async def prevent_sensitive_response_caching(request: Request, call_next): + response = await call_next(request) + path = request.url.path + if path == "/secrets" or path.startswith(("/auth/", "/secrets/")): + response.headers["Cache-Control"] = "no-store" + return response + + bearer_scheme = HTTPBearer(auto_error=False) Session = Annotated[AsyncSession, Depends(get_session)] # Keep the password verification cost similar for unknown and existing accounts. diff --git a/tests/test_auth.py b/tests/test_auth.py index 498ad0f..d94b76b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -30,7 +30,9 @@ def test_login_rejects_invalid_password() -> None: ) assert registered.status_code == 201 + assert registered.headers["cache-control"] == "no-store" assert invalid_login.status_code == 401 + assert invalid_login.headers["cache-control"] == "no-store" assert invalid_login.json()["detail"] == "Invalid credentials" diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 8edc9e0..dd6ce5e 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -39,6 +39,7 @@ def test_secret_lifecycle_and_user_isolation() -> None: json={"name": "database", "value": "initial-secret"}, ) assert created.status_code == 201 + assert created.headers["cache-control"] == "no-store" secret = created.json() secret_id = secret["id"] assert secret["name"] == "database" @@ -46,10 +47,12 @@ def test_secret_lifecycle_and_user_isolation() -> None: listed = client.get("/secrets", headers=owner_headers) assert listed.status_code == 200 + assert listed.headers["cache-control"] == "no-store" assert [item["id"] for item in listed.json()] == [secret_id] other_access = client.get(f"/secrets/{secret_id}", headers=other_headers) assert other_access.status_code == 404 + assert other_access.headers["cache-control"] == "no-store" updated = client.patch( f"/secrets/{secret_id}", @@ -57,15 +60,18 @@ def test_secret_lifecycle_and_user_isolation() -> None: json={"name": "database-prod", "value": "updated-secret"}, ) assert updated.status_code == 200 + assert updated.headers["cache-control"] == "no-store" assert updated.json()["name"] == "database-prod" assert updated.json()["value"] == "updated-secret" fetched = client.get(f"/secrets/{secret_id}", headers=owner_headers) assert fetched.status_code == 200 + assert fetched.headers["cache-control"] == "no-store" assert fetched.json()["value"] == "updated-secret" deleted = client.delete(f"/secrets/{secret_id}", headers=owner_headers) assert deleted.status_code == 204 + assert deleted.headers["cache-control"] == "no-store" missing = client.get(f"/secrets/{secret_id}", headers=owner_headers) assert missing.status_code == 404