From 5ea7874a2ff238f379bdaf25dc1c54e1837d0cda Mon Sep 17 00:00:00 2001 From: Nabi Bukhsh Date: Tue, 13 Oct 2026 05:25:34 +0500 Subject: [PATCH 1/2] 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" + From d95aaa34418ea3ddb0099045b211fc081f12a66a Mon Sep 17 00:00:00 2001 From: Nabi Bukhsh Date: Tue, 13 Oct 2026 05:48:10 +0500 Subject: [PATCH 2/2] fix(proxy): enforce immutable headers and register env key in test_env_keys Build applied headers tuple functionally with Final annotation to satisfy LIT001 and LIT010. Register LITELLM_ENABLE_NO_STORE in EXCLUDED_ROLLOUT_FLAGS in test_env_keys.py. Annotate resp with Final in test_security_headers_middleware.py. --- .../proxy/middleware/security_headers_middleware.py | 10 +++++----- tests/documentation_tests/test_env_keys.py | 1 + .../middleware/test_security_headers_middleware.py | 12 +++++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/middleware/security_headers_middleware.py b/litellm/proxy/middleware/security_headers_middleware.py index 01e8ade8241..0e551eaba8a 100644 --- a/litellm/proxy/middleware/security_headers_middleware.py +++ b/litellm/proxy/middleware/security_headers_middleware.py @@ -51,11 +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: list[tuple[str, str]] = list(STATIC_SECURITY_HEADERS) - if _hsts_enabled(): - applied.append(HSTS_HEADER) - if _no_store_enabled(): - applied.append(NO_STORE_HEADER) + 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 cebe3645879..c85768b7a58 100644 --- a/tests/unit/proxy/middleware/test_security_headers_middleware.py +++ b/tests/unit/proxy/middleware/test_security_headers_middleware.py @@ -6,6 +6,8 @@ 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 @@ -74,19 +76,19 @@ def test_does_not_override_existing_header(monkeypatch): def test_no_store_absent_by_default(monkeypatch): monkeypatch.delenv("LITELLM_ENABLE_NO_STORE", raising=False) - resp = _make_client(_ok).get("/") + 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 = _make_client(_ok).get("/") + 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 = _make_client(_ok).get("/") + resp: Final = _make_client(_ok).get("/") assert "cache-control" not in resp.headers @@ -97,7 +99,7 @@ def test_no_store_does_not_override_existing_header(monkeypatch): return Response("hi", headers={"Cache-Control": "private, max-age=60"}) monkeypatch.setenv("LITELLM_ENABLE_NO_STORE", "true") - resp = _make_client(custom).get("/") + resp: Final = _make_client(custom).get("/") assert resp.headers["cache-control"] == "private, max-age=60" assert resp.headers["x-content-type-options"] == "nosniff" @@ -105,7 +107,7 @@ def test_no_store_does_not_override_existing_header(monkeypatch): 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("/") + resp: Final = _make_client(_ok).get("/") assert resp.headers["strict-transport-security"] == ( "max-age=31536000; includeSubDomains" )