fix(auth_v2): harden HTTP basic, SAML sessions, and JWKS fetch

Address Greptile security findings in the authenticator, SAML and config layers:

- HTTP Basic accepted any password and copied the cleartext password into
  Principal.claims. Verify the password against an injected BasicAuthVerifier
  (InMemoryBasicAuthStore holds username -> salted sha256, constant-time compared
  with hmac.compare_digest) and stop putting the password in the credential;
  basic with no configured verifier now rejects rather than trusting the caller.
- SAML session cookie gains the Secure flag (httponly and samesite=lax already
  set), gated by SamlConfig.cookie_secure.
- SAML session store gains TTL expiry and max-size eviction
  (SamlConfig.session_ttl_seconds / session_max_size) so it can no longer grow
  unbounded or hand out stale sessions.
- JWKS signing-key lookup ran synchronously inside the async request path and
  blocked the event loop on a cache miss; run the JWT verify off-loop via
  starlette run_in_threadpool on the http-bearer, oauth2 at+jwt and oidc paths.
This commit is contained in:
Yassin Kortam 2026-06-10 18:34:10 -07:00
parent 2dd336a795
commit 71a189bf65
5 changed files with 117 additions and 27 deletions

View file

@ -2,6 +2,10 @@ from __future__ import annotations
import base64
import binascii
import functools
import hashlib
import hmac
import secrets
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
import httpx
@ -9,6 +13,7 @@ import jwt
from fastapi import Request
from jwt import PyJWKClient
from jwt import decode as jwt_decode
from starlette.concurrency import run_in_threadpool
from . import errors
from .config import (
@ -86,6 +91,35 @@ def _credential_from_claims(
)
@runtime_checkable
class BasicAuthVerifier(Protocol):
def verify(self, username: str, password: str) -> bool: ...
def hash_basic_password(password: str, salt: Optional[str] = None) -> str:
salt = salt or secrets.token_hex(16)
digest = hashlib.sha256(bytes.fromhex(salt) + password.encode()).hexdigest()
return f"{salt}${digest}"
class InMemoryBasicAuthStore:
def __init__(self, credentials: Dict[str, str]) -> None:
self._credentials = credentials
def verify(self, username: str, password: str) -> bool:
stored = self._credentials.get(username)
if stored is None:
return False
salt, _, expected = stored.partition("$")
try:
candidate = hashlib.sha256(
bytes.fromhex(salt) + password.encode()
).hexdigest()
except ValueError:
return False
return hmac.compare_digest(candidate, expected)
class JwtVerifier:
def __init__(
self,
@ -134,6 +168,14 @@ class JwtVerifier:
raise errors.invalid_token(str(exc)) from exc
async def _verify_jwt_off_loop(
verifier: JwtVerifier, token: str, *, require_at_jwt: Optional[bool] = None
) -> Dict[str, Any]:
return await run_in_threadpool(
functools.partial(verifier.verify, token, require_at_jwt=require_at_jwt)
)
def _select_verifier(token: str, verifiers: List[JwtVerifier]) -> Optional[JwtVerifier]:
if not verifiers:
return None
@ -173,10 +215,14 @@ class HttpAuthenticator:
scheme = SecuritySchemeType.HTTP
def __init__(
self, basic: HttpBasicConfig, jwt_verifiers: List[JwtVerifier]
self,
basic: HttpBasicConfig,
jwt_verifiers: List[JwtVerifier],
basic_verifier: Optional[BasicAuthVerifier] = None,
) -> None:
self._basic = basic
self._verifiers = jwt_verifiers
self._basic_verifier = basic_verifier
async def authenticate(self, request: Request) -> Optional[Credential]:
header = request.headers.get("authorization")
@ -185,35 +231,38 @@ class HttpAuthenticator:
scheme, _, value = header.partition(" ")
scheme_lower = scheme.lower()
if scheme_lower == "bearer" and value:
return self._verify_bearer(value)
return await self._verify_bearer(value)
if scheme_lower == "basic" and self._basic.enabled and value:
return self._verify_basic(value)
return None
def _verify_bearer(self, token: str) -> Credential:
async def _verify_bearer(self, token: str) -> Credential:
verifier = _select_verifier(token, self._verifiers)
if verifier is None:
raise errors.invalid_token("no issuer match")
claims = verifier.verify(token)
claims = await _verify_jwt_off_loop(verifier, token)
return _credential_from_claims(
self.scheme, AuthMethod.BEARER_JWT, token, claims
)
def _verify_basic(self, value: str) -> Credential:
challenge = errors.basic_challenge(self._basic.realm)
try:
decoded = base64.b64decode(value).decode("utf-8")
except (binascii.Error, UnicodeDecodeError) as exc:
raise errors.unauthenticated(
errors.basic_challenge(self._basic.realm)
) from exc
username, _, password = decoded.partition(":")
if not username:
raise errors.unauthenticated(errors.basic_challenge(self._basic.realm))
raise errors.unauthenticated(challenge) from exc
username, separator, password = decoded.partition(":")
if (
not username
or separator != ":"
or self._basic_verifier is None
or not self._basic_verifier.verify(username, password)
):
raise errors.unauthenticated(challenge)
return Credential(
scheme=self.scheme,
method=AuthMethod.HTTP_BASIC,
subject=username,
claims={"_basic_password": password},
)
def challenge(self) -> str:
@ -239,16 +288,16 @@ class OAuth2Authenticator:
if token is None:
return None
if _looks_like_jwt(token):
return self._verify_at_jwt(token)
return await self._verify_at_jwt(token)
if self._introspection is not None:
return await self._introspect(token)
raise errors.invalid_token()
def _verify_at_jwt(self, token: str) -> Credential:
async def _verify_at_jwt(self, token: str) -> Credential:
verifier = _select_verifier(token, self._verifiers)
if verifier is None:
raise errors.invalid_token("no issuer match")
claims = verifier.verify(token, require_at_jwt=True)
claims = await _verify_jwt_off_loop(verifier, token, require_at_jwt=True)
return _credential_from_claims(
self.scheme, AuthMethod.BEARER_JWT, token, claims
)
@ -301,7 +350,7 @@ class OidcAuthenticator:
verifier = _select_verifier(token, self._verifiers)
if verifier is None:
raise errors.invalid_token("no issuer match")
claims = verifier.verify(token)
claims = await _verify_jwt_off_loop(verifier, token)
return _credential_from_claims(self.scheme, AuthMethod.OIDC, token, claims)
def challenge(self) -> str:
@ -337,12 +386,16 @@ class MutualTlsAuthenticator:
return ""
def build_authenticators(config: AuthConfig) -> List[Authenticator]:
def build_authenticators(
config: AuthConfig, *, basic_verifier: Optional[BasicAuthVerifier] = None
) -> List[Authenticator]:
verifiers = [JwtVerifier(provider) for provider in config.oidc_providers]
by_scheme: Dict[SecuritySchemeType, Authenticator] = {}
if config.api_key is not None:
by_scheme[SecuritySchemeType.API_KEY] = ApiKeyAuthenticator(config.api_key)
by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator(config.http_basic, verifiers)
by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator(
config.http_basic, verifiers, basic_verifier
)
by_scheme[SecuritySchemeType.OPENID_CONNECT] = OidcAuthenticator(verifiers)
by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator(
verifiers, config.oauth2_introspection

View file

@ -68,6 +68,9 @@ class SamlConfig(BaseModel):
sp_cert_file: Optional[str] = None
allow_unsolicited: bool = True
session_cookie: str = "saml_session"
cookie_secure: bool = True
session_ttl_seconds: int = 3600
session_max_size: int = 10000
default_redirect_path: str = "/"
xmlsec_binary: Optional[str] = None
attribute_map: Dict[str, str] = Field(

View file

@ -1,7 +1,8 @@
from __future__ import annotations
import secrets
from typing import Any, Dict, List, Optional
import time
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
@ -138,20 +139,40 @@ def build_sp_client(config: SamlConfig) -> Saml2Client:
class SamlSessionStore:
def __init__(self) -> None:
self._sessions: Dict[str, Dict[str, Any]] = {}
def __init__(self, ttl_seconds: int = 3600, max_size: int = 10000) -> None:
self._sessions: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self.outstanding: Dict[str, str] = {}
self._ttl = ttl_seconds
self._max_size = max_size
def remember_request(self, request_id: str, relay_state: str = "/") -> None:
self.outstanding[request_id] = relay_state
def create_session(self, identity: Dict[str, Any]) -> str:
now = time.time()
self._evict(now)
session_id = secrets.token_urlsafe(32)
self._sessions[session_id] = identity
self._sessions[session_id] = (now + self._ttl, identity)
return session_id
def get(self, session_id: str) -> Optional[Dict[str, Any]]:
return self._sessions.get(session_id)
entry = self._sessions.get(session_id)
if entry is None:
return None
expires_at, identity = entry
if expires_at < time.time():
self._sessions.pop(session_id, None)
return None
return identity
def _evict(self, now: float) -> None:
for key in [k for k, (exp, _) in self._sessions.items() if exp < now]:
self._sessions.pop(key, None)
overflow = len(self._sessions) - self._max_size + 1
if overflow > 0:
oldest = sorted(self._sessions, key=lambda k: self._sessions[k][0])
for key in oldest[:overflow]:
self._sessions.pop(key, None)
class SamlAuthenticator:
@ -248,7 +269,11 @@ def build_saml_router(config: SamlConfig, session_store: SamlSessionStore) -> AP
)
response = RedirectResponse(target, status_code=303)
response.set_cookie(
config.session_cookie, session_id, httponly=True, samesite="lax"
config.session_cookie,
session_id,
httponly=True,
samesite="lax",
secure=config.cookie_secure,
)
return response

View file

@ -7,7 +7,7 @@ from fastapi import FastAPI, Request, Security
from fastapi.security import SecurityScopes
from . import errors
from .authenticators import Authenticator, build_authenticators
from .authenticators import Authenticator, BasicAuthVerifier, build_authenticators
from .config import AuthConfig
from .models import Principal
from .network import resolve_network_context
@ -29,6 +29,7 @@ def install_auth(
resolver: IdentityResolver,
*,
rbac: Optional[RbacEngine] = None,
basic_verifier: Optional[BasicAuthVerifier] = None,
mount_scim: bool = True,
mount_oidc: bool = True,
mount_saml: bool = True,
@ -43,7 +44,12 @@ def install_auth(
rely on uvicorn's own ``--forwarded-allow-ips``. Do not enable both.
"""
engine = rbac if rbac is not None else RbacEngine(config.casbin_policy_path)
ctx = AuthContext(config, build_authenticators(config), resolver, engine)
ctx = AuthContext(
config,
build_authenticators(config, basic_verifier=basic_verifier),
resolver,
engine,
)
app.state.auth_v2 = ctx
if mount_scim:
from .scim import build_scim_router
@ -56,7 +62,10 @@ def install_auth(
if mount_saml and config.saml is not None and config.saml.enabled:
from .saml import SamlAuthenticator, SamlSessionStore, build_saml_router
session_store = SamlSessionStore()
session_store = SamlSessionStore(
ttl_seconds=config.saml.session_ttl_seconds,
max_size=config.saml.session_max_size,
)
ctx.authenticators.append(SamlAuthenticator(config.saml, session_store))
app.include_router(build_saml_router(config.saml, session_store))
return ctx

View file

@ -287,7 +287,7 @@ plugins = "pydantic.mypy"
# scim2-models ships py.typed, but its generic, alias-driven SCIM models report
# phantom call-arg errors under mypy though they work at runtime. Treat the
# library as untyped at the boundary; litellm/auth_v2 is its only consumer.
# library as untyped at the boundary; litellm/proxy/auth_v2 is its only consumer.
# CI runs mypy from litellm/ against litellm/mypy.ini, which carries the same
# override; this block keeps root-level mypy runs consistent.
[[tool.mypy.overrides]]