From 5ea7874a2ff238f379bdaf25dc1c54e1837d0cda Mon Sep 17 00:00:00 2001 From: Nabi Bukhsh Date: Tue, 13 Oct 2026 05:25:34 +0500 Subject: [PATCH] feat(proxy): support LITELLM_ENABLE_NO_STORE in SecurityHeadersMiddleware Add opt-in Cache-Control: no-store header via LITELLM_ENABLE_NO_STORE env var to prevent browsers and proxies from caching sensitive responses. Uses setdefault so route-specific Cache-Control headers are preserved. Closes #46078 --- .../middleware/security_headers_middleware.py | 15 ++++++- .../test_security_headers_middleware.py | 45 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/middleware/security_headers_middleware.py b/litellm/proxy/middleware/security_headers_middleware.py index 77af1848d99..01e8ade8241 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: list[tuple[str, str]] = list(STATIC_SECURITY_HEADERS) + if _hsts_enabled(): + applied.append(HSTS_HEADER) + if _no_store_enabled(): + applied.append(NO_STORE_HEADER) for name, value in applied: headers.setdefault(name, value) await send(message) diff --git a/tests/unit/proxy/middleware/test_security_headers_middleware.py b/tests/unit/proxy/middleware/test_security_headers_middleware.py index 48d1c937734..cebe3645879 100644 --- a/tests/unit/proxy/middleware/test_security_headers_middleware.py +++ b/tests/unit/proxy/middleware/test_security_headers_middleware.py @@ -2,7 +2,8 @@ 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 starlette.applications import Starlette @@ -69,3 +70,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 = _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 = _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 = _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 = _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 = _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" +