Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/auth/login_throttle.py: 39%

243 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1"""Failed-login accounting for the Admin UI sign-in path. 

2 

3Wrong passwords are counted over a short window per source address and per source-and-username 

4pair; too many in one window blocks that key for a fixed time. While a key is blocked every attempt 

5from it, right or wrong, is refused with 429 before the password is checked. A blocked pair stops 

6counting against its source, so one script stuck on one account does not block the whole office. 

7Recovery is the master key over the API, which never passes through here, or waiting out the block. 

8""" 

9 

10from __future__ import annotations 

11 

12import asyncio 

13import hashlib 

14import ipaddress 

15import math 

16import time 

17from collections.abc import Mapping 

18from dataclasses import dataclass 

19from functools import cache 

20from typing import Final, Literal, NamedTuple, Protocol, TypeAlias 

21 

22from fastapi import Request, status 

23from pydantic import TypeAdapter, ValidationError 

24from redis.exceptions import RedisError 

25 

26from litellm._logging import verbose_proxy_logger 

27from litellm.caching.in_memory_cache import InMemoryCache 

28from litellm.caching.redis_cache import RedisCache, RedisCircuitBreakerOpenError 

29from litellm.constants import ( 

30 EMPTY_MAPPING, 

31 LOGIN_THROTTLE_CACHE_KEY_PREFIX, 

32 LOGIN_THROTTLE_MAX_TRACKED_BLOCKS, 

33 LOGIN_THROTTLE_MAX_TRACKED_COUNTERS, 

34 LOGIN_THROTTLE_NOT_BLOCKED, 

35 LOGIN_THROTTLE_UNKNOWN_SOURCE, 

36) 

37from litellm.proxy._types import ProxyErrorTypes, ProxyException 

38from litellm.proxy.auth.network import TrustedProxyConfig, resolve_client_ip 

39from litellm.secret_managers.main import get_secret_bool 

40 

41DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE: Final = 10 

42DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 60 

43DEFAULT_FAILED_LOGIN_BLOCK_SECONDS: Final = 300 

44 

45IPV6_SOURCE_PREFIX_LENGTH: Final = 64 

46EXEMPT: Final = 0 

47 

48SOURCE_LIMIT_KEY: Final = "max_failed_login_attempts_per_source" 

49SOURCE_LIMIT_OVERRIDES_KEY: Final = "max_failed_login_attempts_per_source_overrides" 

50WINDOW_KEY: Final = "failed_login_window_seconds" 

51BLOCK_KEY: Final = "failed_login_block_seconds" 

52TRUSTED_PROXY_RANGES_KEY: Final = "trusted_proxy_ranges" 

53 

54_REDIS_FAILURES: Final = (RedisError, RedisCircuitBreakerOpenError, OSError, asyncio.TimeoutError) 

55_LOCAL_BLOCK_EXPIRY: Final = TypeAdapter[float | None](float | None) 

56_SOURCE_LIMIT_OVERRIDES: Final = TypeAdapter[Mapping[str, object]](Mapping[str, object]) 

57_RANGE_ENTRIES: Final = TypeAdapter[tuple[object, ...]](tuple[object, ...]) 

58 

59Scope: TypeAlias = Literal["user", "source"] 

60 

61_BlockTtls: TypeAlias = tuple[int, int] 

62_LUA_BLOCK_TTLS: Final = TypeAdapter[_BlockTtls](_BlockTtls) 

63_Network: TypeAlias = ipaddress.IPv4Network | ipaddress.IPv6Network 

64 

65 

66class LocalStore(Protocol): 

67 """The per-worker store behind the counters and blocks; ``InMemoryCache`` satisfies it.""" 

68 

69 def get_cache(self, key: str) -> object: ... 69 ↛ exitline 69 didn't return from function 'get_cache' because

70 

71 def set_cache(self, key: str, value: float, *, ttl: int) -> None: ... 71 ↛ exitline 71 didn't return from function 'set_cache' because

72 

73 def increment_cache(self, key: str, value: float, *, ttl: int) -> float: ... 73 ↛ exitline 73 didn't return from function 'increment_cache' because

74 

75 def delete_cache(self, key: str) -> None: ... 75 ↛ exitline 75 didn't return from function 'delete_cache' because

76 

77 

78# KEYS: pair counter, pair block, source counter, source block (one cluster slot via the source hash tag) 

79# ARGV: pair limit, source limit (0 = source scope off), window seconds, block seconds 

80# Both scripts return {pair block TTL, source block TTL}; 0 or below means not blocked 

81_BLOCK_TTLS_LUA: Final = "return {redis.call('TTL', KEYS[2]), redis.call('TTL', KEYS[4])}" 

82_RECORD_FAILURE_LUA: Final = ( 

83 "local function bump(count_key, block_key, limit) " 

84 "local blocked = redis.call('TTL', block_key) " 

85 "if blocked > 0 then return blocked end " 

86 "local count = redis.call('INCR', count_key) " 

87 "if redis.call('TTL', count_key) < 0 then redis.call('EXPIRE', count_key, ARGV[3]) end " 

88 "if count > limit then redis.call('SET', block_key, '1', 'EX', ARGV[4]) return tonumber(ARGV[4]) end " 

89 "return 0 end " 

90 "local user_block = bump(KEYS[1], KEYS[2], tonumber(ARGV[1])) " 

91 "local source_block = 0 " 

92 "if tonumber(ARGV[2]) > 0 and user_block == 0 then " 

93 "source_block = bump(KEYS[3], KEYS[4], tonumber(ARGV[2])) end " 

94 "return {user_block, source_block}" 

95) 

96 

97_COUNTERS: Final = InMemoryCache( 

98 max_size_in_memory=LOGIN_THROTTLE_MAX_TRACKED_COUNTERS, default_ttl=DEFAULT_FAILED_LOGIN_WINDOW_SECONDS 

99) 

100_BLOCKS: Final = InMemoryCache( 

101 max_size_in_memory=LOGIN_THROTTLE_MAX_TRACKED_BLOCKS, default_ttl=DEFAULT_FAILED_LOGIN_BLOCK_SECONDS 

102) 

103 

104 

105@cache 

106def _rate_limit_disabled() -> bool: 

107 return get_secret_bool("LITELLM_DISABLE_LOGIN_RATE_LIMIT", default_value=False) is True 

108 

109 

110@cache 

111def warn_login_counters_are_per_worker(num_workers: str) -> None: 

112 verbose_proxy_logger.warning( 

113 "Running %s workers but Redis is not configured. Failed Admin UI sign-in attempts are counted " 

114 "per worker, so the effective limits are %s times the configured values. Configure Redis " 

115 "to share one count across workers.", 

116 num_workers, 

117 num_workers, 

118 ) 

119 

120 

121@cache 

122def warn_source_login_limit_is_off() -> None: 

123 verbose_proxy_logger.warning( 

124 "%s is not set or not a valid list of ranges, so failed Admin UI sign-in attempts are limited per " 

125 "source address and username only. Set it to the address ranges of the proxies in front of LiteLLM, " 

126 "or to an empty list when clients connect directly, to also limit each source address across usernames.", 

127 TRUSTED_PROXY_RANGES_KEY, 

128 ) 

129 

130 

131def declared_proxy_ranges(settings: Mapping[str, object]) -> tuple[str, ...] | None: 

132 """What the operator says fronts LiteLLM: the proxy ranges, an empty tuple for none, None when unsaid. 

133 

134 Only a declared topology makes the source address trustworthy enough to limit across usernames. 

135 An unset key, a value that is not a list of ranges, or a list with an entry that is not an address 

136 or range leaves it unknown and the source scope off. 

137 """ 

138 entries: Final = _configured_range_entries(settings.get(TRUSTED_PROXY_RANGES_KEY)) 

139 if entries is None or any(_parse_network(entry, TRUSTED_PROXY_RANGES_KEY) is None for entry in entries): 139 ↛ 141line 139 didn't jump to line 141 because the condition on line 139 was always true

140 return None 

141 return entries 

142 

143 

144def _configured_range_entries(raw_ranges: object) -> tuple[str, ...] | None: 

145 """Every configured entry, blanks included, so a stray empty string fails validation like any other typo.""" 

146 if raw_ranges is None: 146 ↛ 148line 146 didn't jump to line 148 because the condition on line 146 was always true

147 return None 

148 if isinstance(raw_ranges, str): 

149 return tuple(part.strip() for part in raw_ranges.split(",")) 

150 try: 

151 return tuple(str(entry).strip() for entry in _RANGE_ENTRIES.validate_python(raw_ranges)) 

152 except ValidationError: 

153 verbose_proxy_logger.warning( 

154 "Invalid %s value: expected a list of address ranges, got %s", 

155 TRUSTED_PROXY_RANGES_KEY, 

156 type(raw_ranges).__name__, 

157 ) 

158 return None 

159 

160 

161def _positive_int(raw: object, key: str, default: int) -> int: 

162 if raw is None: 

163 return default 

164 try: 

165 value: Final = int(str(raw)) 

166 except (TypeError, ValueError): 

167 verbose_proxy_logger.warning("Invalid %s value %r; using %s", key, raw, default) 

168 return default 

169 if value < 1: 

170 verbose_proxy_logger.warning("Invalid %s value %s (must be >= 1); using %s", key, value, default) 

171 return default 

172 return value 

173 

174 

175def _int_setting(settings: Mapping[str, object], key: str, default: int) -> int: 

176 return _positive_int(settings.get(key), key, default) 

177 

178 

179def _override_limit(raw: object, default: int) -> int: 

180 """A per-address override: a limit of 1 or more, or ``EXEMPT`` (0) to leave that address unlimited.""" 

181 if str(raw).strip() == str(EXEMPT): 

182 return EXEMPT 

183 return _positive_int(raw, SOURCE_LIMIT_OVERRIDES_KEY, default) 

184 

185 

186def _parse_address(client_ip: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: 

187 """The address as it is limited and counted: an IPv4-mapped IPv6 address is its IPv4 address.""" 

188 try: 

189 address: Final = ipaddress.ip_address(client_ip) 

190 except ValueError: 

191 return None 

192 if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: 

193 return address.ipv4_mapped 

194 return address 

195 

196 

197def _parse_network(raw_range: str, setting_name: str = SOURCE_LIMIT_OVERRIDES_KEY) -> _Network | None: 

198 try: 

199 return ipaddress.ip_network(raw_range.strip(), strict=False) 

200 except ValueError: 

201 verbose_proxy_logger.warning("Invalid address or range %r in %s; skipping", raw_range, setting_name) 

202 return None 

203 

204 

205def _precedence(network: _Network, limit: int) -> tuple[int, bool, int]: 

206 """Sort key for competing overrides: the longest prefix wins, then an exemption, then the higher limit.""" 

207 return (network.prefixlen, limit == EXEMPT, limit) 

208 

209 

210def _source_limit(settings: Mapping[str, object], client_ip: str) -> int: 

211 """Failure allowance for this address: the most specific configured range containing it, else the default. 

212 

213 ``EXEMPT`` (0) means the operator opted this address out of both limits. Between equivalent keys such as 

214 ``1.2.3.4`` and ``1.2.3.4/32`` an exemption wins, then the higher limit. 

215 """ 

216 default: Final = _int_setting(settings, SOURCE_LIMIT_KEY, DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE) 

217 raw_overrides: Final = settings.get(SOURCE_LIMIT_OVERRIDES_KEY) 

218 if raw_overrides is None: 

219 return default 

220 try: 

221 overrides: Final = _SOURCE_LIMIT_OVERRIDES.validate_python(raw_overrides) 

222 except ValidationError: 

223 verbose_proxy_logger.warning( 

224 "Invalid %s value; expected a mapping of address or range to limit", SOURCE_LIMIT_OVERRIDES_KEY 

225 ) 

226 return default 

227 address: Final = _parse_address(client_ip) 

228 if address is None: 

229 return default 

230 matches: Final = sorted( 

231 _precedence(network, _override_limit(raw_limit, default)) 

232 for raw_range, raw_limit in overrides.items() 

233 if (network := _parse_network(raw_range)) is not None and address in network 

234 ) 

235 return matches[-1][-1] if matches else default 

236 

237 

238def user_limit_for(source_limit: int) -> int: 

239 """Failures allowed for one username from one address: half the address allowance, rounded down, at least 1.""" 

240 return max(source_limit // 2, 1) 

241 

242 

243def source_group(client_ip: str) -> str: 

244 """The bucket an address is counted in: IPv4 as is, IPv6 by its /64, so one prefix holder cannot rotate.""" 

245 address: Final = _parse_address(client_ip) 

246 if address is None: 

247 return client_ip 

248 if isinstance(address, ipaddress.IPv6Address): 

249 return str(ipaddress.ip_network((address, IPV6_SOURCE_PREFIX_LENGTH), strict=False)) 

250 return str(address) 

251 

252 

253class _Keys(NamedTuple): 

254 pair_counter: str 

255 pair_block: str 

256 source_counter: str 

257 source_block: str 

258 

259 

260@dataclass(frozen=True, slots=True) 

261class Block: 

262 scope: Scope 

263 retry_after: int 

264 

265 

266@dataclass(frozen=True, slots=True) 

267class LoginThrottle: 

268 """Failed-login limits for one request's source address. 

269 

270 ``source_limit`` is None when the source scope is off: ``trusted_proxy_ranges`` is unset, so the peer 

271 address may be a shared ingress. An empty list means clients connect directly and the peer is the source. 

272 ``user_limit`` is derived from the address allowance either way, see ``user_limit_for``. An address whose 

273 override is ``EXEMPT`` gets a disabled throttle: nothing is counted or blocked for it. 

274 """ 

275 

276 client_ip: str 

277 source_limit: int | None 

278 user_limit: int 

279 window_seconds: int 

280 block_seconds: int 

281 counters: LocalStore 

282 blocks: LocalStore 

283 redis_cache: RedisCache | None = None 

284 enabled: bool = True 

285 

286 @classmethod 

287 def from_request( 

288 cls, 

289 request: Request, 

290 general_settings: Mapping[str, object] | None, 

291 redis_cache: RedisCache | None, 

292 ) -> LoginThrottle: 

293 settings: Final[Mapping[str, object]] = general_settings if general_settings is not None else EMPTY_MAPPING 

294 proxies: Final = declared_proxy_ranges(settings) 

295 resolved, _ = resolve_client_ip( 

296 request, TrustedProxyConfig(use_forwarded_for=bool(proxies), trusted_proxy_cidrs=proxies or ()) 

297 ) 

298 source_limit: Final = _source_limit(settings, resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE) 

299 exempt: Final = source_limit == EXEMPT 

300 return cls( 

301 client_ip=resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE, 

302 source_limit=source_limit if proxies is not None and resolved is not None and not exempt else None, 

303 user_limit=user_limit_for(source_limit), 

304 window_seconds=_int_setting(settings, WINDOW_KEY, DEFAULT_FAILED_LOGIN_WINDOW_SECONDS), 

305 block_seconds=_int_setting(settings, BLOCK_KEY, DEFAULT_FAILED_LOGIN_BLOCK_SECONDS), 

306 counters=_COUNTERS, 

307 blocks=_BLOCKS, 

308 redis_cache=redis_cache, 

309 enabled=not exempt and not _rate_limit_disabled(), 

310 ) 

311 

312 def _keys(self, username: str) -> _Keys: 

313 group: Final = source_group(self.client_ip) 

314 user: Final = hashlib.sha256(username.casefold().encode("utf-8")).hexdigest() 

315 return _Keys( 

316 pair_counter=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:user:{user}", 

317 pair_block=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:block:user:{user}", 

318 source_counter=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:source", 

319 source_block=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:block:source", 

320 ) 

321 

322 async def attempt(self, username: str) -> LoginAttempt: 

323 """Refuses a blocked key before any credential is looked at; otherwise hands back the attempt to settle.""" 

324 if not self.enabled: 

325 return LoginAttempt(throttle=self, username=username) 

326 block: Final = await self._active_block(self._keys(username)) 

327 if block is None: 

328 return LoginAttempt(throttle=self, username=username) 

329 verbose_proxy_logger.warning( 

330 "Admin UI sign-in refused: the %s is blocked for %s more seconds; username=%r source=%s", 

331 block.scope, 

332 block.retry_after, 

333 username, 

334 self.client_ip, 

335 ) 

336 raise self.refused(block.retry_after) 

337 

338 async def _active_block(self, keys: _Keys) -> Block | None: 

339 local: Final = self._local_block_ttls(keys) 

340 shared: Final = await self._shared_block_ttls(keys) 

341 user_ttl: Final = max(local[0], shared[0]) 

342 source_ttl: Final = max(local[1], shared[1]) 

343 if self.source_limit is not None and source_ttl > 0: 

344 return Block(scope="source", retry_after=source_ttl) 

345 if user_ttl > 0: 

346 return Block(scope="user", retry_after=user_ttl) 

347 return None 

348 

349 async def _shared_block_ttls(self, keys: _Keys) -> _BlockTtls: 

350 if self.redis_cache is None: 

351 return LOGIN_THROTTLE_NOT_BLOCKED 

352 try: 

353 return _LUA_BLOCK_TTLS.validate_python( 

354 await self.redis_cache.async_register_script(_BLOCK_TTLS_LUA)(keys, ()) 

355 ) 

356 except _REDIS_FAILURES as err: 

357 self._warn_redis(err) 

358 return LOGIN_THROTTLE_NOT_BLOCKED 

359 

360 def _local_block_ttls(self, keys: _Keys) -> _BlockTtls: 

361 return self._local_block_ttl(keys.pair_block), self._local_block_ttl(keys.source_block) 

362 

363 def _local_block_ttl(self, block_key: str) -> int: 

364 expires_at: Final = _LOCAL_BLOCK_EXPIRY.validate_python(self.blocks.get_cache(block_key)) 

365 if expires_at is None: 

366 return 0 

367 return max(math.ceil(expires_at - time.time()), 0) 

368 

369 async def record_failure(self, username: str) -> _BlockTtls: 

370 keys: Final = self._keys(username) 

371 source_limit: Final = self.source_limit or 0 

372 if self.redis_cache is not None: 

373 try: 

374 return _LUA_BLOCK_TTLS.validate_python( 

375 await self.redis_cache.async_register_script(_RECORD_FAILURE_LUA)( 

376 keys, (self.user_limit, source_limit, self.window_seconds, self.block_seconds) 

377 ) 

378 ) 

379 except _REDIS_FAILURES as err: 

380 self._warn_redis(err) 

381 user_block: Final = self._local_bump(keys.pair_counter, keys.pair_block, self.user_limit) 

382 if source_limit == 0 or user_block > 0: 

383 return user_block, 0 

384 return user_block, self._local_bump(keys.source_counter, keys.source_block, source_limit) 

385 

386 def _local_bump(self, count_key: str, block_key: str, limit: int) -> int: 

387 blocked: Final = self._local_block_ttl(block_key) 

388 if blocked > 0: 

389 return blocked 

390 count: Final = int(self.counters.increment_cache(count_key, 1, ttl=self.window_seconds)) 

391 if count <= limit: 

392 return 0 

393 self.blocks.set_cache(block_key, time.time() + self.block_seconds, ttl=self.block_seconds) 

394 return self.block_seconds 

395 

396 async def clear_pair(self, username: str) -> None: 

397 pair_counter: Final = self._keys(username).pair_counter 

398 if self.redis_cache is not None: 

399 try: 

400 await self.redis_cache.async_delete_cache(pair_counter) 

401 except _REDIS_FAILURES as err: 

402 self._warn_redis(err) 

403 self.counters.delete_cache(pair_counter) 

404 

405 def _warn_redis(self, err: Exception) -> None: 

406 verbose_proxy_logger.warning( 

407 "Redis failed while counting Admin UI sign-in attempts; using this worker's own counters " 

408 "until it recovers: %s", 

409 err, 

410 ) 

411 

412 @staticmethod 

413 def refused(retry_after: int) -> ProxyException: 

414 return ProxyException( 

415 message="Too many failed sign-in attempts. Try again later.", 

416 type=ProxyErrorTypes.auth_error, 

417 param="username", 

418 code=status.HTTP_429_TOO_MANY_REQUESTS, 

419 headers={"Retry-After": str(retry_after)}, # mutable-ok: ProxyException writes into its headers dict 

420 ) 

421 

422 

423@dataclass(frozen=True, slots=True) 

424class LoginAttempt: 

425 throttle: LoginThrottle 

426 username: str 

427 

428 async def succeeded(self) -> None: 

429 if not self.throttle.enabled: 

430 return 

431 await self.throttle.clear_pair(self.username) 

432 

433 async def failed(self) -> None: 

434 if not self.throttle.enabled: 

435 return 

436 user_block, source_block = await self.throttle.record_failure(self.username) 

437 if user_block == 0 and source_block == 0: 

438 return 

439 verbose_proxy_logger.warning( 

440 "Admin UI sign-in blocked for %s seconds after too many failures; scope=%s username=%r source=%s", 

441 user_block or source_block, 

442 "user" if user_block else "source", 

443 self.username, 

444 self.throttle.client_ip, 

445 )