diff --git a/litellm/proxy/middleware/security_headers_middleware.py b/litellm/proxy/middleware/security_headers_middleware.py index 77af1848d99..0e551eaba8a 100644 --- a/litellm/proxy/middleware/security_headers_middleware.py +++ b/litellm/proxy/middleware/security_headers_middleware.py @@ -8,6 +8,10 @@ X-Content-Type-Options: nosniff stops MIME sniffing. Strict-Transport-Security is opt-in via LITELLM_ENABLE_HSTS because it only makes sense over HTTPS and would lock browsers out of plain-http deployments. +Cache-Control: no-store is opt-in via LITELLM_ENABLE_NO_STORE to prevent +browsers and intermediaries from caching sensitive responses (e.g. key lists, +spend logs, user credentials). + Headers are set with setdefault so a route that intentionally sets its own value is never overridden. """ @@ -24,12 +28,17 @@ STATIC_SECURITY_HEADERS: Final = ( ("X-Content-Type-Options", "nosniff"), ) HSTS_HEADER: Final = ("Strict-Transport-Security", "max-age=31536000; includeSubDomains") +NO_STORE_HEADER: Final = ("Cache-Control", "no-store") def _hsts_enabled() -> bool: return os.getenv("LITELLM_ENABLE_HSTS", "false").strip().lower() == "true" +def _no_store_enabled() -> bool: + return os.getenv("LITELLM_ENABLE_NO_STORE", "false").strip().lower() == "true" + + class SecurityHeadersMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app @@ -42,7 +51,11 @@ class SecurityHeadersMiddleware: async def send_with_security_headers(message: Message) -> None: if message["type"] == "http.response.start": headers: Final = MutableHeaders(scope=message) - applied: Final = (*STATIC_SECURITY_HEADERS, HSTS_HEADER) if _hsts_enabled() else STATIC_SECURITY_HEADERS + applied: Final = ( + *STATIC_SECURITY_HEADERS, + *((HSTS_HEADER,) if _hsts_enabled() else ()), + *((NO_STORE_HEADER,) if _no_store_enabled() else ()), + ) for name, value in applied: headers.setdefault(name, value) await send(message) diff --git a/tests/documentation_tests/test_env_keys.py b/tests/documentation_tests/test_env_keys.py index 2a7da58f39c..5d5b62c8f80 100644 --- a/tests/documentation_tests/test_env_keys.py +++ b/tests/documentation_tests/test_env_keys.py @@ -32,6 +32,7 @@ EXCLUDED_ROLLOUT_FLAGS = { "LITELLM_USE_RUST_OCR", "LITELLM_RUST", "LITELLM_BUILTIN_PASS_THROUGH_ROUTES_FIRST", + "LITELLM_ENABLE_NO_STORE", } # Internal infrastructure tuning parameters for streaming/queue management diff --git a/tests/unit/proxy/middleware/test_security_headers_middleware.py b/tests/unit/proxy/middleware/test_security_headers_middleware.py index 48d1c937734..c85768b7a58 100644 --- a/tests/unit/proxy/middleware/test_security_headers_middleware.py +++ b/tests/unit/proxy/middleware/test_security_headers_middleware.py @@ -2,9 +2,12 @@ Tests for SecurityHeadersMiddleware. Verifies anti-framing / content-type headers are present on every response and -that HSTS is opt-in via LITELLM_ENABLE_HSTS. +that HSTS is opt-in via LITELLM_ENABLE_HSTS and Cache-Control: no-store is +opt-in via LITELLM_ENABLE_NO_STORE. """ +from typing import Final + from starlette.applications import Starlette from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse, Response @@ -69,3 +72,45 @@ def test_does_not_override_existing_header(monkeypatch): assert resp.headers["x-frame-options"] == "SAMEORIGIN" # other headers still applied assert resp.headers["x-content-type-options"] == "nosniff" + + +def test_no_store_absent_by_default(monkeypatch): + monkeypatch.delenv("LITELLM_ENABLE_NO_STORE", raising=False) + resp: Final = _make_client(_ok).get("/") + assert "cache-control" not in resp.headers + + +def test_no_store_present_when_enabled(monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_NO_STORE", "true") + resp: Final = _make_client(_ok).get("/") + assert resp.headers["cache-control"] == "no-store" + + +def test_no_store_not_enabled_by_arbitrary_value(monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_NO_STORE", "1") + resp: Final = _make_client(_ok).get("/") + assert "cache-control" not in resp.headers + + +def test_no_store_does_not_override_existing_header(monkeypatch): + """A route that sets its own Cache-Control must win.""" + + async def custom(request): + return Response("hi", headers={"Cache-Control": "private, max-age=60"}) + + monkeypatch.setenv("LITELLM_ENABLE_NO_STORE", "true") + resp: Final = _make_client(custom).get("/") + assert resp.headers["cache-control"] == "private, max-age=60" + assert resp.headers["x-content-type-options"] == "nosniff" + + +def test_both_hsts_and_no_store_enabled(monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_HSTS", "true") + monkeypatch.setenv("LITELLM_ENABLE_NO_STORE", "true") + resp: Final = _make_client(_ok).get("/") + assert resp.headers["strict-transport-security"] == ( + "max-age=31536000; includeSubDomains" + ) + assert resp.headers["cache-control"] == "no-store" + assert resp.headers["x-frame-options"] == "DENY" +