mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-07 09:12:20 +00:00
212 lines
6.9 KiB
Python
212 lines
6.9 KiB
Python
"""
|
|
Regression tests for the OAuth2-proxy header-forgery fix
|
|
(GHSA-5c3m-qffq-4r9m).
|
|
|
|
The hook reads HTTP request headers per ``oauth2_config_mappings`` and
|
|
constructs a ``UserAPIKeyAuth`` from them. The fix has two parts:
|
|
|
|
1. Only requests from configured trusted proxy CIDR ranges may provide
|
|
identity headers.
|
|
2. Only identity fields may be mapped from those headers. Without the
|
|
identity-only allowlist any field could be mapped — including
|
|
``user_role``, which Pydantic coerces from the string
|
|
``"proxy_admin"`` into ``LitellmUserRoles.PROXY_ADMIN``.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
from fastapi import Request
|
|
from starlette.datastructures import Headers
|
|
|
|
sys.path.insert(0, os.path.abspath("../../../.."))
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
from litellm.proxy.auth.oauth2_proxy_hook import (
|
|
ALLOWED_OAUTH2_PROXY_FIELDS,
|
|
handle_oauth2_proxy_request,
|
|
)
|
|
|
|
|
|
def _request_with_headers(headers: dict, *, client_host: str = "127.0.0.1") -> Request:
|
|
scope = {
|
|
"type": "http",
|
|
"client": (client_host, 12345),
|
|
"headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
|
|
}
|
|
request = Request(scope=scope)
|
|
request._headers = Headers(headers)
|
|
return request
|
|
|
|
|
|
@pytest.fixture
|
|
def configure_proxy(monkeypatch):
|
|
"""
|
|
Yields a callable that sets ``oauth2_config_mappings`` and
|
|
``trusted_proxy_ranges`` on the proxy_server module for the duration
|
|
of one test. Defaults to a single identity mapping and localhost as
|
|
a trusted proxy.
|
|
"""
|
|
import litellm.proxy.proxy_server as proxy_server
|
|
|
|
def _configure(*, mappings=None, trusted_proxy_ranges=("127.0.0.1/32",)):
|
|
if mappings is None:
|
|
mappings = {"user_id": "x-user-id"}
|
|
settings = {
|
|
"oauth2_config_mappings": mappings,
|
|
"trusted_proxy_ranges": trusted_proxy_ranges,
|
|
}
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"general_settings",
|
|
settings,
|
|
raising=False,
|
|
)
|
|
|
|
return _configure
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_auth_for_simple_user_id_mapping(configure_proxy):
|
|
configure_proxy()
|
|
request = _request_with_headers({"x-user-id": "alice"})
|
|
|
|
auth = await handle_oauth2_proxy_request(request)
|
|
|
|
assert auth.user_id == "alice"
|
|
assert auth.user_role is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_identity_headers_without_trusted_proxy_ranges(configure_proxy):
|
|
configure_proxy(trusted_proxy_ranges=None)
|
|
request = _request_with_headers({"x-user-id": "alice"})
|
|
|
|
with pytest.raises(ValueError, match="trusted_proxy_ranges"):
|
|
await handle_oauth2_proxy_request(request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_identity_headers_from_untrusted_direct_client(configure_proxy):
|
|
configure_proxy(trusted_proxy_ranges=["10.0.0.0/24"])
|
|
request = _request_with_headers({"x-user-id": "alice"}, client_host="203.0.113.10")
|
|
|
|
with pytest.raises(ValueError, match="not trusted"):
|
|
await handle_oauth2_proxy_request(request)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"privileged_field",
|
|
[
|
|
# The GHSA-5c3m-qffq-4r9m primary privesc field.
|
|
"user_role",
|
|
# Key-level enforcement bypass shapes.
|
|
"api_key",
|
|
"token",
|
|
"permissions",
|
|
"allowed_routes",
|
|
"max_budget",
|
|
"spend",
|
|
"tpm_limit",
|
|
"rpm_limit",
|
|
"model_max_budget",
|
|
"metadata",
|
|
# User-level enforcement bypass — flagged by Greptile as a denylist gap.
|
|
"user_max_budget",
|
|
"user_tpm_limit",
|
|
"user_rpm_limit",
|
|
"user_spend",
|
|
# Team / org / end-user / region — same class, all denied by the
|
|
# identity-only allowlist.
|
|
"team_max_budget",
|
|
"team_spend",
|
|
"team_member_tpm_limit",
|
|
"organization_max_budget",
|
|
"organization_tpm_limit",
|
|
"end_user_max_budget",
|
|
"allowed_model_region",
|
|
# Anything not on ALLOWED_OAUTH2_PROXY_FIELDS is blocked, even
|
|
# fabricated field names admins might try.
|
|
"definitely_not_a_real_field",
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_refuses_to_map_non_identity_fields(configure_proxy, privileged_field):
|
|
# GHSA-5c3m-qffq-4r9m attack shape: admin maps a privileged field
|
|
# to a header and a caller forges the value. The allowlist rejects
|
|
# any non-identity mapping at request time, regardless of whether
|
|
# the field ever appeared on a denylist — which is the whole reason
|
|
# we use an allowlist instead.
|
|
configure_proxy(mappings={privileged_field: f"x-{privileged_field}"})
|
|
request = _request_with_headers({f"x-{privileged_field}": "proxy_admin"})
|
|
|
|
with pytest.raises(ValueError) as exc:
|
|
await handle_oauth2_proxy_request(request)
|
|
assert privileged_field in str(exc.value)
|
|
|
|
|
|
@pytest.mark.parametrize("identity_field", sorted(ALLOWED_OAUTH2_PROXY_FIELDS))
|
|
def test_allowlist_is_identity_only(identity_field):
|
|
# Lock in the allowlist's intent: only identity-assertion fields are
|
|
# safe to populate from a header. If anyone proposes adding budget /
|
|
# spend / role / permission to ``ALLOWED_OAUTH2_PROXY_FIELDS``, this
|
|
# assertion forces them to update the test deliberately.
|
|
assert identity_field in {
|
|
"user_id",
|
|
"user_email",
|
|
"team_id",
|
|
"team_alias",
|
|
"org_id",
|
|
"models",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_role_header_forgery_attack_is_blocked(configure_proxy):
|
|
# End-to-end form of the privesc: with ``user_role`` mapped, the
|
|
# forged ``X-User-Role: proxy_admin`` header would have produced
|
|
# a ``UserAPIKeyAuth(user_role=PROXY_ADMIN)``. Now rejected before
|
|
# any auth object is constructed.
|
|
configure_proxy(
|
|
mappings={"user_id": "x-user-id", "user_role": "x-user-role"},
|
|
)
|
|
request = _request_with_headers(
|
|
{
|
|
"x-user-id": "attacker",
|
|
"x-user-role": LitellmUserRoles.PROXY_ADMIN.value,
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="user_role"):
|
|
await handle_oauth2_proxy_request(request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_safe_fields_still_pass_through(configure_proxy):
|
|
# The documented use case for OAuth2 proxy auth: identity assertion
|
|
# from a trusted upstream. Must remain unaffected by the denylist.
|
|
configure_proxy(
|
|
mappings={
|
|
"user_id": "x-user-id",
|
|
"user_email": "x-user-email",
|
|
"team_id": "x-team-id",
|
|
"models": "x-models",
|
|
},
|
|
)
|
|
request = _request_with_headers(
|
|
{
|
|
"x-user-id": "alice",
|
|
"x-user-email": "alice@example.com",
|
|
"x-team-id": "team-corp",
|
|
"x-models": "gpt-4, gpt-3.5-turbo",
|
|
}
|
|
)
|
|
|
|
auth = await handle_oauth2_proxy_request(request)
|
|
|
|
assert auth.user_id == "alice"
|
|
assert auth.user_email == "alice@example.com"
|
|
assert auth.team_id == "team-corp"
|
|
assert auth.models == ["gpt-4", "gpt-3.5-turbo"]
|