From 74febbea647095f91f6ba76092422d347036564d Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 00:12:08 +0200 Subject: [PATCH 01/19] feat(proxy): add optional fastapi-guard security middleware Off by default (LITELLM_GUARD_ENABLED=true to enable): IP block/allow lists, rate limiting with auto-ban, user-agent blocking, penetration detection, passive mode, and optional Redis-backed shared state. Pure ASGI (house style); the flag is read lazily per request so YAML environment_variables entries are honored. No dependency change: the import is lazy and errors with install guidance when the flag is on without the package. --- litellm/proxy/middleware/guard_middleware.py | 158 ++++++++++++++++++ litellm/proxy/proxy_server.py | 8 + .../proxy/middleware/test_guard_middleware.py | 135 +++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 litellm/proxy/middleware/guard_middleware.py create mode 100644 tests/unit/proxy/middleware/test_guard_middleware.py diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py new file mode 100644 index 00000000000..4bf9a949513 --- /dev/null +++ b/litellm/proxy/middleware/guard_middleware.py @@ -0,0 +1,158 @@ +"""Optional fastapi-guard security middleware for the proxy. + +fastapi-guard (https://github.com/Guard-Core/fastapi-guard) provides IP +block/allow lists, rate limiting with auto-ban, user-agent blocking, +penetration-attempt detection, passive mode, optional Redis-backed shared +state, and optional IPInfo geo/cloud-provider lookups. + +Positioning: this is edge protection at the ASGI layer, complementary to +litellm's existing controls. It does not replace the virtual-key RPM/TPM +limiter (identity-level), the Enterprise ``allowed_ips`` auth-layer check, +or the login throttle. It runs before auth so screened requests never +reach key validation. + +Design constraints honored here: + +- Pure ASGI (``__call__(scope, receive, send)``), never BaseHTTPMiddleware, + matching the middleware house style and its streaming-preservation test. +- Opt-in via ``LITELLM_GUARD_ENABLED``; when unset, the wrapper delegates + straight through with no guard import and no config work. +- fastapi-guard is NOT added to pyproject dependencies by this module: the + import is lazy and raises an actionable error when the flag is enabled + without the package installed. (Fork PRs may not modify dependency files + per the guard-fork-dependencies CI policy, so the dependency addition is + left to the maintainers.) +- Rejections are built in litellm's OpenAI-error JSON shape (the + ``AdmissionControlMiddleware`` model), so clients see the same error + envelope for 403/429 as for other proxy rejections. +""" + +import os +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from guard import SecurityConfig + +GUARD_ENABLED_ENV = "LITELLM_GUARD_ENABLED" +GUARD_EXCLUDED_PATHS_ENV = "LITELLM_GUARD_EXCLUDED_PATHS" +DEFAULT_EXCLUDED_PATHS = "/health,/health/liveliness,/health/readiness,/metrics" +DEFAULT_TRUSTED_PROXIES = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + + +def _env_str(name: str, default: str = "") -> str: + return os.getenv(name, default) + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + return int(raw) + + +def _env_bool(name: str) -> bool: + return os.getenv(name, "").strip().lower() in ("1", "true", "yes") + + +def _csv(raw: str | None) -> tuple[str, ...]: + if not raw: + return () + return tuple(item.strip() for item in raw.split(",") if item.strip()) + + +def guard_enabled() -> bool: + """Read lazily per request so YAML ``environment_variables`` entries are + seen (the ``LITELLM_ENABLE_HSTS`` pattern).""" + return _env_bool(GUARD_ENABLED_ENV) + + +def _build_guard_config() -> SecurityConfig: + """Build the SecurityConfig from the LITELLM_GUARD_* environment variables.""" + from guard import SecurityConfig + + kwargs: dict[str, Any] = { + "enable_rate_limiting": True, + "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), + "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), + "enable_ip_banning": True, + "auto_ban_threshold": _env_int("LITELLM_GUARD_AUTO_BAN_THRESHOLD", 10), + "auto_ban_duration": _env_int("LITELLM_GUARD_AUTO_BAN_DURATION", 300), + "enable_penetration_detection": True, + # In-memory state unless a Redis URL is configured: never implicitly + # depend on a Redis server being reachable. The proxy defaults to a + # single uvicorn worker; multi-worker deployments should set + # LITELLM_GUARD_REDIS_URL so counters and bans are shared. + "enable_redis": False, + "passive_mode": _env_bool("LITELLM_GUARD_PASSIVE_MODE"), + "blacklist": _csv(_env_str("LITELLM_GUARD_BLOCKED_IPS")), + "blocked_user_agents": list(_csv(_env_str("LITELLM_GUARD_BLOCKED_USER_AGENTS"))), + "trusted_proxies": _csv(_env_str("LITELLM_GUARD_TRUSTED_PROXIES")) or _csv(DEFAULT_TRUSTED_PROXIES), + "trusted_proxy_depth": _env_int("LITELLM_GUARD_TRUSTED_PROXY_DEPTH", 1), + "exclude_paths": list(_csv(_env_str(GUARD_EXCLUDED_PATHS_ENV)) or DEFAULT_EXCLUDED_PATHS.split(",")), + } + + if allowed_ips := _csv(_env_str("LITELLM_GUARD_ALLOWED_IPS")): + kwargs["whitelist"] = allowed_ips + if blocked_countries := _csv(_env_str("LITELLM_GUARD_BLOCKED_COUNTRIES")): + kwargs["blocked_countries"] = frozenset(blocked_countries) + if allowed_countries := _csv(_env_str("LITELLM_GUARD_ALLOWED_COUNTRIES")): + kwargs["whitelist_countries"] = frozenset(allowed_countries) + if cloud_providers := _csv(_env_str("LITELLM_GUARD_BLOCK_CLOUD_PROVIDERS")): + kwargs["block_cloud_providers"] = frozenset(cloud_providers) + + if redis_url := _env_str("LITELLM_GUARD_REDIS_URL"): + kwargs["enable_redis"] = True + kwargs["redis_url"] = redis_url + kwargs["redis_prefix"] = "litellm_guard:" + + if ipinfo_token := _env_str("LITELLM_GUARD_IPINFO_TOKEN"): + kwargs["ipinfo_token"] = ipinfo_token + + return SecurityConfig(**kwargs) + + +class GuardMiddleware: + """Pure-ASGI fastapi-guard wrapper. + + The inner fastapi-guard middleware is instantiated on the first request + after the flag is seen (so YAML-configured environment variables are + honored), and the downstream app is wrapped by it; everything the flag + admits flows through guard's pipeline and then the normal proxy stack. + """ + + def __init__(self, app: Any) -> None: + self.app = app + self._guard_asgi: Any = None + self._guard_failed = False + + def _ensure_guard(self, scope: Any) -> bool: + if self._guard_asgi is not None or self._guard_failed: + return not self._guard_failed + try: + from guard import PureASGISecurityMiddleware + + self._guard_asgi = PureASGISecurityMiddleware(self.app, config=_build_guard_config()) + return True + except ImportError as exc: + self._guard_failed = True + # Log loudly once; security misconfiguration must be visible. + print( + f"LITELLM_GUARD_ENABLED is set but fastapi-guard is not " + f"installed: {exc}. Install it with: " + f"pip install fastapi-guard. Guard is DISABLED until then." + ) + return False + + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + if scope["type"] != "http" or not guard_enabled(): + await self.app(scope, receive, send) + return + + if not self._ensure_guard(scope): + # Flag on, package missing: fail open with a loud log rather + # than taking the proxy down (matches the disabled path; the + # misconfiguration is logged at startup of the first request). + await self.app(scope, receive, send) + return + + await self._guard_asgi(scope, receive, send) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 089647c6297..187d4f288d1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2610,6 +2610,14 @@ app.add_middleware(InFlightRequestsMiddleware) app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(GZipBufferedResponseMiddleware) +# Optional fastapi-guard security middleware (off unless LITELLM_GUARD_ENABLED +# is set). Added inside SecurityHeaders so screened responses are still +# headered, and outside the auth dependencies so screened-and-blocked +# requests never reach key validation. See middleware/guard_middleware.py. +from litellm.proxy.middleware.guard_middleware import GuardMiddleware # noqa: E402 + +app.add_middleware(GuardMiddleware) + def mount_swagger_ui(): swagger_directory: Final = os.path.join(current_dir, "swagger") diff --git a/tests/unit/proxy/middleware/test_guard_middleware.py b/tests/unit/proxy/middleware/test_guard_middleware.py new file mode 100644 index 00000000000..31b6be2f084 --- /dev/null +++ b/tests/unit/proxy/middleware/test_guard_middleware.py @@ -0,0 +1,135 @@ +""" +Unit tests for the optional fastapi-guard middleware wiring. + +Mirrors test_security_headers_middleware.py: minimal app, TestClient, +monkeypatched env, default-off assertion, and the pure-ASGI house-style +assertion. fastapi-guard is an optional dependency (not added to +pyproject by this change), so behavior tests skip when it is absent. +""" + +import asyncio +import itertools +import os + +import pytest +from starlette.testclient import TestClient + +from litellm.proxy.middleware.guard_middleware import ( + GuardMiddleware, + guard_enabled, +) + +_IP = itertools.count(1) + +try: + import guard # noqa: F401 + + _GUARD_AVAILABLE = True +except ImportError: + _GUARD_AVAILABLE = False + + +def _unique_ip() -> str: + n = next(_IP) + return f"198.51.{n // 250}.{(n % 250) + 1}" + + +def test_is_pure_asgi_not_base_http_middleware(): + """BaseHTTPMiddleware degrades streaming; this must be pure ASGI.""" + from starlette.middleware.base import BaseHTTPMiddleware + + assert not issubclass(GuardMiddleware, BaseHTTPMiddleware) + assert "__call__" in GuardMiddleware.__dict__ + + +def test_guard_enabled_flag(monkeypatch): + monkeypatch.delenv("LITELLM_GUARD_ENABLED", raising=False) + assert guard_enabled() is False + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + assert guard_enabled() is True + + +def _build_app(): + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Route + + async def ping(request): + return JSONResponse({"ok": True}) + + app = Starlette(routes=[Route("/ping", ping, methods=["GET"])]) + app.add_middleware(GuardMiddleware) + return app + + +class _ClientIPInjector: + """TestClient hardcodes scope["client"]; the middleware stack must see + the test IP, so mutate the scope before it reaches the app.""" + + def __init__(self, asgi_app, client_ip): + self._asgi_app = asgi_app + self._client_ip = client_ip + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + scope["client"] = (self._client_ip, 50000) + await self._asgi_app(scope, receive, send) + + +def _client(client_ip): + return TestClient(_ClientIPInjector(_build_app(), client_ip)) + + +def test_disabled_by_default(monkeypatch): + monkeypatch.delenv("LITELLM_GUARD_ENABLED", raising=False) + client = _client(_unique_ip()) + assert client.get("/ping").status_code == 200 + + +def test_fail_loud_without_package(monkeypatch): + if _GUARD_AVAILABLE: + pytest.skip("fastapi-guard is installed") + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + client = _client(_unique_ip()) + # Flag on, package missing: request still passes (logged loudly), but + # the guard attribute proves the wiring attempted the import. + assert client.get("/ping").status_code == 200 + + +@pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") +def test_blocked_ip_is_rejected(monkeypatch): + blocked = _unique_ip() + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + monkeypatch.setenv("LITELLM_GUARD_BLOCKED_IPS", blocked) + with _client(blocked) as client: + assert client.get("/ping").status_code == 403 + + +@pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") +def test_rate_limit_returns_429(monkeypatch): + client_ip = _unique_ip() + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + monkeypatch.setenv("LITELLM_GUARD_RATE_LIMIT", "2") + monkeypatch.setenv("LITELLM_GUARD_RATE_LIMIT_WINDOW", "60") + with _client(client_ip) as client: + codes = [client.get("/ping").status_code for _ in range(3)] + assert codes == [200, 200, 429] + + +@pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") +def test_passive_mode_never_blocks(monkeypatch): + blocked = _unique_ip() + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + monkeypatch.setenv("LITELLM_GUARD_PASSIVE_MODE", "true") + monkeypatch.setenv("LITELLM_GUARD_BLOCKED_IPS", blocked) + with _client(blocked) as client: + assert client.get("/ping").status_code == 200 + + +@pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") +def test_redis_stays_off_without_config(monkeypatch): + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + from litellm.proxy.middleware.guard_middleware import _build_guard_config + + config = _build_guard_config() + assert config.enable_redis is False From 21b5eb64549749965bf7fdbc09c68f1780bc2029 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 01:14:09 +0200 Subject: [PATCH 02/19] fix(proxy): add future annotations import to guard middleware The return annotation referenced the TYPE_CHECKING-only SecurityConfig name, which raised NameError at import time and broke every proxy unit test in CI. --- litellm/proxy/middleware/guard_middleware.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 4bf9a949513..c1183167237 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -27,6 +27,8 @@ Design constraints honored here: envelope for 403/429 as for other proxy rejections. """ +from __future__ import annotations + import os from typing import TYPE_CHECKING, Any From 1605b43a1d15d6565295f8ff5a2757fb9d52bad0 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 01:28:27 +0200 Subject: [PATCH 03/19] fix(proxy): use verbose_proxy_logger instead of print in guard middleware --- litellm/proxy/middleware/guard_middleware.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index c1183167237..4159d4af784 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -32,6 +32,8 @@ from __future__ import annotations import os from typing import TYPE_CHECKING, Any +from litellm._logging import verbose_proxy_logger + if TYPE_CHECKING: from guard import SecurityConfig @@ -138,10 +140,11 @@ class GuardMiddleware: except ImportError as exc: self._guard_failed = True # Log loudly once; security misconfiguration must be visible. - print( - f"LITELLM_GUARD_ENABLED is set but fastapi-guard is not " - f"installed: {exc}. Install it with: " - f"pip install fastapi-guard. Guard is DISABLED until then." + verbose_proxy_logger.error( + "LITELLM_GUARD_ENABLED is set but fastapi-guard is not " + "installed: %s. Install it with: pip install fastapi-guard. " + "Guard is DISABLED until then.", + exc, ) return False From c27bffb33e12868e82d7c19b4dc7a9936cf11810 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 01:39:40 +0200 Subject: [PATCH 04/19] fix(proxy): replace Any annotations with starlette.types in guard middleware Keeps the strict-ruff ANN401 delta at zero. --- litellm/proxy/middleware/guard_middleware.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 4159d4af784..a98485382b9 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -30,7 +30,9 @@ Design constraints honored here: from __future__ import annotations import os -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING + +from starlette.types import ASGIApp, Receive, Scope, Send from litellm._logging import verbose_proxy_logger @@ -74,7 +76,7 @@ def _build_guard_config() -> SecurityConfig: """Build the SecurityConfig from the LITELLM_GUARD_* environment variables.""" from guard import SecurityConfig - kwargs: dict[str, Any] = { + kwargs: dict[str, object] = { "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), @@ -124,12 +126,12 @@ class GuardMiddleware: admits flows through guard's pipeline and then the normal proxy stack. """ - def __init__(self, app: Any) -> None: + def __init__(self, app: ASGIApp) -> None: self.app = app - self._guard_asgi: Any = None + self._guard_asgi: ASGIApp | None = None self._guard_failed = False - def _ensure_guard(self, scope: Any) -> bool: + def _ensure_guard(self, scope: Scope) -> bool: if self._guard_asgi is not None or self._guard_failed: return not self._guard_failed try: @@ -148,7 +150,7 @@ class GuardMiddleware: ) return False - async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or not guard_enabled(): await self.app(scope, receive, send) return From 430165193849c7a95f6e785ceb53228c52dfc058 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 01:55:54 +0200 Subject: [PATCH 05/19] fix(proxy): restructure guard import error path for TRY300 Move the return out of the except block so the success return does not follow a raise (strict-ruff TRY300 delta gate). --- litellm/proxy/middleware/guard_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index a98485382b9..87e7d196bee 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -148,7 +148,7 @@ class GuardMiddleware: "Guard is DISABLED until then.", exc, ) - return False + return not self._guard_failed async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or not guard_enabled(): From dcb4d9e7ef9b200d3921531b450d065b724316c3 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 02:14:09 +0200 Subject: [PATCH 06/19] fix(proxy): drop try-block return to clear TRY300 --- litellm/proxy/middleware/guard_middleware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 87e7d196bee..115e47bace2 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -138,7 +138,6 @@ class GuardMiddleware: from guard import PureASGISecurityMiddleware self._guard_asgi = PureASGISecurityMiddleware(self.app, config=_build_guard_config()) - return True except ImportError as exc: self._guard_failed = True # Log loudly once; security misconfiguration must be visible. From df88b920c404ea014dc9ac0fa1d40c606a23f350 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 02:32:27 +0200 Subject: [PATCH 07/19] fix(proxy): satisfy type-discipline gates in guard middleware LIT001: declare the mutable-ok suppression for the conditionally assembled config kwargs. LIT003: give the availability-probe noqa a reason. --- litellm/proxy/middleware/guard_middleware.py | 2 +- tests/unit/proxy/middleware/test_guard_middleware.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 115e47bace2..45b1f67f08e 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -76,7 +76,7 @@ def _build_guard_config() -> SecurityConfig: """Build the SecurityConfig from the LITELLM_GUARD_* environment variables.""" from guard import SecurityConfig - kwargs: dict[str, object] = { + kwargs: dict[str, object] = { # mutable-ok: assembled conditionally, then passed once to SecurityConfig "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), diff --git a/tests/unit/proxy/middleware/test_guard_middleware.py b/tests/unit/proxy/middleware/test_guard_middleware.py index 31b6be2f084..51a94f86f79 100644 --- a/tests/unit/proxy/middleware/test_guard_middleware.py +++ b/tests/unit/proxy/middleware/test_guard_middleware.py @@ -22,7 +22,7 @@ from litellm.proxy.middleware.guard_middleware import ( _IP = itertools.count(1) try: - import guard # noqa: F401 + import guard # noqa: F401 # availability probe for skip logic _GUARD_AVAILABLE = True except ImportError: From a51a2c05efe0eac48fbbfa93d5ca6f5ebf875d00 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 02:54:11 +0200 Subject: [PATCH 08/19] fix(proxy): meet type-discipline gates in guard middleware and tests Final declarations throughout, scope rebuilt instead of mutated in the test injector, mutable-ok/noqa reasons supplied. --- litellm/proxy/middleware/guard_middleware.py | 14 ++++---- .../proxy/middleware/test_guard_middleware.py | 36 ++++++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 45b1f67f08e..fd618f02c67 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -30,7 +30,7 @@ Design constraints honored here: from __future__ import annotations import os -from typing import TYPE_CHECKING +from typing import Final, TYPE_CHECKING from starlette.types import ASGIApp, Receive, Scope, Send @@ -39,10 +39,10 @@ from litellm._logging import verbose_proxy_logger if TYPE_CHECKING: from guard import SecurityConfig -GUARD_ENABLED_ENV = "LITELLM_GUARD_ENABLED" -GUARD_EXCLUDED_PATHS_ENV = "LITELLM_GUARD_EXCLUDED_PATHS" -DEFAULT_EXCLUDED_PATHS = "/health,/health/liveliness,/health/readiness,/metrics" -DEFAULT_TRUSTED_PROXIES = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +GUARD_ENABLED_ENV: Final = "LITELLM_GUARD_ENABLED" +GUARD_EXCLUDED_PATHS_ENV: Final = "LITELLM_GUARD_EXCLUDED_PATHS" +DEFAULT_EXCLUDED_PATHS: Final = "/health,/health/liveliness,/health/readiness,/metrics" +DEFAULT_TRUSTED_PROXIES: Final = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" def _env_str(name: str, default: str = "") -> str: @@ -50,7 +50,7 @@ def _env_str(name: str, default: str = "") -> str: def _env_int(name: str, default: int) -> int: - raw = os.getenv(name) + raw: Final = os.getenv(name) if raw is None or raw == "": return default return int(raw) @@ -76,7 +76,7 @@ def _build_guard_config() -> SecurityConfig: """Build the SecurityConfig from the LITELLM_GUARD_* environment variables.""" from guard import SecurityConfig - kwargs: dict[str, object] = { # mutable-ok: assembled conditionally, then passed once to SecurityConfig + kwargs: Final[dict[str, object]] = { # mutable-ok: assembled conditionally, then passed once to SecurityConfig "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), diff --git a/tests/unit/proxy/middleware/test_guard_middleware.py b/tests/unit/proxy/middleware/test_guard_middleware.py index 51a94f86f79..aa7d47040f3 100644 --- a/tests/unit/proxy/middleware/test_guard_middleware.py +++ b/tests/unit/proxy/middleware/test_guard_middleware.py @@ -10,6 +10,7 @@ pyproject by this change), so behavior tests skip when it is absent. import asyncio import itertools import os +from typing import Final import pytest from starlette.testclient import TestClient @@ -19,18 +20,18 @@ from litellm.proxy.middleware.guard_middleware import ( guard_enabled, ) -_IP = itertools.count(1) +_IP: Final = itertools.count(1) try: import guard # noqa: F401 # availability probe for skip logic - _GUARD_AVAILABLE = True + _GUARD_AVAILABLE: Final = True except ImportError: - _GUARD_AVAILABLE = False + _GUARD_AVAILABLE: Final = False def _unique_ip() -> str: - n = next(_IP) + n: Final = next(_IP) return f"198.51.{n // 250}.{(n % 250) + 1}" @@ -57,7 +58,7 @@ def _build_app(): async def ping(request): return JSONResponse({"ok": True}) - app = Starlette(routes=[Route("/ping", ping, methods=["GET"])]) + app: Final = Starlette(routes=[Route("/ping", ping, methods=["GET"])]) app.add_middleware(GuardMiddleware) return app @@ -67,13 +68,14 @@ class _ClientIPInjector: the test IP, so mutate the scope before it reaches the app.""" def __init__(self, asgi_app, client_ip): - self._asgi_app = asgi_app - self._client_ip = client_ip + self._asgi_app: Final = asgi_app + self._client_ip: Final = client_ip async def __call__(self, scope, receive, send): - if scope["type"] == "http": - scope["client"] = (self._client_ip, 50000) - await self._asgi_app(scope, receive, send) + # TestClient hardcodes scope["client"]; the middleware stack must see + # the TEST-NET address, so build a new scope rather than mutate. + screened: Final = {**scope, "client": (self._client_ip, 50000)} if scope["type"] == "http" else scope + await self._asgi_app(screened, receive, send) def _client(client_ip): @@ -82,7 +84,7 @@ def _client(client_ip): def test_disabled_by_default(monkeypatch): monkeypatch.delenv("LITELLM_GUARD_ENABLED", raising=False) - client = _client(_unique_ip()) + client: Final = _client(_unique_ip()) assert client.get("/ping").status_code == 200 @@ -90,7 +92,7 @@ def test_fail_loud_without_package(monkeypatch): if _GUARD_AVAILABLE: pytest.skip("fastapi-guard is installed") monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") - client = _client(_unique_ip()) + client: Final = _client(_unique_ip()) # Flag on, package missing: request still passes (logged loudly), but # the guard attribute proves the wiring attempted the import. assert client.get("/ping").status_code == 200 @@ -98,7 +100,7 @@ def test_fail_loud_without_package(monkeypatch): @pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") def test_blocked_ip_is_rejected(monkeypatch): - blocked = _unique_ip() + blocked: Final = _unique_ip() monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") monkeypatch.setenv("LITELLM_GUARD_BLOCKED_IPS", blocked) with _client(blocked) as client: @@ -107,18 +109,18 @@ def test_blocked_ip_is_rejected(monkeypatch): @pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") def test_rate_limit_returns_429(monkeypatch): - client_ip = _unique_ip() + client_ip: Final = _unique_ip() monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") monkeypatch.setenv("LITELLM_GUARD_RATE_LIMIT", "2") monkeypatch.setenv("LITELLM_GUARD_RATE_LIMIT_WINDOW", "60") with _client(client_ip) as client: - codes = [client.get("/ping").status_code for _ in range(3)] + codes: Final = [client.get("/ping").status_code for _ in range(3)] assert codes == [200, 200, 429] @pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") def test_passive_mode_never_blocks(monkeypatch): - blocked = _unique_ip() + blocked: Final = _unique_ip() monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") monkeypatch.setenv("LITELLM_GUARD_PASSIVE_MODE", "true") monkeypatch.setenv("LITELLM_GUARD_BLOCKED_IPS", blocked) @@ -131,5 +133,5 @@ def test_redis_stays_off_without_config(monkeypatch): monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") from litellm.proxy.middleware.guard_middleware import _build_guard_config - config = _build_guard_config() + config: Final = _build_guard_config() assert config.enable_redis is False From 07ab7a7416e69e81bf10a2ee8c8650c41baf28f5 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 03:13:05 +0200 Subject: [PATCH 09/19] style(proxy): import order per ruff --- litellm/proxy/middleware/guard_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index fd618f02c67..722ae761f5f 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -30,7 +30,7 @@ Design constraints honored here: from __future__ import annotations import os -from typing import Final, TYPE_CHECKING +from typing import TYPE_CHECKING, Final from starlette.types import ASGIApp, Receive, Scope, Send From 95e6ecedc1bfded1dad3898d63e6e4fe85db8c18 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 03:24:40 +0200 Subject: [PATCH 10/19] fix(proxy): give the guard import noqa a reason for the type-discipline gate --- litellm/proxy/proxy_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 187d4f288d1..61e07f74d87 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2614,7 +2614,9 @@ app.add_middleware(GZipBufferedResponseMiddleware) # is set). Added inside SecurityHeaders so screened responses are still # headered, and outside the auth dependencies so screened-and-blocked # requests never reach key validation. See middleware/guard_middleware.py. -from litellm.proxy.middleware.guard_middleware import GuardMiddleware # noqa: E402 +from litellm.proxy.middleware.guard_middleware import ( # noqa: E402 # mid-file attach: the middleware stack freezes once the lifespan runs + GuardMiddleware, +) app.add_middleware(GuardMiddleware) From 6cf3560186a928916915df3f1e221957229e03d3 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 03:51:41 +0200 Subject: [PATCH 11/19] fix(proxy): strict-gate compliance in guard middleware Optional import for 3.9-tolerant annotations; Final throughout; mutable-ok and noqa reasons; scope rebuilt instead of mutated in tests. --- litellm/proxy/middleware/guard_middleware.py | 23 +++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 722ae761f5f..6e950a82786 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -131,13 +131,19 @@ class GuardMiddleware: self._guard_asgi: ASGIApp | None = None self._guard_failed = False - def _ensure_guard(self, scope: Scope) -> bool: - if self._guard_asgi is not None or self._guard_failed: - return not self._guard_failed + def _ensure_guard(self) -> ASGIApp | None: + """Build and return the guard ASGI app, or None when the package is + missing (logged loudly; the flag-on misconfiguration must stay + visible).""" + if self._guard_asgi is not None: + return self._guard_asgi + if self._guard_failed: + return None try: - from guard import PureASGISecurityMiddleware + import guard # pyright: ignore[reportMissingImports] # optional dependency - self._guard_asgi = PureASGISecurityMiddleware(self.app, config=_build_guard_config()) + self._guard_asgi = guard.PureASGISecurityMiddleware(self.app, config=_build_guard_config()) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # guard ships no type information + return self._guard_asgi except ImportError as exc: self._guard_failed = True # Log loudly once; security misconfiguration must be visible. @@ -147,18 +153,19 @@ class GuardMiddleware: "Guard is DISABLED until then.", exc, ) - return not self._guard_failed + return None async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or not guard_enabled(): await self.app(scope, receive, send) return - if not self._ensure_guard(scope): + guard_asgi: Final = self._ensure_guard() + if guard_asgi is None: # Flag on, package missing: fail open with a loud log rather # than taking the proxy down (matches the disabled path; the # misconfiguration is logged at startup of the first request). await self.app(scope, receive, send) return - await self._guard_asgi(scope, receive, send) + await guard_asgi(scope, receive, send) From 2765a912eb4a1b443413b01dfa05454e17487c95 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 04:06:19 +0200 Subject: [PATCH 12/19] fix(proxy): move success return out of try block (TRY300) --- litellm/proxy/middleware/guard_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 6e950a82786..e85010da05a 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -143,7 +143,6 @@ class GuardMiddleware: import guard # pyright: ignore[reportMissingImports] # optional dependency self._guard_asgi = guard.PureASGISecurityMiddleware(self.app, config=_build_guard_config()) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # guard ships no type information - return self._guard_asgi except ImportError as exc: self._guard_failed = True # Log loudly once; security misconfiguration must be visible. @@ -154,6 +153,7 @@ class GuardMiddleware: exc, ) return None + return self._guard_asgi async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or not guard_enabled(): From f8542898afb2b4d8f1e38f6b3e036293cbbf5ec7 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 04:33:52 +0200 Subject: [PATCH 13/19] fix(proxy): Final declarations and lint compliance in guard middleware --- litellm/proxy/middleware/guard_middleware.py | 25 ++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index e85010da05a..fe827247822 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -29,16 +29,14 @@ Design constraints honored here: from __future__ import annotations +import importlib import os -from typing import TYPE_CHECKING, Final +from typing import Final from starlette.types import ASGIApp, Receive, Scope, Send from litellm._logging import verbose_proxy_logger -if TYPE_CHECKING: - from guard import SecurityConfig - GUARD_ENABLED_ENV: Final = "LITELLM_GUARD_ENABLED" GUARD_EXCLUDED_PATHS_ENV: Final = "LITELLM_GUARD_EXCLUDED_PATHS" DEFAULT_EXCLUDED_PATHS: Final = "/health,/health/liveliness,/health/readiness,/metrics" @@ -72,11 +70,13 @@ def guard_enabled() -> bool: return _env_bool(GUARD_ENABLED_ENV) -def _build_guard_config() -> SecurityConfig: - """Build the SecurityConfig from the LITELLM_GUARD_* environment variables.""" - from guard import SecurityConfig +def _build_guard_config(): + # type: () -> object + """Build the guard config from the LITELLM_GUARD_* environment variables.""" + guard_module: Final = importlib.import_module("guard") + security_config_cls: Final = guard_module.SecurityConfig - kwargs: Final[dict[str, object]] = { # mutable-ok: assembled conditionally, then passed once to SecurityConfig + kwargs: Final[dict[str, object]] = { "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), @@ -114,7 +114,7 @@ def _build_guard_config() -> SecurityConfig: if ipinfo_token := _env_str("LITELLM_GUARD_IPINFO_TOKEN"): kwargs["ipinfo_token"] = ipinfo_token - return SecurityConfig(**kwargs) + return security_config_cls(**kwargs) class GuardMiddleware: @@ -140,9 +140,8 @@ class GuardMiddleware: if self._guard_failed: return None try: - import guard # pyright: ignore[reportMissingImports] # optional dependency - - self._guard_asgi = guard.PureASGISecurityMiddleware(self.app, config=_build_guard_config()) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # guard ships no type information + guard_module: Final = importlib.import_module("guard") + middleware_cls: Final = guard_module.SecurityMiddleware except ImportError as exc: self._guard_failed = True # Log loudly once; security misconfiguration must be visible. @@ -153,6 +152,8 @@ class GuardMiddleware: exc, ) return None + + self._guard_asgi = middleware_cls(self.app, config=_build_guard_config()) return self._guard_asgi async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: From bebfe14c9c0745cf81af3000fb0947d3518462c7 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 04:34:29 +0200 Subject: [PATCH 14/19] fix(proxy): mutable-ok suppression on guard config kwargs --- litellm/proxy/middleware/guard_middleware.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index fe827247822..2fce352bd05 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -76,7 +76,8 @@ def _build_guard_config(): guard_module: Final = importlib.import_module("guard") security_config_cls: Final = guard_module.SecurityConfig - kwargs: Final[dict[str, object]] = { + kwargs: dict[str, object] = { # mutable-ok: assembled conditionally, then passed once to SecurityConfig + "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), From 2e709d12bd4ed64407405d52c7ba172bf1333ac7 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 04:34:54 +0200 Subject: [PATCH 15/19] style(proxy): ruff-format guard middleware --- litellm/proxy/middleware/guard_middleware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 2fce352bd05..47093ab898d 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -77,7 +77,6 @@ def _build_guard_config(): security_config_cls: Final = guard_module.SecurityConfig kwargs: dict[str, object] = { # mutable-ok: assembled conditionally, then passed once to SecurityConfig - "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), From 3019ebf4905e348813d0de517f52fa1752776fb9 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 04:49:17 +0200 Subject: [PATCH 16/19] fix(proxy): noqa ANN202 on guard config builder (untyped optional dep) --- litellm/proxy/middleware/guard_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 47093ab898d..233c31bea54 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -70,7 +70,7 @@ def guard_enabled() -> bool: return _env_bool(GUARD_ENABLED_ENV) -def _build_guard_config(): +def _build_guard_config(): # noqa: ANN202 # return type is from the lazily imported, untyped optional dependency # type: () -> object """Build the guard config from the LITELLM_GUARD_* environment variables.""" guard_module: Final = importlib.import_module("guard") From 2f320a58efa8bccbd7f6f2528c4e1461da0d7dd6 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 05:09:19 +0200 Subject: [PATCH 17/19] fix(proxy): drop redundant mutable-ok suppression from guard config kwargs --- litellm/proxy/middleware/guard_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 233c31bea54..46ba276ea71 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -76,7 +76,7 @@ def _build_guard_config(): # noqa: ANN202 # return type is from the lazily imp guard_module: Final = importlib.import_module("guard") security_config_cls: Final = guard_module.SecurityConfig - kwargs: dict[str, object] = { # mutable-ok: assembled conditionally, then passed once to SecurityConfig + kwargs: Final = { "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), "rate_limit_window": _env_int("LITELLM_GUARD_RATE_LIMIT_WINDOW", 60), From bc80a712b74ac5672350c92bab482f55e7f40658 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 05:38:47 +0200 Subject: [PATCH 18/19] fix(proxy): fail closed, zero-trust proxies, in-stack guard ordering Review-response pass on the guard middleware: - Enabled-but-missing fastapi-guard now rejects with 503 (OpenAI-error envelope, logged once) instead of forwarding unscreened. - Trusted proxies default to none: X-Forwarded-For is honored only from explicitly listed ingress addresses, so the header cannot spoof allowlists, limits, or bans. - Attach GuardMiddleware inside CORS/SecurityHeaders so guard rejections keep their headers. - Retype the optional-dep seam (Callable[..., object]/ASGIApp) and drop the type comment; config built as one literal so conditional inserts cannot re-trip the argument-type ceiling. - Tests: fail-closed 503 + log-once, CORS/security-header pin on rejections, untrusted XFF ignored. --- litellm/proxy/middleware/guard_middleware.py | 132 +++++++++++------- litellm/proxy/proxy_server.py | 22 +-- .../proxy/middleware/test_guard_middleware.py | 91 ++++++++++-- 3 files changed, 177 insertions(+), 68 deletions(-) diff --git a/litellm/proxy/middleware/guard_middleware.py b/litellm/proxy/middleware/guard_middleware.py index 46ba276ea71..3012cdf9168 100644 --- a/litellm/proxy/middleware/guard_middleware.py +++ b/litellm/proxy/middleware/guard_middleware.py @@ -17,30 +17,41 @@ Design constraints honored here: matching the middleware house style and its streaming-preservation test. - Opt-in via ``LITELLM_GUARD_ENABLED``; when unset, the wrapper delegates straight through with no guard import and no config work. -- fastapi-guard is NOT added to pyproject dependencies by this module: the - import is lazy and raises an actionable error when the flag is enabled - without the package installed. (Fork PRs may not modify dependency files - per the guard-fork-dependencies CI policy, so the dependency addition is +- Fail-closed when enabled: the proxy does not depend on fastapi-guard (it + is deliberately absent from pyproject), so the import is lazy. If the + flag is set and the package is missing, every screened request is + rejected with a 503 in litellm's OpenAI-error envelope and an error is + logged once: an operator who explicitly enabled the guard must not get + unscreened passthrough. (Fork PRs may not modify dependency files per + the guard-fork-dependencies CI policy, so the dependency addition is left to the maintainers.) -- Rejections are built in litellm's OpenAI-error JSON shape (the - ``AdmissionControlMiddleware`` model), so clients see the same error - envelope for 403/429 as for other proxy rejections. +- Zero-trust client identity by default: ``X-Forwarded-For`` is honored + only from proxies explicitly listed in ``LITELLM_GUARD_TRUSTED_PROXIES`` + (guard ignores forwarded headers from unlisted peers), so a caller + cannot spoof its way past allowlists, limits, or bans by setting the + header. Deployments behind a load balancer must list their ingress + addresses there. +- Rejections guard itself builds use guard's response shapes; the one + rejection this wrapper builds (guard unavailable) uses litellm's + OpenAI-error JSON shape (the ``AdmissionControlMiddleware`` model), so + clients see a familiar envelope. """ from __future__ import annotations import importlib import os +from collections.abc import Callable from typing import Final +from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send from litellm._logging import verbose_proxy_logger GUARD_ENABLED_ENV: Final = "LITELLM_GUARD_ENABLED" GUARD_EXCLUDED_PATHS_ENV: Final = "LITELLM_GUARD_EXCLUDED_PATHS" -DEFAULT_EXCLUDED_PATHS: Final = "/health,/health/liveliness,/health/readiness,/metrics" -DEFAULT_TRUSTED_PROXIES: Final = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +DEFAULT_EXCLUDED_PATHS: Final = ("/health", "/health/liveliness", "/health/readiness", "/metrics") def _env_str(name: str, default: str = "") -> str: @@ -70,12 +81,29 @@ def guard_enabled() -> bool: return _env_bool(GUARD_ENABLED_ENV) -def _build_guard_config(): # noqa: ANN202 # return type is from the lazily imported, untyped optional dependency - # type: () -> object - """Build the guard config from the LITELLM_GUARD_* environment variables.""" - guard_module: Final = importlib.import_module("guard") - security_config_cls: Final = guard_module.SecurityConfig +def _build_guard_config() -> object: + """Build the guard config from the LITELLM_GUARD_* environment variables. + Returns guard's ``SecurityConfig``. The ``object`` return and the + dynamic import are the seam: fastapi-guard is an optional dependency + that is deliberately absent from the environments litellm type-checks, + so the class can only be reached through a runtime lookup. + """ + guard_module: Final = importlib.import_module("guard") + # fastapi-guard is an optional dependency that is deliberately absent + # from the environments litellm type-checks, so attributes read off the + # dynamically imported module are Any; the Callable annotation re-types + # the seam so no Any escapes this line. + security_config_cls: Final[Callable[..., object]] = guard_module.SecurityConfig # pyright: ignore[reportAny] # optional dep absent from the typecheck venv; seam re-typed below + + redis_url_env: Final = _env_str("LITELLM_GUARD_REDIS_URL") + ipinfo_token_env: Final = _env_str("LITELLM_GUARD_IPINFO_TOKEN") + # One literal, no conditional inserts: optional list-valued features are + # passed as their empty/None form, which SecurityConfig treats the same + # as unset. In-memory state unless a Redis URL is configured: never + # implicitly depend on a Redis server being reachable. The proxy + # defaults to a single uvicorn worker; multi-worker deployments should + # set LITELLM_GUARD_REDIS_URL so counters and bans are shared. kwargs: Final = { "enable_rate_limiting": True, "rate_limit": _env_int("LITELLM_GUARD_RATE_LIMIT", 100), @@ -84,39 +112,47 @@ def _build_guard_config(): # noqa: ANN202 # return type is from the lazily imp "auto_ban_threshold": _env_int("LITELLM_GUARD_AUTO_BAN_THRESHOLD", 10), "auto_ban_duration": _env_int("LITELLM_GUARD_AUTO_BAN_DURATION", 300), "enable_penetration_detection": True, - # In-memory state unless a Redis URL is configured: never implicitly - # depend on a Redis server being reachable. The proxy defaults to a - # single uvicorn worker; multi-worker deployments should set - # LITELLM_GUARD_REDIS_URL so counters and bans are shared. - "enable_redis": False, + "enable_redis": bool(redis_url_env), + "redis_url": redis_url_env or None, + "redis_prefix": "litellm_guard:", "passive_mode": _env_bool("LITELLM_GUARD_PASSIVE_MODE"), "blacklist": _csv(_env_str("LITELLM_GUARD_BLOCKED_IPS")), + "whitelist": _csv(_env_str("LITELLM_GUARD_ALLOWED_IPS")) or None, "blocked_user_agents": list(_csv(_env_str("LITELLM_GUARD_BLOCKED_USER_AGENTS"))), - "trusted_proxies": _csv(_env_str("LITELLM_GUARD_TRUSTED_PROXIES")) or _csv(DEFAULT_TRUSTED_PROXIES), + "blocked_countries": frozenset(_csv(_env_str("LITELLM_GUARD_BLOCKED_COUNTRIES"))), + "whitelist_countries": frozenset(_csv(_env_str("LITELLM_GUARD_ALLOWED_COUNTRIES"))), + "block_cloud_providers": frozenset(_csv(_env_str("LITELLM_GUARD_BLOCK_CLOUD_PROVIDERS"))) or None, + # Zero-trust default: no proxies are trusted, so forwarded headers + # are ignored and the connecting peer is the client. List real + # ingress addresses to enable X-Forwarded-For resolution. + "trusted_proxies": _csv(_env_str("LITELLM_GUARD_TRUSTED_PROXIES")), "trusted_proxy_depth": _env_int("LITELLM_GUARD_TRUSTED_PROXY_DEPTH", 1), - "exclude_paths": list(_csv(_env_str(GUARD_EXCLUDED_PATHS_ENV)) or DEFAULT_EXCLUDED_PATHS.split(",")), + "exclude_paths": list(_csv(_env_str(GUARD_EXCLUDED_PATHS_ENV)) or DEFAULT_EXCLUDED_PATHS), + "ipinfo_token": ipinfo_token_env or None, } - - if allowed_ips := _csv(_env_str("LITELLM_GUARD_ALLOWED_IPS")): - kwargs["whitelist"] = allowed_ips - if blocked_countries := _csv(_env_str("LITELLM_GUARD_BLOCKED_COUNTRIES")): - kwargs["blocked_countries"] = frozenset(blocked_countries) - if allowed_countries := _csv(_env_str("LITELLM_GUARD_ALLOWED_COUNTRIES")): - kwargs["whitelist_countries"] = frozenset(allowed_countries) - if cloud_providers := _csv(_env_str("LITELLM_GUARD_BLOCK_CLOUD_PROVIDERS")): - kwargs["block_cloud_providers"] = frozenset(cloud_providers) - - if redis_url := _env_str("LITELLM_GUARD_REDIS_URL"): - kwargs["enable_redis"] = True - kwargs["redis_url"] = redis_url - kwargs["redis_prefix"] = "litellm_guard:" - - if ipinfo_token := _env_str("LITELLM_GUARD_IPINFO_TOKEN"): - kwargs["ipinfo_token"] = ipinfo_token - return security_config_cls(**kwargs) +def _guard_unavailable_response() -> JSONResponse: + """Fail-closed rejection for when the guard is enabled but cannot be + initialized (litellm's OpenAI-error envelope, the + ``AdmissionControlMiddleware`` model).""" + return JSONResponse( + status_code=503, + content={ + "error": { + "message": ( + "LITELLM_GUARD_ENABLED is set but the fastapi-guard package " + "is not installed. Install it with: pip install fastapi-guard. " + "Requests are rejected until then." + ), + "type": "guard_unavailable_error", + "code": "503", + } + }, + ) + + class GuardMiddleware: """Pure-ASGI fastapi-guard wrapper. @@ -133,22 +169,23 @@ class GuardMiddleware: def _ensure_guard(self) -> ASGIApp | None: """Build and return the guard ASGI app, or None when the package is - missing (logged loudly; the flag-on misconfiguration must stay - visible).""" + missing (logged loudly once; the caller then fails closed).""" if self._guard_asgi is not None: return self._guard_asgi if self._guard_failed: return None try: guard_module: Final = importlib.import_module("guard") - middleware_cls: Final = guard_module.SecurityMiddleware + # Same optional-dep seam as _build_guard_config: the attribute + # is Any; the Callable annotation keeps it from escaping. + middleware_cls: Final[Callable[..., ASGIApp]] = guard_module.SecurityMiddleware # pyright: ignore[reportAny] # optional dep absent from the typecheck venv; seam re-typed below except ImportError as exc: self._guard_failed = True # Log loudly once; security misconfiguration must be visible. verbose_proxy_logger.error( "LITELLM_GUARD_ENABLED is set but fastapi-guard is not " "installed: %s. Install it with: pip install fastapi-guard. " - "Guard is DISABLED until then.", + "Requests are REJECTED until then.", exc, ) return None @@ -163,10 +200,11 @@ class GuardMiddleware: guard_asgi: Final = self._ensure_guard() if guard_asgi is None: - # Flag on, package missing: fail open with a loud log rather - # than taking the proxy down (matches the disabled path; the - # misconfiguration is logged at startup of the first request). - await self.app(scope, receive, send) + # Flag on, package missing: fail closed with 503 rather than + # forward unscreened. The operator explicitly enabled the + # guard; silently honoring the opposite of that intent would + # make the flag a no-op. The misconfiguration is logged once. + await _guard_unavailable_response()(scope, receive, send) return await guard_asgi(scope, receive, send) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 61e07f74d87..775591e6c71 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2563,6 +2563,18 @@ current_dir = os.path.dirname(os.path.abspath(__file__)) # app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") +# Optional fastapi-guard security middleware (off unless LITELLM_GUARD_ENABLED +# is set). Added before CORSMiddleware and SecurityHeadersMiddleware so it +# nests *inside* both (Starlette makes the last-added middleware outermost) +# and guard's own 403/429 rejections still leave with CORS and security +# headers. It stays outside the auth dependencies, so screened-and-blocked +# requests never reach key validation. See middleware/guard_middleware.py. +from litellm.proxy.middleware.guard_middleware import ( # noqa: E402 # mid-file attach: the middleware stack freezes once the lifespan runs + GuardMiddleware, +) + +app.add_middleware(GuardMiddleware) + app.add_middleware( CORSMiddleware, allow_origins=origins, @@ -2610,16 +2622,6 @@ app.add_middleware(InFlightRequestsMiddleware) app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(GZipBufferedResponseMiddleware) -# Optional fastapi-guard security middleware (off unless LITELLM_GUARD_ENABLED -# is set). Added inside SecurityHeaders so screened responses are still -# headered, and outside the auth dependencies so screened-and-blocked -# requests never reach key validation. See middleware/guard_middleware.py. -from litellm.proxy.middleware.guard_middleware import ( # noqa: E402 # mid-file attach: the middleware stack freezes once the lifespan runs - GuardMiddleware, -) - -app.add_middleware(GuardMiddleware) - def mount_swagger_ui(): swagger_directory: Final = os.path.join(current_dir, "swagger") diff --git a/tests/unit/proxy/middleware/test_guard_middleware.py b/tests/unit/proxy/middleware/test_guard_middleware.py index aa7d47040f3..95c1e3cb084 100644 --- a/tests/unit/proxy/middleware/test_guard_middleware.py +++ b/tests/unit/proxy/middleware/test_guard_middleware.py @@ -4,17 +4,19 @@ Unit tests for the optional fastapi-guard middleware wiring. Mirrors test_security_headers_middleware.py: minimal app, TestClient, monkeypatched env, default-off assertion, and the pure-ASGI house-style assertion. fastapi-guard is an optional dependency (not added to -pyproject by this change), so behavior tests skip when it is absent. +pyproject by this change), so behavior tests skip when it is absent; the +fail-closed path is exercised unconditionally via a poisoned sys.modules +entry. """ -import asyncio import itertools -import os +import sys from typing import Final import pytest from starlette.testclient import TestClient +from litellm._logging import verbose_proxy_logger from litellm.proxy.middleware.guard_middleware import ( GuardMiddleware, guard_enabled, @@ -63,6 +65,34 @@ def _build_app(): return app +def _build_stacked_app(): + """The proxy's nesting: guard innermost, then CORS, then security + headers outermost (Starlette makes the last-added middleware + outermost), so guard rejections leave with their headers.""" + from starlette.applications import Starlette + from starlette.middleware.cors import CORSMiddleware + from starlette.responses import JSONResponse + from starlette.routing import Route + + from litellm.proxy.middleware.security_headers_middleware import ( + SecurityHeadersMiddleware, + ) + + async def ping(request): + return JSONResponse({"ok": True}) + + app: Final = Starlette(routes=[Route("/ping", ping, methods=["GET"])]) + app.add_middleware(GuardMiddleware) + app.add_middleware( + CORSMiddleware, + allow_origins=["https://ok.example"], + allow_methods=["GET"], + allow_headers=["*"], + ) + app.add_middleware(SecurityHeadersMiddleware) + return app + + class _ClientIPInjector: """TestClient hardcodes scope["client"]; the middleware stack must see the test IP, so mutate the scope before it reaches the app.""" @@ -78,8 +108,8 @@ class _ClientIPInjector: await self._asgi_app(screened, receive, send) -def _client(client_ip): - return TestClient(_ClientIPInjector(_build_app(), client_ip)) +def _client(client_ip, asgi_app=None): + return TestClient(_ClientIPInjector(asgi_app or _build_app(), client_ip)) def test_disabled_by_default(monkeypatch): @@ -88,14 +118,21 @@ def test_disabled_by_default(monkeypatch): assert client.get("/ping").status_code == 200 -def test_fail_loud_without_package(monkeypatch): - if _GUARD_AVAILABLE: - pytest.skip("fastapi-guard is installed") +def test_enabled_without_package_fails_closed(monkeypatch): + """Flag on + package missing: every request is rejected with 503 and + the misconfiguration is logged exactly once. An operator who enabled + the guard must not get unscreened passthrough.""" + monkeypatch.setitem(sys.modules, "guard", None) monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + error_messages: Final = [] + monkeypatch.setattr(verbose_proxy_logger, "error", lambda msg, *args: error_messages.append(msg)) client: Final = _client(_unique_ip()) - # Flag on, package missing: request still passes (logged loudly), but - # the guard attribute proves the wiring attempted the import. - assert client.get("/ping").status_code == 200 + first: Final = client.get("/ping") + second: Final = client.get("/ping") + assert first.status_code == 503 + assert second.status_code == 503 + assert first.json()["error"]["type"] == "guard_unavailable_error" + assert len(error_messages) == 1 @pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") @@ -107,6 +144,37 @@ def test_blocked_ip_is_rejected(monkeypatch): assert client.get("/ping").status_code == 403 +@pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") +def test_untrusted_forwarded_header_is_ignored(monkeypatch): + """No trusted proxies configured: X-Forwarded-For must not override the + connecting address, or a private-network caller could spoof its way + past allowlists, limits, and bans.""" + blocked: Final = "10.4.5.6" + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + monkeypatch.setenv("LITELLM_GUARD_BLOCKED_IPS", blocked) + with _client(blocked) as client: + rejected: Final = client.get("/ping", headers={"x-forwarded-for": "198.51.100.9"}) + assert rejected.status_code == 403 + + +@pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") +def test_rejected_response_keeps_cors_and_security_headers(monkeypatch): + """Guard is nested inside the CORS and security-headers wrappers, so a + guard rejection carries their headers (readable cross-origin errors).""" + blocked: Final = _unique_ip() + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + monkeypatch.setenv("LITELLM_GUARD_BLOCKED_IPS", blocked) + with _client(blocked, _build_stacked_app()) as client: + rejected: Final = client.get("/ping", headers={"Origin": "https://ok.example"}) + assert rejected.status_code == 403 + # Both wrappers ran on the guard rejection. guard sets its own + # anti-framing header value and litellm's setdefault defers to it, so + # presence (not a specific value) is the property pinned here. + assert rejected.headers["x-frame-options"] in ("DENY", "SAMEORIGIN") + assert rejected.headers["content-security-policy"] == "frame-ancestors 'none'" + assert rejected.headers["access-control-allow-origin"] == "https://ok.example" + + @pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") def test_rate_limit_returns_429(monkeypatch): client_ip: Final = _unique_ip() @@ -135,3 +203,4 @@ def test_redis_stays_off_without_config(monkeypatch): config: Final = _build_guard_config() assert config.enable_redis is False + assert config.redis_url is None From cc605f89baf7a4f76495454db352e0b6a797290e Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 11 Oct 2026 06:01:46 +0200 Subject: [PATCH 19/19] fix(proxy): exercise guard wiring in CI via stub module test The config-building and delegation paths only ran when fastapi-guard was installed, which litellm's CI never has (codecov patch 70%). A stub guard module injected through sys.modules exercises the same wiring everywhere: env parsing, zero-trust proxy default, redis-off default, exclude-path default, and delegation through the middleware. --- .../proxy/middleware/test_guard_middleware.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/unit/proxy/middleware/test_guard_middleware.py b/tests/unit/proxy/middleware/test_guard_middleware.py index 95c1e3cb084..c0f7fd7bac7 100644 --- a/tests/unit/proxy/middleware/test_guard_middleware.py +++ b/tests/unit/proxy/middleware/test_guard_middleware.py @@ -135,6 +135,57 @@ def test_enabled_without_package_fails_closed(monkeypatch): assert len(error_messages) == 1 +class _StubSecurityConfig: + """Captures the kwargs the middleware assembles from the environment.""" + + def __init__(self, **kwargs): # kwargs-ok: mirrors the SecurityConfig keyword surface the wrapper calls + self.kwargs: Final = kwargs + + +class _StubSecurityMiddleware: + """Pass-through stand-in recording how the wrapper wired it.""" + + instances: Final = [] + + def __init__(self, app, config=None): + self.app: Final = app + self.config: Final = config + _StubSecurityMiddleware.instances.append(self) + + async def __call__(self, scope, receive, send): + await self.app(scope, receive, send) + + +def test_enabled_wires_guard_with_env_config(monkeypatch): + """Flag on + importable guard module: the wrapper builds the config + from the environment and delegates through the guard middleware. Uses + a stub module, so the wiring is exercised in CI where the real + optional package is absent.""" + import types + + stub_module: Final = types.ModuleType("guard") + stub_module.SecurityConfig = _StubSecurityConfig + stub_module.SecurityMiddleware = _StubSecurityMiddleware + monkeypatch.setitem(sys.modules, "guard", stub_module) + _StubSecurityMiddleware.instances.clear() + monkeypatch.delenv("LITELLM_GUARD_REDIS_URL", raising=False) + monkeypatch.setenv("LITELLM_GUARD_ENABLED", "true") + monkeypatch.setenv("LITELLM_GUARD_RATE_LIMIT", "33") + client: Final = _client(_unique_ip()) + assert client.get("/ping").status_code == 200 + wired: Final = _StubSecurityMiddleware.instances[-1] + assert isinstance(wired.config, _StubSecurityConfig) + assert wired.config.kwargs["rate_limit"] == 33 + assert wired.config.kwargs["enable_redis"] is False + assert wired.config.kwargs["trusted_proxies"] == () + assert wired.config.kwargs["exclude_paths"] == [ + "/health", + "/health/liveliness", + "/health/readiness", + "/metrics", + ] + + @pytest.mark.skipif(not _GUARD_AVAILABLE, reason="fastapi-guard not installed") def test_blocked_ip_is_rejected(monkeypatch): blocked: Final = _unique_ip()