feat(proxy): throttle failed Admin UI sign-ins per source and source/username

Replace the username-global lockout with counters keyed by source address and by
source/username pair. Each has a fixed counting window (60s) and a separate
block TTL (300s). Blocks are soft: a correct password still signs in, wrong
passwords from a blocked key take one of 5 held slots per worker and are held
30s before a 429. Once a pair is blocked its failures stop counting against the
source. The source scope runs only when trusted_proxy_ranges is set, IPv6 is
grouped by /64, and per-source limits accept IP and CIDR overrides with
longest-prefix matching. Redis is authoritative through one Lua script per
failure, with bounded per-worker fallback when Redis raises.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-15 23:53:14 +00:00
parent 5c0757e990
commit aa7f1e16b8
10 changed files with 1043 additions and 884 deletions

View file

@ -874,7 +874,7 @@ class RedisCache(BaseCache):
return _LUA_COUNT.validate_python(count)
@_redis_circuit_breaker_guard_sync
def batch_get_counts(self, key_list: list[str] | tuple[str, ...]) -> tuple[int | None, ...]:
def batch_get_counts(self, key_list: list[str]) -> tuple[int | None, ...]:
"""Read integer counters for ``key_list``, in order, raising when Redis cannot answer.
``batch_get_cache`` swallows every failure and returns an empty dict, which the caller
@ -885,7 +885,7 @@ class RedisCache(BaseCache):
return _decoded_counts(self._run_redis_mget_operation(keys=namespaced_keys))
@_redis_circuit_breaker_guard
async def async_batch_get_counts(self, key_list: list[str] | tuple[str, ...]) -> tuple[int | None, ...]:
async def async_batch_get_counts(self, key_list: list[str]) -> tuple[int | None, ...]:
"""Async twin of ``batch_get_counts``, raising on failure the same way."""
namespaced_keys: Final = [self.check_and_fix_namespace(key=key) for key in key_list]
return _decoded_counts(await self._async_run_redis_mget_operation(keys=namespaced_keys))

View file

@ -2736,20 +2736,29 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
description="sends alerts if requests hang for 5min+",
)
ui_access_mode: Literal["admin_only", "all"] | None = Field("all", description="Control access to the Proxy UI")
max_failed_login_attempts: int | None = Field(
None,
ge=1,
description="Number of failed Admin UI sign-in attempts allowed for one username, from any source address, within `failed_login_window_seconds`, before further attempts for that username are refused with 429. Attempts are answered with a doubling delay well before this ceiling. Set under `general_settings` in config.yaml. Defaults to 50",
)
max_failed_login_attempts_per_source: int | None = Field(
None,
ge=1,
description="Number of failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`, before further attempts from that address are refused with 429. Counted independently of `max_failed_login_attempts`. Set under `general_settings` in config.yaml. Defaults to 250",
description="Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Only enforced when `trusted_proxy_ranges` is set, since otherwise every client behind an ingress shares one address. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10",
)
max_failed_login_attempts_per_source_overrides: dict[str, int] | None = Field(
None,
description="Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins. Set under `general_settings` in config.yaml",
)
max_failed_login_attempts_per_user: int | None = Field(
None,
ge=1,
description="Failed Admin UI sign-in attempts allowed from one source address for one username within `failed_login_window_seconds`. One more blocks that address for that username for `failed_login_block_seconds`, and its further failures stop counting against `max_failed_login_attempts_per_source`, so a script stuck on one account does not block everyone behind the same address. Set under `general_settings` in config.yaml. Defaults to 5",
)
failed_login_window_seconds: int | None = Field(
None,
ge=1,
description="Fixed window in seconds over which failed Admin UI sign-in attempts are counted. The window starts at the first failure and is not extended by later ones. Set under `general_settings` in config.yaml. Defaults to 900",
description="Fixed window in seconds over which failed Admin UI sign-in attempts are counted. The window starts at the first failure and is not extended by later ones. Set under `general_settings` in config.yaml. Defaults to 60",
)
failed_login_block_seconds: int | None = Field(
None,
ge=1,
description="How long a blocked source address, or source address and username, stays blocked. The block is soft: a correct password still signs in, but each attempt from a blocked key takes one of 5 held slots per worker and a wrong password is held for 30 seconds before it is refused with 429. Set under `general_settings` in config.yaml. Defaults to 300",
)
allowed_routes: list | None = Field(None, description="Proxy API Endpoints you want users to be able to access")
reject_clientside_metadata_tags: bool | None = Field(

View file

@ -1,142 +1,231 @@
"""Failed-login accounting for the Admin UI sign-in path.
Counts failed credential checks over a fixed window against two independent keys, the
username on its own and the source address on its own, so that one username attacked from
many sources and one source spraying many usernames are both counted. Repeated failures
are answered slowly, doubling from one second, and refused with 429 once either counter
reaches its limit. Built per request by ``LoginThrottle.from_request`` because it carries
that request's resolved source address, and because the coordination cache is assigned at
startup and can be reassigned later.
Wrong passwords are counted over a short window per source address and per source-and-username
pair; too many in one window blocks that key for a fixed time. Blocks are soft: a correct password
still signs in, while a wrong one from a blocked key is held open before its 429 and only a few can
be held at once, which bounds how many guesses a blocked key gets checked. A blocked pair stops
counting against its source, so one script stuck on one account does not block the whole office.
"""
from __future__ import annotations
import asyncio
import hashlib
from collections.abc import Awaitable, Callable, Mapping
import ipaddress
import math
import time
from collections.abc import AsyncGenerator, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass
from functools import cache
from types import MappingProxyType
from typing import Final, NamedTuple, NoReturn
from typing import Final, Literal, NamedTuple, NoReturn
from fastapi import Request
from fastapi import Request, status
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.caching.dual_cache import DualCache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.caching.redis_cache import RedisCache
from litellm.proxy._types import ProxyErrorTypes, ProxyException
from litellm.proxy.auth.network import TrustedProxyConfig, normalize_cidr_ranges, resolve_client_ip
from litellm.proxy.auth.trusted_proxy_utils import TRUSTED_PROXY_RANGES_KEY
from litellm.secret_managers.main import get_secret_bool
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS: Final = 50
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE: Final = 250
DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 900
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE: Final = 10
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_USER: Final = 5
DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 60
DEFAULT_FAILED_LOGIN_BLOCK_SECONDS: Final = 300
USERNAME_DELAY_ONSET: Final = 3
SOURCE_DELAY_ONSET: Final = 25
FIRST_DELAY_SECONDS: Final = 1.0
MAX_DELAY_SECONDS: Final = 30.0
MAX_CONCURRENT_DELAYS_PER_SOURCE: Final = 5
BLOCKED_ATTEMPT_HOLD_SECONDS: Final = 30
MAX_HELD_ATTEMPTS_PER_KEY: Final = 5
IPV6_SOURCE_PREFIX_LENGTH: Final = 64
_MAX_DELAY_DOUBLINGS: Final = 16
SOURCE_LIMIT_KEY: Final = "max_failed_login_attempts_per_source"
SOURCE_LIMIT_OVERRIDES_KEY: Final = "max_failed_login_attempts_per_source_overrides"
USER_LIMIT_KEY: Final = "max_failed_login_attempts_per_user"
WINDOW_KEY: Final = "failed_login_window_seconds"
BLOCK_KEY: Final = "failed_login_block_seconds"
TRUSTED_PROXY_RANGES_KEY: Final = "trusted_proxy_ranges"
_CACHE_KEY_PREFIX: Final = "login_fail"
_UNKNOWN_SOURCE: Final = "unknown"
_MAX_LOGGED_USERNAME_CHARS: Final = 128
_MAX_TRACKED_COUNTERS: Final = 20_000
_MAX_TRACKED_BLOCKS: Final = 10_000
_NO_SETTINGS: Final[Mapping[str, object]] = MappingProxyType({})
_NOT_BLOCKED: Final = (0, 0)
_LOCAL_BLOCK_EXPIRY: Final = TypeAdapter[float | None](float | None)
_SOURCE_LIMIT_OVERRIDES: Final = TypeAdapter[Mapping[str, object]](Mapping[str, object])
_MAX_TRACKED_LOGIN_USERNAMES: Final = 10_000
_MAX_TRACKED_LOGIN_SOURCES: Final = 10_000
Scope = Literal["user", "source"]
_BlockTtls = tuple[int, int]
_LUA_BLOCK_TTLS: Final = TypeAdapter[_BlockTtls](_BlockTtls)
_Network = ipaddress.IPv4Network | ipaddress.IPv6Network
# KEYS: pair counter, pair block, source counter, source block (one cluster slot via the source hash tag)
# ARGV: pair limit, source limit (0 = source scope off), window seconds, block seconds
# Both scripts return {pair block TTL, source block TTL}; 0 or below means not blocked
_BLOCK_TTLS_LUA: Final = "return {redis.call('TTL', KEYS[2]), redis.call('TTL', KEYS[4])}"
_RECORD_FAILURE_LUA: Final = (
"local function bump(count_key, block_key, limit) "
"local blocked = redis.call('TTL', block_key) "
"if blocked > 0 then return blocked end "
"local count = redis.call('INCR', count_key) "
"if redis.call('TTL', count_key) < 0 then redis.call('EXPIRE', count_key, ARGV[3]) end "
"if count > limit then redis.call('SET', block_key, '1', 'EX', ARGV[4]) return tonumber(ARGV[4]) end "
"return 0 end "
"local user_block = bump(KEYS[1], KEYS[2], tonumber(ARGV[1])) "
"local source_block = 0 "
"if tonumber(ARGV[2]) > 0 and user_block == 0 then "
"source_block = bump(KEYS[3], KEYS[4], tonumber(ARGV[2])) end "
"return {user_block, source_block}"
)
_COUNTERS: Final = InMemoryCache(
max_size_in_memory=_MAX_TRACKED_COUNTERS, default_ttl=DEFAULT_FAILED_LOGIN_WINDOW_SECONDS
)
_BLOCKS: Final = InMemoryCache(max_size_in_memory=_MAX_TRACKED_BLOCKS, default_ttl=DEFAULT_FAILED_LOGIN_BLOCK_SECONDS)
_HELD_ATTEMPTS: Final[dict[str, int]] = {}
def _bounded_store(max_entries: int) -> DualCache:
return DualCache(
in_memory_cache=InMemoryCache(max_size_in_memory=max_entries),
default_in_memory_ttl=DEFAULT_FAILED_LOGIN_WINDOW_SECONDS,
)
async def _sleep(seconds: float) -> None:
await asyncio.sleep(seconds)
_FAILED_LOGIN_USERNAME_CACHE: Final = _bounded_store(_MAX_TRACKED_LOGIN_USERNAMES)
_FAILED_LOGIN_SOURCE_CACHE: Final = _bounded_store(_MAX_TRACKED_LOGIN_SOURCES)
_NO_SETTINGS: Final = MappingProxyType({})
_UNAVAILABLE: Final = object()
_DELAYS_IN_FLIGHT: Final[dict[str, int]] = {} # mutable-ok: per-source slots taken and released around each held delay
@cache
def _rate_limit_disabled() -> bool:
return get_secret_bool("LITELLM_DISABLE_LOGIN_RATE_LIMIT", default_value=False) is True
@cache
def warn_login_counters_are_per_worker(num_workers: str) -> None:
"""Warn once per process that failed sign-in counters are not shared across workers."""
verbose_proxy_logger.warning(
"Running %s workers but Redis is not configured for LiteLLM caching. "
"Failed Admin UI sign-in attempts are counted per worker, so an attacker "
"gets max_failed_login_attempts guesses per worker instead of overall. "
"Configure Redis via the 'cache' section in your proxy config.",
"Running %s workers but Redis is not configured. Failed Admin UI sign-in attempts are counted "
"per worker, so the effective limits are %s times the configured values. Configure Redis "
"to share one count across workers.",
num_workers,
num_workers,
)
@cache
def _rate_limit_disabled() -> bool:
"""Resolved once per process so an unauthenticated flood never reaches the secret manager."""
return bool(get_secret_bool("LITELLM_DISABLE_LOGIN_RATE_LIMIT", False))
def warn_source_login_limit_is_off() -> None:
verbose_proxy_logger.warning(
"%s is not set, so failed Admin UI sign-in attempts are limited per source address and username "
"only. Set it to the address ranges of the proxies in front of LiteLLM to also limit each "
"source address across usernames.",
TRUSTED_PROXY_RANGES_KEY,
)
async def _sleep(seconds: float) -> None:
"""The wait a rejected sign-in is held for. Replaced in tests so the suite pays no wall clock."""
await asyncio.sleep(seconds)
class FailureCounts(NamedTuple):
"""Failures recorded so far in this window against each of the two keys."""
username: int
source: int
def _parse_int_setting(value: object) -> object:
if not isinstance(value, str):
return value
try:
return int(value.strip())
except ValueError:
return value
def _int_setting(name: str, value: object, default: int, minimum: int) -> int:
if value is None:
def _positive_int(raw: object, key: str, default: int) -> int:
if raw is None:
return default
parsed: Final = _parse_int_setting(value)
if isinstance(parsed, bool) or not isinstance(parsed, int) or parsed < minimum:
try:
value: Final = int(str(raw))
except (TypeError, ValueError):
verbose_proxy_logger.warning("Invalid %s value %r; using %s", key, raw, default)
return default
if value < 1:
verbose_proxy_logger.warning("Invalid %s value %s (must be >= 1); using %s", key, value, default)
return default
return value
def _int_setting(settings: Mapping[str, object], key: str, default: int) -> int:
return _positive_int(settings.get(key), key, default)
def _parse_address(client_ip: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
try:
return ipaddress.ip_address(client_ip)
except ValueError:
return None
def _parse_network(raw_range: str) -> _Network | None:
try:
return ipaddress.ip_network(raw_range.strip(), strict=False)
except ValueError:
verbose_proxy_logger.warning(
"general_settings.%s=%r is not an integer >= %s; using the default of %s", name, value, minimum, default
"Invalid address or range %r in %s; skipping", raw_range, SOURCE_LIMIT_OVERRIDES_KEY
)
return None
def _source_limit(settings: Mapping[str, object], client_ip: str) -> int:
"""Failure allowance for this address: the most specific configured range containing it, else the default."""
default: Final = _int_setting(settings, SOURCE_LIMIT_KEY, DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE)
raw_overrides: Final = settings.get(SOURCE_LIMIT_OVERRIDES_KEY)
if raw_overrides is None:
return default
try:
overrides: Final = _SOURCE_LIMIT_OVERRIDES.validate_python(raw_overrides)
except ValidationError:
verbose_proxy_logger.warning(
"Invalid %s value; expected a mapping of address or range to limit", SOURCE_LIMIT_OVERRIDES_KEY
)
return default
return parsed
address: Final = _parse_address(client_ip)
if address is None:
return default
matches: Final = sorted(
(network.prefixlen, _positive_int(raw_limit, SOURCE_LIMIT_OVERRIDES_KEY, default))
for raw_range, raw_limit in overrides.items()
if (network := _parse_network(raw_range)) is not None and address in network
)
return matches[-1][1] if matches else default
def _as_count(cached: object) -> int:
return int(cached) if isinstance(cached, int | float) and not isinstance(cached, bool) else 0
def source_group(client_ip: str) -> str:
"""The bucket an address is counted in: IPv4 as is, IPv6 by its /64, so one prefix holder cannot rotate."""
address: Final = _parse_address(client_ip)
if address is None:
return client_ip
if isinstance(address, ipaddress.IPv6Address):
mapped: Final = address.ipv4_mapped
if mapped is not None:
return str(mapped)
return str(ipaddress.ip_network((address, IPV6_SOURCE_PREFIX_LENGTH), strict=False))
return str(address)
class _Keys(NamedTuple):
pair_counter: str
pair_block: str
source_counter: str
source_block: str
@dataclass(frozen=True, slots=True)
class Block:
scope: Scope
retry_after: int
@dataclass(frozen=True, slots=True)
class LoginThrottle:
"""Fixed-window failed-login accounting for one request's username and source address."""
"""Failed-login limits for one request's source address; ``source_limit`` is None when the
source scope is off because ``trusted_proxy_ranges`` is unset and the peer address is the ingress."""
client_ip: str
max_attempts: int
max_attempts_per_source: int
source_limit: int | None
user_limit: int
window_seconds: int
username_cache: DualCache
source_cache: DualCache
block_seconds: int
counters: InMemoryCache
blocks: InMemoryCache
redis_cache: RedisCache | None = None
enabled: bool = True
@classmethod
def from_request(
cls, request: Request, general_settings: Mapping[str, object] | None, redis_cache: RedisCache | None
) -> "LoginThrottle":
"""Build the throttle for this request from the proxy's general_settings and shared Redis cache."""
settings: Final = general_settings or _NO_SETTINGS
cls,
request: Request,
general_settings: Mapping[str, object] | None,
redis_cache: RedisCache | None,
) -> LoginThrottle:
settings: Final = general_settings if general_settings is not None else _NO_SETTINGS
cidrs: Final = normalize_cidr_ranges(
settings.get(TRUSTED_PROXY_RANGES_KEY), setting_name=TRUSTED_PROXY_RANGES_KEY
)
@ -145,199 +234,166 @@ class LoginThrottle:
)
return cls(
client_ip=resolved or _UNKNOWN_SOURCE,
max_attempts=_int_setting(
"max_failed_login_attempts",
settings.get("max_failed_login_attempts"),
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS,
1,
),
max_attempts_per_source=_int_setting(
"max_failed_login_attempts_per_source",
settings.get("max_failed_login_attempts_per_source"),
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE,
1,
),
window_seconds=_int_setting(
"failed_login_window_seconds",
settings.get("failed_login_window_seconds"),
DEFAULT_FAILED_LOGIN_WINDOW_SECONDS,
1,
),
username_cache=_FAILED_LOGIN_USERNAME_CACHE,
source_cache=_FAILED_LOGIN_SOURCE_CACHE,
source_limit=_source_limit(settings, resolved) if cidrs and resolved is not None else None,
user_limit=_int_setting(settings, USER_LIMIT_KEY, DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_USER),
window_seconds=_int_setting(settings, WINDOW_KEY, DEFAULT_FAILED_LOGIN_WINDOW_SECONDS),
block_seconds=_int_setting(settings, BLOCK_KEY, DEFAULT_FAILED_LOGIN_BLOCK_SECONDS),
counters=_COUNTERS,
blocks=_BLOCKS,
redis_cache=redis_cache,
enabled=not _rate_limit_disabled(),
)
@staticmethod
def _loggable(username: str) -> str:
"""The username with anything that could forge a log line removed."""
return "".join(c for c in username if c.isprintable())[:_MAX_LOGGED_USERNAME_CHARS]
def _keys(self, username: str) -> _Keys:
group: Final = source_group(self.client_ip)
user: Final = hashlib.sha256(username.casefold().encode("utf-8")).hexdigest()
return _Keys(
pair_counter=f"{_CACHE_KEY_PREFIX}:{{{group}}}:user:{user}",
pair_block=f"{_CACHE_KEY_PREFIX}:{{{group}}}:block:user:{user}",
source_counter=f"{_CACHE_KEY_PREFIX}:{{{group}}}:source",
source_block=f"{_CACHE_KEY_PREFIX}:{{{group}}}:block:source",
)
@staticmethod
def _username_key(username: str) -> str:
identity: Final = hashlib.sha256(username.casefold().encode("utf-8")).hexdigest()
return f"{_CACHE_KEY_PREFIX}:user:{identity}"
def _source_key(self) -> str:
return f"{_CACHE_KEY_PREFIX}:source:{self.client_ip}"
async def _outcome(self, work: Awaitable[object]) -> object:
@asynccontextmanager
async def attempt(self, username: str, *, exempt: bool = False) -> AsyncGenerator[LoginAttempt]:
if not self.enabled or exempt:
yield LoginAttempt(throttle=self, username=username, block=None)
return
keys: Final = self._keys(username)
block: Final = await self._active_block(keys)
if block is None:
yield LoginAttempt(throttle=self, username=username, block=None)
return
slot: Final = keys.pair_block if block.scope == "user" else keys.source_block
held: Final = _HELD_ATTEMPTS.get(slot, 0)
if held >= MAX_HELD_ATTEMPTS_PER_KEY:
verbose_proxy_logger.warning(
"Admin UI sign-in refused: %s attempts already held for a blocked %s; username=%r source=%s",
held,
block.scope,
username,
self.client_ip,
)
self.refuse(BLOCKED_ATTEMPT_HOLD_SECONDS)
_HELD_ATTEMPTS[slot] = held + 1
try:
return await work
except Exception as exc: # noqa: BLE001 # an unreachable cache must never deny a valid credential
verbose_proxy_logger.warning("login attempt accounting unavailable: %s", exc)
return _UNAVAILABLE
yield LoginAttempt(throttle=self, username=username, block=block)
finally:
remaining: Final = _HELD_ATTEMPTS.get(slot, 1) - 1
if remaining > 0:
_HELD_ATTEMPTS[slot] = remaining
else:
_HELD_ATTEMPTS.pop(slot, None)
async def _shared(self, work: Callable[[RedisCache], Awaitable[object]]) -> object:
"""The Redis result, or ``_UNAVAILABLE`` when Redis is not configured or the call raised."""
redis_cache: Final = self.redis_cache
if redis_cache is None:
return _UNAVAILABLE
return await self._outcome(work(redis_cache))
async def _active_block(self, keys: _Keys) -> Block | None:
local: Final = self._local_block_ttls(keys)
shared: Final = await self._shared_block_ttls(keys)
user_ttl: Final = max(local[0], shared[0])
source_ttl: Final = max(local[1], shared[1])
if user_ttl > 0:
return Block(scope="user", retry_after=user_ttl)
if self.source_limit is not None and source_ttl > 0:
return Block(scope="source", retry_after=source_ttl)
return None
async def _failures(self, store: DualCache, key: str) -> int:
"""The shared count plus this worker's own.
A failure is written to exactly one of the two: Redis, or this worker's store when Redis
refused it. So the local store is empty while Redis is healthy, and once Redis answers
again the guesses it missed still count. Read through ``async_batch_get_counts`` because
``async_get_cache`` turns a failed GET into ``None``, which would pass as an empty counter.
"""
local: Final = _as_count(await self._outcome(store.async_get_cache(key=key)))
shared: Final = await self._shared(lambda redis_cache: redis_cache.async_batch_get_counts((key,)))
if not isinstance(shared, tuple):
return local
return _as_count(shared[0]) + local
async def _remaining_window(self, key: str) -> int:
"""Seconds until this counter expires.
Counters are only ever written together with their expiry, so a counter without one
was stripped out of band (PERSIST, a restore). It is given the full window again,
since nothing increments a key once the limit is reached.
"""
async def _shared_block_ttls(self, keys: _Keys) -> _BlockTtls:
if self.redis_cache is None:
return self.window_seconds
ttl: Final = await self._shared(lambda redis_cache: redis_cache.async_get_ttl(key))
if isinstance(ttl, int) and ttl > 0:
return min(ttl, self.window_seconds)
await self._shared(lambda redis_cache: redis_cache.async_increment_with_floor(key, 0, self.window_seconds))
return self.window_seconds
return _NOT_BLOCKED
try:
return _LUA_BLOCK_TTLS.validate_python(
await self.redis_cache.async_register_script(_BLOCK_TTLS_LUA)(list(keys), [])
)
except Exception as err:
self._warn_redis(err)
return _NOT_BLOCKED
def _refused(self, retry_after: int, param: str) -> ProxyException:
return ProxyException(
def _local_block_ttls(self, keys: _Keys) -> _BlockTtls:
return self._local_block_ttl(keys.pair_block), self._local_block_ttl(keys.source_block)
def _local_block_ttl(self, block_key: str) -> int:
expires_at: Final = _LOCAL_BLOCK_EXPIRY.validate_python(self.blocks.get_cache(block_key))
if expires_at is None:
return 0
return max(math.ceil(expires_at - time.time()), 0)
async def record_failure(self, username: str) -> _BlockTtls:
keys: Final = self._keys(username)
source_limit: Final = self.source_limit or 0
if self.redis_cache is not None:
try:
return _LUA_BLOCK_TTLS.validate_python(
await self.redis_cache.async_register_script(_RECORD_FAILURE_LUA)(
list(keys), [self.user_limit, source_limit, self.window_seconds, self.block_seconds]
)
)
except Exception as err:
self._warn_redis(err)
user_block: Final = self._local_bump(keys.pair_counter, keys.pair_block, self.user_limit)
if source_limit == 0 or user_block > 0:
return user_block, 0
return user_block, self._local_bump(keys.source_counter, keys.source_block, source_limit)
def _local_bump(self, count_key: str, block_key: str, limit: int) -> int:
blocked: Final = self._local_block_ttl(block_key)
if blocked > 0:
return blocked
count: Final = int(self.counters.increment_cache(count_key, 1, ttl=self.window_seconds))
if count <= limit:
return 0
self.blocks.set_cache(block_key, time.time() + self.block_seconds, ttl=self.block_seconds)
return self.block_seconds
async def clear_pair(self, username: str) -> None:
pair_counter: Final = self._keys(username).pair_counter
if self.redis_cache is not None:
try:
await self.redis_cache.async_delete_cache(pair_counter)
except Exception as err:
self._warn_redis(err)
self.counters.delete_cache(pair_counter)
def _warn_redis(self, err: Exception) -> None:
verbose_proxy_logger.warning(
"Redis failed while counting Admin UI sign-in attempts; using this worker's own counters "
"until it recovers: %s",
err,
)
@staticmethod
def refuse(retry_after: int) -> NoReturn:
raise ProxyException(
message="Too many failed sign-in attempts. Try again later.",
type=ProxyErrorTypes.auth_error,
param=param,
code=429,
headers={"Retry-After": str(retry_after)}, # mutable-ok: ProxyException coerces header values
param="username",
code=status.HTTP_429_TOO_MANY_REQUESTS,
headers={"Retry-After": str(retry_after)},
)
async def _refuse(self, key: str, scope: str, param: str, username: str, failures: int, limit: int) -> NoReturn:
retry_after: Final = await self._remaining_window(key)
@dataclass(frozen=True, slots=True)
class LoginAttempt:
throttle: LoginThrottle
username: str
block: Block | None
async def succeeded(self) -> None:
if not self.throttle.enabled:
return
await self.throttle.clear_pair(self.username)
async def failed(self) -> None:
if not self.throttle.enabled:
return
if self.block is not None:
await _sleep(BLOCKED_ATTEMPT_HOLD_SECONDS)
self.throttle.refuse(max(self.block.retry_after - BLOCKED_ATTEMPT_HOLD_SECONDS, 1))
user_block, source_block = await self.throttle.record_failure(self.username)
if user_block == 0 and source_block == 0:
return
verbose_proxy_logger.warning(
"Admin UI sign-in attempts exhausted for %s; username=%s source=%s failures=%s limit=%s window=%ss",
scope,
self._loggable(username),
self.client_ip,
failures,
limit,
self.window_seconds,
"Admin UI sign-in blocked for %s seconds after too many failures; scope=%s username=%r source=%s",
user_block or source_block,
"user" if user_block else "source",
self.username,
self.throttle.client_ip,
)
raise self._refused(retry_after, param)
async def raise_if_blocked(self, username: str) -> None:
"""Refuse before the database lookup and before the invite-link password hash."""
if not self.enabled:
return
username_key: Final = self._username_key(username)
source_key: Final = self._source_key()
username_failures: Final = await self._failures(self.username_cache, username_key)
if username_failures >= self.max_attempts:
await self._refuse(
username_key, "username", "max_failed_login_attempts", username, username_failures, self.max_attempts
)
source_failures: Final = await self._failures(self.source_cache, source_key)
if source_failures >= self.max_attempts_per_source:
await self._refuse(
source_key,
"source address",
"max_failed_login_attempts_per_source",
username,
source_failures,
self.max_attempts_per_source,
)
async def _bump(self, store: DualCache, key: str) -> int:
shared: Final = await self._shared(
lambda redis_cache: redis_cache.async_increment_with_floor(key, 1, self.window_seconds)
)
if shared is _UNAVAILABLE:
return _as_count(
await self._outcome(store.async_increment_cache(key=key, value=1, ttl=self.window_seconds))
)
return _as_count(shared) + _as_count(await self._outcome(store.async_get_cache(key=key)))
async def record_failure(self, username: str) -> FailureCounts:
"""Count one rejected credential guess against this username and against this source."""
if not self.enabled:
return FailureCounts(username=0, source=0)
return FailureCounts(
username=await self._bump(self.username_cache, self._username_key(username)),
source=await self._bump(self.source_cache, self._source_key()),
)
@staticmethod
def delay_seconds(counts: FailureCounts) -> float:
"""Seconds to hold a rejected attempt for, doubling per failure past whichever onset is further along."""
steps: Final = min(
max(counts.username - USERNAME_DELAY_ONSET, counts.source - SOURCE_DELAY_ONSET),
_MAX_DELAY_DOUBLINGS,
)
if steps < 0:
return 0.0
return min(FIRST_DELAY_SECONDS * float(2**steps), MAX_DELAY_SECONDS)
async def delay_for(self, username: str, counts: FailureCounts) -> None:
"""Hold this rejected attempt open before answering it, so guessing costs wall-clock time.
Only ever reached once the credentials are known to be wrong, so a valid password is
never delayed. Sources are capped at ``MAX_CONCURRENT_DELAYS_PER_SOURCE`` held
connections; over that, the attempt is refused immediately instead of parking a socket.
"""
if not self.enabled:
return
delay: Final = self.delay_seconds(counts)
if delay <= 0:
return
in_flight: Final = _DELAYS_IN_FLIGHT.get(self.client_ip, 0)
if in_flight >= MAX_CONCURRENT_DELAYS_PER_SOURCE:
verbose_proxy_logger.warning(
"Admin UI sign-in attempts held concurrently exhausted; username=%s source=%s in_flight=%s",
self._loggable(username),
self.client_ip,
in_flight,
)
raise self._refused(int(MAX_DELAY_SECONDS), "concurrent_failed_logins")
_DELAYS_IN_FLIGHT[self.client_ip] = in_flight + 1
try:
await _sleep(delay)
finally:
remaining: Final = _DELAYS_IN_FLIGHT.get(self.client_ip, 1) - 1
if remaining > 0:
_DELAYS_IN_FLIGHT[self.client_ip] = remaining
else:
_DELAYS_IN_FLIGHT.pop(self.client_ip, None)
async def clear(self, username: str) -> None:
"""Drop the username counter after a successful sign-in.
The source counter is left alone. It is shared by every account behind that address,
so one success there says nothing about the other attempts it is counting.
"""
if not self.enabled:
return
key: Final = self._username_key(username)
await self._shared(lambda redis_cache: redis_cache.async_delete_cache(key))
await self._outcome(self.username_cache.async_delete_cache(key=key))

View file

@ -27,7 +27,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
from litellm.proxy.auth.login_throttle import LoginThrottle
from litellm.proxy.auth.login_throttle import LoginAttempt, LoginThrottle
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
@ -219,9 +219,21 @@ async def authenticate_user(
admin_credentials_match: Final = _admin_credentials_match(username, password, master_key, general_settings)
if not admin_credentials_match:
await throttle.raise_if_blocked(username)
async with throttle.attempt(username, exempt=admin_credentials_match) as attempt:
return await _sign_in(
username, password, master_key, prisma_client, attempt, general_settings, admin_credentials_match
)
async def _sign_in(
username: str,
password: str,
master_key: str,
prisma_client: PrismaClient | None,
attempt: LoginAttempt,
general_settings: Mapping[str, object],
admin_credentials_match: bool,
) -> LoginResult:
# Check if we can find the `username` in the db. On the UI, users can enter username=their email
_user_row: LiteLLM_UserTable | None = None
user_role: (
@ -315,7 +327,7 @@ async def authenticate_user(
key = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(user_info)
await throttle.clear(username)
await attempt.succeeded()
return LoginResult(
user_id=user_id,
@ -372,7 +384,7 @@ async def authenticate_user(
key = response["token"]
await throttle.clear(username)
await attempt.succeeded()
return LoginResult(
user_id=user_id,
@ -382,7 +394,7 @@ async def authenticate_user(
login_method="username_password",
)
else:
await throttle.delay_for(username, await throttle.record_failure(username))
await attempt.failed()
raise ProxyException(
message=_invalid_credentials_message(general_settings),
type=ProxyErrorTypes.auth_error,
@ -390,7 +402,7 @@ async def authenticate_user(
code=401,
)
else:
await throttle.delay_for(username, await throttle.record_failure(username))
await attempt.failed()
raise ProxyException(
message=_invalid_credentials_message(general_settings),
type=ProxyErrorTypes.auth_error,

View file

@ -325,7 +325,12 @@ from litellm.proxy.auth.auth_utils import (
from litellm.proxy.auth.fallback_model_access import router_fallback_access_check
from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY, LicenseCheck
from litellm.proxy.auth.login_throttle import LoginThrottle, warn_login_counters_are_per_worker
from litellm.proxy.auth.login_throttle import (
TRUSTED_PROXY_RANGES_KEY,
LoginThrottle,
warn_login_counters_are_per_worker,
warn_source_login_limit_is_off,
)
from litellm.proxy.auth.model_checks import (
expand_wildcard_deployments_for_model_info,
get_all_fallbacks,
@ -5803,11 +5808,10 @@ class ProxyConfig:
if general_settings is None:
general_settings = {}
### FAILED-LOGIN ACCOUNTING MULTI-INSTANCE PREREQUISITE CHECK ###
# Failed Admin UI sign-in counters live in redis_usage_cache when available so a
# brute-force run is counted once across workers instead of once per worker.
if os.getenv("NUM_WORKERS", "1") != "1" and redis_usage_cache is None:
warn_login_counters_are_per_worker(os.getenv("NUM_WORKERS", "1"))
if not general_settings.get(TRUSTED_PROXY_RANGES_KEY):
warn_source_login_limit_is_off()
_bg_hc_model_groups: Final = parse_background_health_check_model_groups(general_settings)
_enable_hc_routing = False

File diff suppressed because it is too large Load diff

View file

@ -517,38 +517,25 @@ def make_key(
def reset_login_throttle(monkeypatch):
"""Clear the Admin UI failed-login counters between tests.
`client` is session scoped and the counters live in shared module stores with a 900s
window, so without this a failed sign-in test could return 429 in unrelated tests later.
`client` is session scoped and the counters live in shared module stores with a 300s block
window, so without this a failed sign-in test could block unrelated tests later.
Only the throttle's own keys are removed, so other cache entries remain untouched.
"""
from litellm.proxy import proxy_server as ps
from litellm.proxy.auth import login_throttle
from litellm.proxy.auth.login_throttle import (
_CACHE_KEY_PREFIX,
_FAILED_LOGIN_SOURCE_CACHE,
_FAILED_LOGIN_USERNAME_CACHE,
)
from litellm.proxy.auth.login_throttle import _BLOCKS, _CACHE_KEY_PREFIX, _COUNTERS
async def _no_delay(_seconds: float) -> None:
"""The escalating wait on a rejected sign-in, replaced so the route tests stay fast."""
"""The hold on a rejected sign-in from a blocked key, replaced so the route tests stay fast."""
monkeypatch.setattr(login_throttle, "_sleep", _no_delay)
def _drop_throttle_keys() -> None:
login_throttle._DELAYS_IN_FLIGHT.clear()
for cache in (_FAILED_LOGIN_USERNAME_CACHE, _FAILED_LOGIN_SOURCE_CACHE):
in_memory = getattr(cache, "in_memory_cache", None)
if in_memory is None:
continue
tracked = tuple(
key
for store in (getattr(in_memory, "cache_dict", None), getattr(in_memory, "ttl_dict", None))
if isinstance(store, dict)
for key in tuple(store)
if str(key).startswith(_CACHE_KEY_PREFIX)
)
for key in tracked:
in_memory.delete_cache(key)
login_throttle._HELD_ATTEMPTS.clear()
for store in (_COUNTERS, _BLOCKS):
for key in tuple(store.cache_dict) + tuple(store.ttl_dict):
if key.startswith(_CACHE_KEY_PREFIX):
store.delete_cache(key)
monkeypatch.setattr(ps, "redis_usage_cache", None)
_drop_throttle_keys()

View file

@ -10,11 +10,8 @@ Routes covered:
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import AsyncMock, MagicMock
import pytest
from .conftest import normalize
# ---------------------------------------------------------------------------
@ -495,15 +492,38 @@ def _install_real_auth(monkeypatch, **settings):
def _form_login(client, username="admin", password="wrong"):
return client.post(
"/login", data={"username": username, "password": password}, follow_redirects=False
).status_code
return client.post("/login", data={"username": username, "password": password}, follow_redirects=False).status_code
def _json_login(client, path, username="admin", password="wrong"):
return client.post(path, json={"username": username, "password": password}).status_code
def _db_user(monkeypatch, email: str):
"""A database user with a stored hash, faked so the route reaches the known-user branch without Postgres."""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy import proxy_server as ps
user = MagicMock()
user.user_id = "u-1"
user.user_email = email
user.user_role = "internal_user"
user.password = "scrypt:stored"
repo = MagicMock()
repo.return_value.table.find_first = AsyncMock(return_value=user)
monkeypatch.setattr(ps, "prisma_client", MagicMock())
monkeypatch.setattr("litellm.proxy.auth.login_utils.UserRepository", repo)
monkeypatch.setattr("litellm.proxy.auth.login_utils._rehash_password_if_needed", AsyncMock())
monkeypatch.setattr(
"litellm.proxy.auth.login_utils.verify_password", lambda given, stored: given == "right-db-password"
)
monkeypatch.setattr(
"litellm.proxy.auth.login_utils.generate_key_helper_fn", AsyncMock(return_value={"token": "sk-ui"})
)
monkeypatch.setenv("DATABASE_URL", "postgresql://stub")
def test_budget_is_shared_across_every_login_endpoint(client, monkeypatch, reset_login_throttle):
"""The endpoint is not part of the key, so spending the budget on one route blocks the rest.
@ -511,92 +531,117 @@ def test_budget_is_shared_across_every_login_endpoint(client, monkeypatch, reset
"""
_install_real_auth(
monkeypatch,
max_failed_login_attempts=10,
max_failed_login_attempts_per_user=10,
control_plane_url="https://cp.example.com",
)
assert [_form_login(client) for _ in range(5)] == [401] * 5
assert [_json_login(client, "/v2/login") for _ in range(5)] == [401] * 5
assert _json_login(client, "/v3/login") == 429, "the eleventh attempt must be refused on a third route"
assert _json_login(client, "/v3/login") == 401, "the eleventh failure crosses the limit and installs the block"
assert _json_login(client, "/v3/login") == 429, "the twelfth attempt must be refused on a third route"
def test_budget_is_shared_across_username_casing(client, monkeypatch, reset_login_throttle):
"""The database lookup is case-insensitive, so casing must not partition the counter."""
_install_real_auth(monkeypatch, max_failed_login_attempts=10)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=3)
assert [_json_login(client, "/v2/login", username="admin@corp.com") for _ in range(5)] == [401] * 5
assert [_json_login(client, "/v2/login", username="ADMIN@corp.com") for _ in range(5)] == [401] * 5
assert [_json_login(client, "/v2/login", username="admin@corp.com") for _ in range(2)] == [401] * 2
assert [_json_login(client, "/v2/login", username="ADMIN@corp.com") for _ in range(2)] == [401] * 2
assert _json_login(client, "/v2/login", username="Admin@corp.com") == 429
def test_a_refused_attempt_carries_retry_after(client, monkeypatch, reset_login_throttle):
"""The 429 tells the caller how long the window has left."""
_install_real_auth(monkeypatch, max_failed_login_attempts=2, failed_login_window_seconds=77)
"""The 429 tells the caller how long the block has left, after the 30 seconds it was already held."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1, failed_login_block_seconds=77)
assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401]
refused = client.post("/v2/login", json={"username": "admin", "password": "wrong"})
assert refused.status_code == 429
assert refused.headers.get("retry-after") == "77"
assert refused.headers.get("retry-after") == "47"
def test_the_form_returns_a_human_readable_lockout_page(client, monkeypatch, reset_login_throttle):
"""The no-JavaScript form must render a wait page when its POST is throttled."""
_install_real_auth(monkeypatch, max_failed_login_attempts=2, failed_login_window_seconds=77)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1, failed_login_block_seconds=77)
assert [_form_login(client) for _ in range(2)] == [401, 401]
refused = client.post("/login", data={"username": "admin", "password": "wrong"})
assert refused.status_code == 429
assert refused.headers.get("content-type", "").startswith("text/html")
assert "Try again in about 77 seconds" in refused.text
assert refused.headers.get("retry-after") == "77"
assert "Try again in about 47 seconds" in refused.text
assert refused.headers.get("retry-after") == "47"
def test_a_second_username_from_the_same_source_still_gets_through(client, monkeypatch, reset_login_throttle):
"""The username counter carries no address, so one account exhausting it cannot block another."""
_install_real_auth(monkeypatch, max_failed_login_attempts=2)
"""The pair block is per username, so one account's block cannot take the office down with it."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
for _ in range(3):
_json_login(client, "/v2/login", username="admin")
assert [_json_login(client, "/v2/login", username="admin") for _ in range(3)] == [401, 401, 429]
assert _json_login(client, "/v2/login", username="someone-else@example.com") == 401
def test_a_spray_across_usernames_is_refused_on_the_source_counter(client, monkeypatch, reset_login_throttle):
"""A fresh username per guess keeps every username counter at one, so the address is what stops it."""
_install_real_auth(monkeypatch, max_failed_login_attempts=100, max_failed_login_attempts_per_source=4)
def test_a_spray_across_usernames_is_blocked_on_the_source_when_the_source_is_attributable(
client, monkeypatch, reset_login_throttle
):
"""A fresh username per guess keeps every pair at one, so the address is what stops it."""
_install_real_auth(monkeypatch, trusted_proxy_ranges=["10.0.0.0/8"], max_failed_login_attempts_per_source=4)
sprayed = [_json_login(client, "/v2/login", username=f"sprayed-{i}@corp.com") for i in range(4)]
assert sprayed == [401] * 4
sprayed = [_json_login(client, "/v2/login", username=f"sprayed-{i}@corp.com") for i in range(5)]
assert sprayed == [401] * 5
assert _json_login(client, "/v2/login", username="sprayed-5@corp.com") == 429
assert _json_login(client, "/v2/login", username="sprayed-6@corp.com") == 429
def test_the_configured_admin_password_still_signs_in_while_refused(client, monkeypatch, reset_login_throttle):
def test_a_spray_across_usernames_is_not_blocked_without_trusted_proxy_ranges(
client, monkeypatch, reset_login_throttle
):
"""Without a configured proxy range the peer address is whoever fronts the proxy, shared by every
client, so a source-wide block would block them all and the source scope stays off."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=4)
sprayed = [_json_login(client, "/v2/login", username=f"sprayed-{i}@corp.com") for i in range(8)]
assert sprayed == [401] * 8
def test_the_configured_admin_password_still_signs_in_while_blocked(client, monkeypatch, reset_login_throttle):
"""The operator must never be locked out of the console by traffic aimed at it."""
from unittest.mock import AsyncMock, patch
_install_real_auth(monkeypatch, max_failed_login_attempts=2)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
monkeypatch.setenv("DATABASE_URL", "postgresql://stub")
assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401]
assert _json_login(client, "/v2/login") == 429
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( # test-quality-ok: success mints a UI key and persists the user; faked so no DB is needed
"litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"})
with (
patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()),
patch( # test-quality-ok: success mints a UI key and persists the user; faked so no DB is needed
"litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"})
),
):
assert _json_login(client, "/v2/login", password="right-password") == 200
def test_sign_in_succeeds_again_once_the_budget_is_restored(client, monkeypatch, reset_login_throttle):
"""A cleared bucket lets the same username straight back in."""
_install_real_auth(monkeypatch, max_failed_login_attempts=2)
def test_a_database_users_correct_password_signs_in_while_blocked(client, monkeypatch, reset_login_throttle):
"""The block is soft: guessing at an account slows the guesser down, it does not lock the owner out."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
_db_user(monkeypatch, "user@corp.com")
assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401]
assert _json_login(client, "/v2/login") == 429
assert [_json_login(client, "/v2/login", username="user@corp.com") for _ in range(3)] == [401, 401, 429]
assert _json_login(client, "/v2/login", username="user@corp.com", password="right-db-password") == 200
assert _json_login(client, "/v2/login", username="user@corp.com") == 429, "the block itself is still in force"
def test_sign_in_succeeds_again_once_the_block_is_cleared(client, monkeypatch, reset_login_throttle):
"""A cleared store lets the same username straight back to a plain credential check."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]
reset_login_throttle()
assert _json_login(client, "/v2/login") == 401

View file

@ -3438,6 +3438,34 @@ async def test_load_config_warns_per_worker_login_counters_without_general_setti
assert "Running 4 workers but Redis is not configured" in caplog.text
@pytest.mark.asyncio
async def test_load_config_warns_that_the_source_login_limit_is_off_without_trusted_proxy_ranges(
tmp_path, monkeypatch, caplog
):
"""The per-source failed-login limit is skipped when the source cannot be attributed, and the
operator must be told so at startup; a configured range silences it."""
import logging
from litellm.proxy.auth.login_throttle import warn_source_login_limit_is_off
from litellm.proxy.proxy_server import ProxyConfig
monkeypatch.setenv("NUM_WORKERS", "1")
warn_source_login_limit_is_off.cache_clear()
config_file = tmp_path / "config.yaml"
config_file.write_text("model_list: []\n")
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
assert "trusted_proxy_ranges is not set" in caplog.text
caplog.clear()
warn_source_login_limit_is_off.cache_clear()
config_file.write_text("model_list: []\ngeneral_settings:\n trusted_proxy_ranges: ['10.0.0.0/8']\n")
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
assert "trusted_proxy_ranges is not set" not in caplog.text
@pytest.mark.asyncio
async def test_load_environment_variables_direct_and_os_environ():
"""
@ -13331,14 +13359,16 @@ async def test_login_throttle_settings_are_not_hot_applied_from_the_database():
ps.general_settings.clear()
await ProxyConfig()._update_general_settings(
db_general_settings={
"max_failed_login_attempts": 999,
"max_failed_login_attempts_per_user": 999,
"max_failed_login_attempts_per_source": 999,
"failed_login_window_seconds": 1,
"failed_login_block_seconds": 1,
}
)
assert "max_failed_login_attempts" not in ps.general_settings
assert "max_failed_login_attempts_per_user" not in ps.general_settings
assert "max_failed_login_attempts_per_source" not in ps.general_settings
assert "failed_login_window_seconds" not in ps.general_settings
assert "failed_login_block_seconds" not in ps.general_settings
finally:
ps.general_settings.clear()
ps.general_settings.update(original)

View file

@ -26447,9 +26447,14 @@ export interface components {
* @description If True, router fallbacks configured in router_settings are only attempted when the calling key (and its team and project) is allowed to call the fallback model; unauthorized fallback targets are skipped and the primary model's error is returned. Default is False.
*/
enforce_fallback_model_access?: boolean | null;
/**
* Failed Login Block Seconds
* @description How long a blocked source address, or source address and username, stays blocked. The block is soft: a correct password still signs in, but each attempt from a blocked key takes one of 5 held slots per worker and a wrong password is held for 30 seconds before it is refused with 429. Set under `general_settings` in config.yaml. Defaults to 300
*/
failed_login_block_seconds?: number | null;
/**
* Failed Login Window Seconds
* @description Fixed window in seconds over which failed Admin UI sign-in attempts are counted. The window starts at the first failure and is not extended by later ones. Set under `general_settings` in config.yaml. Defaults to 900
* @description Fixed window in seconds over which failed Admin UI sign-in attempts are counted. The window starts at the first failure and is not extended by later ones. Set under `general_settings` in config.yaml. Defaults to 60
*/
failed_login_window_seconds?: number | null;
/**
@ -26496,16 +26501,23 @@ export interface components {
* @description max batch input file size in MB for /v1/files uploads with purpose=batch, if a file is larger than this size it will be rejected before being forwarded to the provider
*/
max_batch_file_size_mb?: number | null;
/**
* Max Failed Login Attempts
* @description Number of failed Admin UI sign-in attempts allowed for one username, from any source address, within `failed_login_window_seconds`, before further attempts for that username are refused with 429. Attempts are answered with a doubling delay well before this ceiling. Set under `general_settings` in config.yaml. Defaults to 50
*/
max_failed_login_attempts?: number | null;
/**
* Max Failed Login Attempts Per Source
* @description Number of failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`, before further attempts from that address are refused with 429. Counted independently of `max_failed_login_attempts`. Set under `general_settings` in config.yaml. Defaults to 250
* @description Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Only enforced when `trusted_proxy_ranges` is set, since otherwise every client behind an ingress shares one address. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10
*/
max_failed_login_attempts_per_source?: number | null;
/**
* Max Failed Login Attempts Per Source Overrides
* @description Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins. Set under `general_settings` in config.yaml
*/
max_failed_login_attempts_per_source_overrides?: {
[key: string]: number;
} | null;
/**
* Max Failed Login Attempts Per User
* @description Failed Admin UI sign-in attempts allowed from one source address for one username within `failed_login_window_seconds`. One more blocks that address for that username for `failed_login_block_seconds`, and its further failures stop counting against `max_failed_login_attempts_per_source`, so a script stuck on one account does not block everyone behind the same address. Set under `general_settings` in config.yaml. Defaults to 5
*/
max_failed_login_attempts_per_user?: number | null;
/**
* Max File Size Mb
* @description max file size in MB for /v1/files uploads, for any purpose, if a file is larger than this size it will be rejected before being forwarded to the provider