mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-24 05:45:11 +00:00
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
This commit is contained in:
parent
b24b68e576
commit
5ea7874a2f
2 changed files with 58 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue