mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge 37386e988e into f2e0a5db1e
This commit is contained in:
commit
1a5234acf5
11 changed files with 792 additions and 378 deletions
|
|
@ -2036,3 +2036,6 @@ BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY: Final = "batch_enqueued_token_limit"
|
|||
# Shared read-only empty mapping, for defaulting optional Mapping parameters without
|
||||
# constructing a fresh mutable dict at each call site.
|
||||
EMPTY_MAPPING: Final = MappingProxyType({})
|
||||
|
||||
# API endpoint for breached password k-anonymity search
|
||||
HIBP_RANGE_API_BASE: Final = "https://api.pwnedpasswords.com/range"
|
||||
|
|
|
|||
|
|
@ -1798,6 +1798,17 @@ class NewUserRequest(GenerateRequestBase):
|
|||
send_invite_email: bool | None = None
|
||||
sso_user_id: str | None = None
|
||||
organizations: list[str] | None = None
|
||||
password: str | None = None
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_not_supported(cls, value: str | None) -> str | None:
|
||||
if value is not None:
|
||||
raise ValueError(
|
||||
"password cannot be set via /user/new. Users set their own password through an "
|
||||
"invitation link (POST /invitation/new)."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class NewUserResponse(GenerateKeyResponse):
|
||||
|
|
|
|||
|
|
@ -4,13 +4,28 @@ Applied at every path that persists a new or changed password for a DB-backed
|
|||
user (``/user/update``, ``/user/bulk_update``, and the invitation onboarding
|
||||
claim flow), so the strength bar is configured in one place instead of
|
||||
per-endpoint.
|
||||
|
||||
Also screens new passwords against known data breaches via the
|
||||
haveibeenpwned.com (HIBP) k-anonymity range API: only the first 5 characters
|
||||
of the password's SHA-1 hash ever leave the proxy, and the check fails open
|
||||
(allows the password) when HIBP is unreachable.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import asyncio
|
||||
import hashlib
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._version import version
|
||||
from litellm.constants import HIBP_RANGE_API_BASE
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
HIBP_TIMEOUT_SECONDS: Final = 5.0
|
||||
|
||||
DEFAULT_MIN_LENGTH: Final = 12
|
||||
MIN_ALLOWED_LENGTH: Final = 8
|
||||
|
|
@ -90,3 +105,114 @@ def validate_password_policy(password: str, general_settings: Mapping[str, objec
|
|||
param="password",
|
||||
code=400,
|
||||
)
|
||||
|
||||
|
||||
def _hibp_client() -> AsyncHTTPHandler:
|
||||
return get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.PasswordBreachCheck,
|
||||
params={"timeout": HIBP_TIMEOUT_SECONDS}, # mutable-ok: callee takes a bare dict (PEP 589)
|
||||
)
|
||||
|
||||
|
||||
def _is_suffix_in_range_response(response_body: str, hash_suffix: str) -> bool:
|
||||
for line in response_body.upper().splitlines():
|
||||
entry_suffix, _, count = line.strip().partition(":")
|
||||
if entry_suffix == hash_suffix:
|
||||
return int(count.strip() or "0") > 0
|
||||
return False
|
||||
|
||||
|
||||
async def _is_password_breached(password: str, client: AsyncHTTPHandler) -> bool:
|
||||
# usedforsecurity=False: SHA-1 is only a lookup key into the HIBP dataset, so no security property rests on it
|
||||
sha1_hex: Final = hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
|
||||
headers: Final = { # mutable-ok: callee takes a bare dict (PEP 589)
|
||||
"Add-Padding": "true",
|
||||
"User-Agent": f"litellm-proxy/{version}",
|
||||
}
|
||||
try:
|
||||
response: Final = await client.get(
|
||||
f"{HIBP_RANGE_API_BASE}/{sha1_hex[:5]}",
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
breached: Final = _is_suffix_in_range_response(response.text, sha1_hex[5:])
|
||||
except Exception as e: # noqa: BLE001 # fail-open: any HIBP failure skips the check, never breaks the caller
|
||||
verbose_proxy_logger.warning("Breached-password check skipped, HIBP lookup failed: %s", e)
|
||||
return False
|
||||
return breached
|
||||
|
||||
|
||||
def is_breach_check_enabled(general_settings: Mapping[str, object]) -> bool:
|
||||
return general_settings.get("password_policy_check_breached_passwords", True) is not False
|
||||
|
||||
|
||||
async def is_password_breached(
|
||||
password: str,
|
||||
general_settings: Mapping[str, object],
|
||||
client: AsyncHTTPHandler | None = None,
|
||||
) -> bool:
|
||||
"""False when the check is disabled, the password is absent from the HIBP
|
||||
corpus, or HIBP is unreachable (fail open)."""
|
||||
if not is_breach_check_enabled(general_settings):
|
||||
return False
|
||||
return await _is_password_breached(password, client if client is not None else _hibp_client())
|
||||
|
||||
|
||||
def breached_password_error() -> ProxyException:
|
||||
return ProxyException(
|
||||
message=(
|
||||
"This password appears in known data breaches and cannot be used. Please choose a different password."
|
||||
),
|
||||
type=ProxyErrorTypes.validation_error,
|
||||
param="password",
|
||||
code=400,
|
||||
)
|
||||
|
||||
|
||||
async def validate_password_not_breached(
|
||||
password: str,
|
||||
general_settings: Mapping[str, object],
|
||||
client: AsyncHTTPHandler | None = None,
|
||||
) -> None:
|
||||
"""Raise ``ProxyException`` (400) if ``password`` appears in a known data breach.
|
||||
|
||||
Fails open: an unreachable or misbehaving HIBP allows the password."""
|
||||
if not await is_password_breached(password, general_settings, client):
|
||||
return
|
||||
raise breached_password_error()
|
||||
|
||||
|
||||
def _strength_verdict(password: str, general_settings: Mapping[str, object]) -> ProxyException | None:
|
||||
try:
|
||||
validate_password_policy(password, general_settings)
|
||||
except ProxyException as e:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
async def validate_passwords_bulk(
|
||||
passwords: Sequence[str],
|
||||
general_settings: Mapping[str, object],
|
||||
client: AsyncHTTPHandler | None = None,
|
||||
) -> Mapping[str, ProxyException | None]:
|
||||
"""Per-unique-password policy verdicts for a batch: the ProxyException to
|
||||
surface, or None when the password is acceptable.
|
||||
|
||||
Deduplicates first, then issues every needed HIBP lookup concurrently, so a
|
||||
batch caller pays one HIBP timeout window in the worst case instead of one
|
||||
per password (each lookup still fails open independently)."""
|
||||
unique_passwords: Final = tuple(dict.fromkeys(passwords))
|
||||
strength_verdicts: Final[Mapping[str, ProxyException | None]] = MappingProxyType(
|
||||
{password: _strength_verdict(password, general_settings) for password in unique_passwords}
|
||||
)
|
||||
to_screen: Final = tuple(password for password in unique_passwords if strength_verdicts[password] is None)
|
||||
breached_flags: Final = await asyncio.gather(
|
||||
*(is_password_breached(password, general_settings, client) for password in to_screen)
|
||||
)
|
||||
breached_passwords: Final = frozenset(password for password, breached in zip(to_screen, breached_flags) if breached)
|
||||
return MappingProxyType(
|
||||
{
|
||||
password: breached_password_error() if password in breached_passwords else strength_verdicts[password]
|
||||
for password in unique_passwords
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,9 +27,14 @@ from pydantic import TypeAdapter, ValidationError
|
|||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_checks import get_team_object, get_user_object
|
||||
from litellm.proxy.auth.password_policy import validate_password_policy
|
||||
from litellm.proxy.auth.password_policy import (
|
||||
validate_password_not_breached,
|
||||
validate_password_policy,
|
||||
validate_passwords_bulk,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
|
|
@ -162,10 +167,17 @@ def _team_membership_table(
|
|||
return team_membership_table
|
||||
|
||||
|
||||
def _hash_password_in_dict(data: dict, general_settings: Mapping[str, object]) -> None:
|
||||
"""Validate and hash password field in-place if present."""
|
||||
async def _hash_password_in_dict(
|
||||
data: dict, general_settings: Mapping[str, object], password_prevalidated: bool = False
|
||||
) -> None:
|
||||
"""Validate and hash password field in-place if present.
|
||||
|
||||
``password_prevalidated`` skips the policy checks for callers that already
|
||||
validated the password (the bulk path screens its whole batch upfront)."""
|
||||
if "password" in data and data["password"] is not None:
|
||||
validate_password_policy(data["password"], general_settings)
|
||||
if not password_prevalidated:
|
||||
validate_password_policy(data["password"], general_settings)
|
||||
await validate_password_not_breached(data["password"], general_settings)
|
||||
data["password"] = hash_password(data["password"])
|
||||
|
||||
|
||||
|
|
@ -495,6 +507,7 @@ async def new_user(
|
|||
- prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
|
||||
- organizations: List[str] - List of organization id's the user is a member of
|
||||
- budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
|
||||
- password: Optional[str] - Not supported; any value is rejected with a 422. Users set their own password through an invitation link (POST /invitation/new).
|
||||
Returns:
|
||||
- key: (str) The generated api key for the user
|
||||
- expires: (datetime) Datetime object for when key expires.
|
||||
|
|
@ -514,7 +527,7 @@ async def new_user(
|
|||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import _license_check, general_settings, prisma_client
|
||||
from litellm.proxy.proxy_server import _license_check, prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
|
|
@ -562,7 +575,7 @@ async def new_user(
|
|||
# generate_key_helper_fn only forwards object_permission_id, so without this the entitlement
|
||||
# the caller sent would be dropped on the floor.
|
||||
data_json = await _set_object_permission(data_json=data_json, prisma_client=prisma_client)
|
||||
_hash_password_in_dict(data_json, general_settings)
|
||||
data_json.pop("password", None)
|
||||
teams = data.teams
|
||||
if teams is None:
|
||||
teams = check_if_default_team_set()
|
||||
|
|
@ -1426,6 +1439,7 @@ async def _update_single_user_helper(
|
|||
user_request: UpdateUserRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: str | None = None,
|
||||
password_prevalidated: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Helper function to update a single user.
|
||||
|
|
@ -1448,7 +1462,7 @@ async def _update_single_user_helper(
|
|||
|
||||
data_json: Final[dict] = user_request.model_dump(exclude_unset=True)
|
||||
non_default_values = _update_internal_user_params(data_json=data_json, data=user_request)
|
||||
_hash_password_in_dict(non_default_values, general_settings)
|
||||
await _hash_password_in_dict(non_default_values, general_settings, password_prevalidated=password_prevalidated)
|
||||
|
||||
existing_user_row: BaseModel | None = None
|
||||
if user_request.user_id:
|
||||
|
|
@ -1698,19 +1712,38 @@ async def bulk_update_processed_users(
|
|||
users_to_update: list[UpdateUserRequest],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: str | None = None,
|
||||
hibp_client: AsyncHTTPHandler | None = None,
|
||||
) -> BulkUpdateUserResponse:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
results: Final[list[UserUpdateResult]] = []
|
||||
successful_updates = 0
|
||||
failed_updates = 0
|
||||
|
||||
# Screen the batch's passwords upfront and concurrently: done per-user
|
||||
# inside the loop below, each HIBP lookup would be awaited serially and a
|
||||
# degraded-slow HIBP could stretch a full batch to minutes, timing out the
|
||||
# request after some updates already persisted.
|
||||
password_verdicts: Final = await validate_passwords_bulk(
|
||||
tuple(u.password for u in users_to_update if u.password is not None),
|
||||
general_settings,
|
||||
client=hibp_client,
|
||||
)
|
||||
|
||||
# Process each user update independently
|
||||
try:
|
||||
for user_request in users_to_update:
|
||||
try:
|
||||
if (
|
||||
user_request.password is not None
|
||||
and (password_error := password_verdicts.get(user_request.password)) is not None
|
||||
):
|
||||
raise password_error
|
||||
response = await _update_single_user_helper(
|
||||
user_request=user_request,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
password_prevalidated=True,
|
||||
)
|
||||
# Record success
|
||||
results.append(
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ from litellm.proxy.auth.model_checks import (
|
|||
get_mcp_server_ids,
|
||||
get_team_models,
|
||||
)
|
||||
from litellm.proxy.auth.password_policy import validate_password_policy
|
||||
from litellm.proxy.auth.password_policy import validate_password_not_breached, validate_password_policy
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
_fetch_global_spend_with_event_coordination,
|
||||
user_api_key_auth,
|
||||
|
|
@ -16313,6 +16313,7 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
|
|||
)
|
||||
|
||||
validate_password_policy(data.password, general_settings)
|
||||
await validate_password_not_breached(data.password, general_settings)
|
||||
hashed_pw: Final = hash_password(data.password)
|
||||
current_time = litellm.utils.get_utc_datetime()
|
||||
async with prisma_client.db.tx() as tx:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class httpxSpecialProvider(str, Enum):
|
|||
UI = "ui"
|
||||
Sandbox = "sandbox"
|
||||
ModelCostMap = "model_cost_map"
|
||||
PasswordBreachCheck = "password_breach_check"
|
||||
|
||||
|
||||
VerifyTypes = str | bool | ssl.SSLContext
|
||||
|
|
|
|||
|
|
@ -8,15 +8,20 @@ Covers the security behavior of:
|
|||
session key only after the password is written
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi import HTTPException
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import InvitationClaim
|
||||
from litellm.proxy._types import InvitationClaim, ProxyException
|
||||
|
||||
_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
|
@ -386,7 +391,9 @@ async def test_claim_token_rejects_concurrent_reuse_before_password_write():
|
|||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
patch( # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.generate_key_helper_fn",
|
||||
new_callable=AsyncMock,
|
||||
|
|
@ -426,7 +433,9 @@ async def test_claim_token_sets_accepted_at_after_password_written():
|
|||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
patch( # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.premium_user", False),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.generate_key_helper_fn",
|
||||
|
|
@ -483,7 +492,9 @@ async def test_claim_token_rolls_back_invite_when_session_key_mint_fails():
|
|||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
patch( # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.generate_key_helper_fn",
|
||||
new_callable=AsyncMock,
|
||||
|
|
@ -505,3 +516,124 @@ async def test_claim_token_rolls_back_invite_when_session_key_mint_fails():
|
|||
}
|
||||
assert rollback_kwargs["data"]["accepted_at"] is None
|
||||
assert rollback_kwargs["data"]["is_accepted"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /onboarding/claim_token - password policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hibp_url_for(password: str) -> str:
|
||||
sha1 = hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
|
||||
return f"https://api.pwnedpasswords.com/range/{sha1[:5]}"
|
||||
|
||||
|
||||
def _hibp_suffix_for(password: str) -> str:
|
||||
return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()[5:]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_token_rejects_short_password_before_consuming_invite():
|
||||
"""Default policy requires 12 characters; the invite must stay claimable."""
|
||||
from litellm.proxy.proxy_server import claim_onboarding_link
|
||||
|
||||
invite = _make_invite(is_accepted=False)
|
||||
prisma = _make_prisma(invite, _make_user())
|
||||
request = _make_claim_request(_make_onboarding_token())
|
||||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="Sh0rt!pw",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test"), # test-quality-ok: same as above
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: same as above
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await claim_onboarding_link(data=data, request=request)
|
||||
|
||||
assert exc_info.value.code == "400"
|
||||
assert "at least 12 characters" in exc_info.value.message
|
||||
prisma.db.litellm_invitationlink.update_many.assert_not_called()
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_claim_token_rejects_breached_password_before_consuming_invite():
|
||||
"""A password found in the HIBP corpus must be rejected and never stored."""
|
||||
from litellm.proxy.proxy_server import claim_onboarding_link
|
||||
|
||||
password = "P@ssword123456"
|
||||
respx.get(_hibp_url_for(password)).mock(
|
||||
return_value=httpx.Response(200, text=f"{_hibp_suffix_for(password)}:1387")
|
||||
)
|
||||
|
||||
invite = _make_invite(is_accepted=False)
|
||||
prisma = _make_prisma(invite, _make_user())
|
||||
request = _make_claim_request(_make_onboarding_token())
|
||||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password=password,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test"), # test-quality-ok: same as above
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: same as above
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await claim_onboarding_link(data=data, request=request)
|
||||
|
||||
assert exc_info.value.code == "400"
|
||||
assert "data breaches" in exc_info.value.message
|
||||
prisma.db.litellm_invitationlink.update_many.assert_not_called()
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_claim_token_fails_open_when_hibp_unreachable():
|
||||
"""An HIBP outage must never block onboarding: the claim proceeds."""
|
||||
from litellm.proxy.proxy_server import claim_onboarding_link
|
||||
|
||||
password = "NewP@ssw0rd-2026"
|
||||
respx.get(_hibp_url_for(password)).mock(side_effect=httpx.ConnectError("no route to host"))
|
||||
|
||||
invite = _make_invite(is_accepted=False)
|
||||
user = _make_user()
|
||||
prisma = _make_prisma(invite, user)
|
||||
request = _make_claim_request(_make_onboarding_token())
|
||||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password=password,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test"), # test-quality-ok: same as above
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: same as above
|
||||
patch("litellm.proxy.proxy_server.premium_user", False), # test-quality-ok: same as above
|
||||
patch( # test-quality-ok: same as above
|
||||
"litellm.proxy.proxy_server.generate_key_helper_fn",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"token": "sk-generated-key", "user_id": "user-123"},
|
||||
),
|
||||
patch( # test-quality-ok: same as above
|
||||
"litellm.proxy.proxy_server.get_custom_url",
|
||||
return_value="http://localhost:4000/",
|
||||
),
|
||||
patch( # test-quality-ok: same as above
|
||||
"litellm.proxy.proxy_server.get_disabled_non_admin_personal_key_creation",
|
||||
return_value=False,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.get_server_root_path", return_value=""), # test-quality-ok: same as above
|
||||
):
|
||||
result = await claim_onboarding_link(data=data, request=request)
|
||||
|
||||
assert "token" in result
|
||||
prisma.db.litellm_usertable.update.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -2,22 +2,56 @@
|
|||
Tests for the configurable password-strength policy in
|
||||
`litellm.proxy.auth.password_policy`, enforced on every path that persists a
|
||||
new or changed password for a locally-managed user.
|
||||
|
||||
The breach-check (HIBP) tests inject a real AsyncHTTPHandler wrapping an
|
||||
httpx.MockTransport, so no network is touched and nothing is monkeypatched.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
from litellm.proxy.auth.password_policy import (
|
||||
DEFAULT_MIN_LENGTH,
|
||||
MIN_ALLOWED_LENGTH,
|
||||
PasswordPolicy,
|
||||
get_password_policy,
|
||||
validate_password_not_breached,
|
||||
validate_password_policy,
|
||||
validate_passwords_bulk,
|
||||
)
|
||||
|
||||
STRONG_PASSWORD = "Str0ng!Passw0rd"
|
||||
|
||||
|
||||
def _sha1_upper(password: str) -> str:
|
||||
return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
|
||||
|
||||
|
||||
def _client_with_transport(handler) -> AsyncHTTPHandler:
|
||||
http_handler = AsyncHTTPHandler()
|
||||
http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
return http_handler
|
||||
|
||||
|
||||
def _client_never_called() -> AsyncHTTPHandler:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError(f"unexpected HTTP call to {request.url}")
|
||||
|
||||
return _client_with_transport(handler)
|
||||
|
||||
|
||||
def _client_returning(body: str, status_code: int = 200) -> AsyncHTTPHandler:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code, text=body)
|
||||
|
||||
return _client_with_transport(handler)
|
||||
|
||||
|
||||
def test_get_password_policy_defaults_to_pif_baseline():
|
||||
policy = get_password_policy({})
|
||||
assert policy == PasswordPolicy(
|
||||
|
|
@ -134,3 +168,178 @@ def test_validate_password_policy_rejects_unicode_letter_as_special_character():
|
|||
def test_validate_password_policy_accepts_real_special_character_with_unicode_letters():
|
||||
"""Same base password as the rejection test above, plus an actual symbol."""
|
||||
assert validate_password_policy("Passwörd1234!", {}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breach_check_skipped_when_disabled():
|
||||
result = await validate_password_not_breached(
|
||||
password="password12345", # breached in reality, but the check is off
|
||||
general_settings={"password_policy_check_breached_passwords": False},
|
||||
client=_client_never_called(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_breached_password():
|
||||
password = "correct horse battery staple"
|
||||
sha1 = _sha1_upper(password)
|
||||
body = f"AAAA000000000000000000000000000000A:0\r\n{sha1[5:]}:42\r\nBBBB000000000000000000000000000000B:7"
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await validate_password_not_breached(password=password, general_settings={}, client=_client_returning(body))
|
||||
assert exc_info.value.code == "400"
|
||||
assert exc_info.value.type == ProxyErrorTypes.validation_error
|
||||
assert exc_info.value.param == "password"
|
||||
assert "data breaches" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_sha1_prefix_leaves_the_proxy():
|
||||
password = "a very secret password"
|
||||
sha1 = _sha1_upper(password)
|
||||
captured_requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured_requests.append(request)
|
||||
return httpx.Response(200, text="0000000000000000000000000000000000A:1")
|
||||
|
||||
result = await validate_password_not_breached(
|
||||
password=password, general_settings={}, client=_client_with_transport(handler)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
(request,) = captured_requests
|
||||
assert request.url.path == f"/range/{sha1[:5]}"
|
||||
assert sha1[5:] not in str(request.url)
|
||||
assert request.headers["Add-Padding"] == "true"
|
||||
assert "litellm" in request.headers["User-Agent"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignores_padding_entries_with_zero_count():
|
||||
"""HIBP padding entries (requested via Add-Padding) carry count 0 and must
|
||||
not be treated as breaches when they collide with the password's suffix."""
|
||||
password = "a padded-away password"
|
||||
sha1 = _sha1_upper(password)
|
||||
|
||||
result = await validate_password_not_breached(
|
||||
password=password, general_settings={}, client=_client_returning(f"{sha1[5:]}:0")
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepts_password_absent_from_breach_corpus():
|
||||
result = await validate_password_not_breached(
|
||||
password="a genuinely novel password",
|
||||
general_settings={},
|
||||
client=_client_returning("0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breach_check_fails_open_on_network_error():
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("no route to host")
|
||||
|
||||
result = await validate_password_not_breached(
|
||||
password="password12345", # breached, but HIBP is unreachable
|
||||
general_settings={},
|
||||
client=_client_with_transport(handler),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breach_check_fails_open_on_http_error_status():
|
||||
result = await validate_password_not_breached(
|
||||
password="password12345",
|
||||
general_settings={},
|
||||
client=_client_returning("service unavailable", status_code=503),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breach_check_fails_open_on_malformed_response_body():
|
||||
result = await validate_password_not_breached(
|
||||
password="password12345",
|
||||
general_settings={},
|
||||
client=_client_returning(f"{_sha1_upper('password12345')[5:]}:not-a-number"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_passwords_bulk_screens_concurrently():
|
||||
"""All HIBP lookups for a batch must be in flight at once: each handler
|
||||
call stalls until every expected request has arrived, and a handler that
|
||||
gives up waiting reports the password as breached. Serial awaiting (the
|
||||
old per-user behavior) leaves each earlier request waiting forever for the
|
||||
later ones, so every verdict comes back as a breach and the test fails."""
|
||||
passwords = ("Uniqu3!Passw0rd-a", "Uniqu3!Passw0rd-b", "Uniqu3!Passw0rd-c")
|
||||
suffix_by_prefix = {_sha1_upper(p)[:5]: _sha1_upper(p)[5:] for p in passwords}
|
||||
all_arrived = asyncio.Event()
|
||||
arrivals: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
arrivals.append(request.url.path)
|
||||
if len(arrivals) == len(passwords):
|
||||
all_arrived.set()
|
||||
try:
|
||||
await asyncio.wait_for(all_arrived.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
return httpx.Response(200, text=f"{suffix_by_prefix[request.url.path.rsplit('/', 1)[-1]]}:1")
|
||||
return httpx.Response(200, text="0000000000000000000000000000000000A:1")
|
||||
|
||||
verdicts = await validate_passwords_bulk(passwords, {}, client=_client_with_transport(handler))
|
||||
assert set(arrivals) == {f"/range/{prefix}" for prefix in suffix_by_prefix}
|
||||
assert all(verdicts[p] is None for p in passwords)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_passwords_bulk_deduplicates_lookups():
|
||||
"""500 users sharing one password must cost exactly one HIBP lookup."""
|
||||
password = "Sh@red-Passw0rd!"
|
||||
request_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
return httpx.Response(200, text="0000000000000000000000000000000000A:1")
|
||||
|
||||
verdicts = await validate_passwords_bulk((password,) * 500, {}, client=_client_with_transport(handler))
|
||||
assert request_count == 1
|
||||
assert verdicts == {password: None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_passwords_bulk_mixed_verdicts():
|
||||
"""Weak passwords are rejected without an HIBP lookup; breached ones get
|
||||
the breach error; acceptable ones map to None."""
|
||||
breached = "Br3ached!Passw0rd"
|
||||
clean = "Cl3an!!Passw0rd42"
|
||||
weak = "short1!"
|
||||
breached_sha1 = _sha1_upper(breached)
|
||||
looked_up_prefixes: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
looked_up_prefixes.append(request.url.path.rsplit("/", 1)[-1])
|
||||
if request.url.path == f"/range/{breached_sha1[:5]}":
|
||||
return httpx.Response(200, text=f"{breached_sha1[5:]}:99")
|
||||
return httpx.Response(200, text="0000000000000000000000000000000000A:1")
|
||||
|
||||
verdicts = await validate_passwords_bulk((breached, clean, weak), {}, client=_client_with_transport(handler))
|
||||
assert _sha1_upper(weak)[:5] not in looked_up_prefixes
|
||||
assert verdicts[clean] is None
|
||||
assert "data breaches" in verdicts[breached].message
|
||||
assert verdicts[breached].code == "400"
|
||||
assert "12 characters" in verdicts[weak].message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_passwords_bulk_empty_batch_makes_no_lookups():
|
||||
verdicts = await validate_passwords_bulk((), {}, client=_client_never_called())
|
||||
assert verdicts == {}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ from litellm.proxy._types import (
|
|||
LiteLLM_AuditLogs,
|
||||
LiteLLM_TeamMembership,
|
||||
LitellmUserRoles,
|
||||
NewUserRequest,
|
||||
OrganizationMemberUpdateRequest,
|
||||
ResetSpendRequest,
|
||||
UpdateKeyRequest,
|
||||
|
|
@ -277,3 +278,18 @@ def test_team_membership_budget_table_present_still_works():
|
|||
}
|
||||
result = LiteLLM_TeamMembership.model_validate(data)
|
||||
assert result.litellm_budget_table is None
|
||||
|
||||
|
||||
def test_new_user_request_loudly_rejects_a_password():
|
||||
"""
|
||||
/user/new has never persisted a password (the field used to be silently
|
||||
dropped). Sending one must now fail visibly so the dead path cannot be
|
||||
revived without going through the password policy.
|
||||
"""
|
||||
with pytest.raises(ValidationError, match="invitation link"):
|
||||
NewUserRequest(user_email="alice@example.com", password="hunter2hunter2")
|
||||
|
||||
|
||||
def test_new_user_request_without_password_still_works():
|
||||
request = NewUserRequest(user_email="alice@example.com")
|
||||
assert request.password is None
|
||||
|
|
|
|||
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -16757,6 +16757,7 @@ export interface paths {
|
|||
* - prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
|
||||
* - organizations: List[str] - List of organization id's the user is a member of
|
||||
* - budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
|
||||
* - password: Optional[str] - Not supported; any value is rejected with a 422. Users set their own password through an invitation link (POST /invitation/new).
|
||||
* Returns:
|
||||
* - key: (str) The generated api key for the user
|
||||
* - expires: (datetime) Datetime object for when key expires.
|
||||
|
|
@ -32768,6 +32769,8 @@ export interface components {
|
|||
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
|
||||
/** Organizations */
|
||||
organizations?: string[] | null;
|
||||
/** Password */
|
||||
password?: string | null;
|
||||
/**
|
||||
* Permissions
|
||||
* @default {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue