mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
* test(proxy): move auth, hooks, policy_engine and client tests into tests/unit/proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): stub HIBP through respx by disabling the aiohttp transport Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): share the httpx transport fixture across proxy unit tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): restore proxy globals without a missing-value sentinel Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): package moved dirs and stub the login breach check at the HTTP boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): isolate the mcp server manager per test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""
|
|
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(
|
|
min_length=DEFAULT_MIN_LENGTH,
|
|
require_uppercase=True,
|
|
require_lowercase=True,
|
|
require_numbers=True,
|
|
require_special_characters=True,
|
|
)
|
|
|
|
|
|
def test_get_password_policy_reads_overrides_from_general_settings():
|
|
policy = get_password_policy(
|
|
{
|
|
"password_policy_min_length": 20,
|
|
"password_policy_require_uppercase": False,
|
|
"password_policy_require_lowercase": False,
|
|
"password_policy_require_numbers": False,
|
|
"password_policy_require_special_characters": False,
|
|
}
|
|
)
|
|
assert policy == PasswordPolicy(
|
|
min_length=20,
|
|
require_uppercase=False,
|
|
require_lowercase=False,
|
|
require_numbers=False,
|
|
require_special_characters=False,
|
|
)
|
|
|
|
|
|
def test_validate_password_policy_accepts_strong_password():
|
|
assert validate_password_policy(STRONG_PASSWORD, {}) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"password,expected_fragment",
|
|
[
|
|
("Sh0rt!Pw", "12 characters"),
|
|
("weakpassword123!", "uppercase"),
|
|
("WEAKPASSWORD123!", "lowercase"),
|
|
("WeakPassword!!!!", "number"),
|
|
("WeakPassword12345", "special character"),
|
|
],
|
|
)
|
|
def test_validate_password_policy_rejects_each_missing_class(password, expected_fragment):
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
validate_password_policy(password, {})
|
|
assert exc_info.value.code == "400"
|
|
assert exc_info.value.type == ProxyErrorTypes.validation_error
|
|
assert exc_info.value.param == "password"
|
|
assert expected_fragment in exc_info.value.message
|
|
|
|
|
|
def test_validate_password_policy_reports_every_violation_at_once():
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
validate_password_policy("weak", {})
|
|
assert "12 characters" in exc_info.value.message
|
|
assert "uppercase" in exc_info.value.message
|
|
assert "number" in exc_info.value.message
|
|
assert "special character" in exc_info.value.message
|
|
|
|
|
|
def test_validate_password_policy_honors_relaxed_config():
|
|
general_settings = {
|
|
"password_policy_min_length": MIN_ALLOWED_LENGTH,
|
|
"password_policy_require_special_characters": False,
|
|
}
|
|
# 8 chars, has upper/lower/number, no special char: fails default policy,
|
|
# passes the relaxed one above.
|
|
validate_password_policy("Abcd1234", general_settings)
|
|
with pytest.raises(ProxyException):
|
|
validate_password_policy("Abcd1234", {})
|
|
|
|
|
|
def test_validate_password_policy_honors_stricter_min_length():
|
|
general_settings = {"password_policy_min_length": 20}
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
validate_password_policy(STRONG_PASSWORD, general_settings)
|
|
assert "20 characters" in exc_info.value.message
|
|
|
|
|
|
@pytest.mark.parametrize("configured_min_length", [0, -1, -100, 1, 7])
|
|
def test_get_password_policy_floors_nonpositive_or_too_low_min_length(configured_min_length):
|
|
"""A misconfigured min_length must never disable the length check
|
|
entirely: it floors at MIN_ALLOWED_LENGTH instead of passing through."""
|
|
policy = get_password_policy({"password_policy_min_length": configured_min_length})
|
|
assert policy.min_length == MIN_ALLOWED_LENGTH
|
|
|
|
|
|
def test_validate_password_policy_rejects_short_password_even_with_zero_min_length_configured():
|
|
general_settings = {"password_policy_min_length": 0}
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
validate_password_policy("a", general_settings)
|
|
assert f"{MIN_ALLOWED_LENGTH} characters" in exc_info.value.message
|
|
|
|
|
|
def test_get_password_policy_ignores_boolean_min_length():
|
|
"""`bool` is a subclass of `int` in Python; a stray `true`/`false` value
|
|
must not silently coerce into a min_length of 1 or 0."""
|
|
policy = get_password_policy({"password_policy_min_length": False})
|
|
assert policy.min_length == DEFAULT_MIN_LENGTH
|
|
|
|
|
|
def test_validate_password_policy_rejects_unicode_letter_as_special_character():
|
|
"""Regression: an ASCII-only `[^A-Za-z0-9]` check would miscount an
|
|
accented letter as the required special character, so a letters-and-
|
|
digits-only password like this one (no real symbol) must still be
|
|
rejected."""
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
validate_password_policy("Passwörd1234", {})
|
|
assert "special character" in exc_info.value.message
|
|
|
|
|
|
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 == {}
|