fix(oauth2-proxy): drop premium gate; identity-only allowlist is the security fix

Greptile flagged the ``premium_user is not True`` check as a hard
backwards-incompatible break for OSS users currently running
``enable_oauth2_proxy_auth=True``. They were right: unlike the
api_base case (where the docs already required admin opt-in), this
path was documented as available to OSS users. Adding the gate would
have closed a documented feature, not fixed a vuln.

Reframed the change:

* The **identity-only allowlist** (``ALLOWED_OAUTH2_PROXY_FIELDS`` =
  ``{user_id, user_email, team_id, team_alias, org_id, models}``) is
  the actual security fix — it closes the privesc by rejecting any
  mapping to a non-identity field at request time. This is unchanged.
* The **premium gate** was parity-with-siblings (a product decision,
  not a security one). Removed. BerriAI can re-add it on their own
  schedule with a proper deprecation cycle if they want enterprise-
  only gating.

Tests: removed ``test_rejects_when_not_premium``; everything else
(allowlist enforcement, identity passthrough, attack-shape
regression) still passes — 14 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
user 2026-04-29 23:04:05 +00:00
parent b35287a062
commit fbcfd59b1a
No known key found for this signature in database
2 changed files with 16 additions and 39 deletions

View file

@ -3,7 +3,7 @@ from typing import Any, Dict, FrozenSet
from fastapi import Request from fastapi import Request
from litellm._logging import verbose_proxy_logger from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth from litellm.proxy._types import UserAPIKeyAuth
# OAuth2-proxy header trust is for **identity assertion** from a trusted # OAuth2-proxy header trust is for **identity assertion** from a trusted
# upstream auth proxy (oauth2-proxy, Authelia, etc.). The allowlist below # upstream auth proxy (oauth2-proxy, Authelia, etc.). The allowlist below
@ -42,31 +42,19 @@ async def handle_oauth2_proxy_request(request: Request) -> UserAPIKeyAuth:
The auth model assumes the proxy is deployed behind a trusted OAuth2 The auth model assumes the proxy is deployed behind a trusted OAuth2
reverse proxy that injects authenticated identity headers (e.g. reverse proxy that injects authenticated identity headers (e.g.
oauth2-proxy, Authelia). Two safeguards above and beyond that oauth2-proxy, Authelia).
deployment assumption:
1. **Premium gate.** The sibling auth paths (``enable_oauth2_auth`` **Identity-only allowlist.** ``oauth2_config_mappings`` maps header
and ``enable_jwt_auth``) require ``premium_user``; this path names to ``UserAPIKeyAuth`` fields. Without an allowlist, an admin
previously did not, which let any open-source deployment turn who maps the wrong header to ``user_role`` lets any caller send
the feature on without realising it requires a hardened ``X-User-Role: proxy_admin`` and gain full admin privileges
deployment topology. (Pydantic coerces the string into the enum). Only fields in
2. **Identity-only allowlist.** ``oauth2_config_mappings`` maps ``ALLOWED_OAUTH2_PROXY_FIELDS`` (identity assertion only see the
header names to ``UserAPIKeyAuth`` fields. Without an allowlist, constant's comment) may be mapped; any other mapping is rejected at
an admin who maps the wrong header to ``user_role`` lets any request time so the misconfiguration surfaces loudly rather than as
caller send ``X-User-Role: proxy_admin`` and gain full admin a silent privesc.
privileges (Pydantic coerces the string into the enum). Only
fields in ``ALLOWED_OAUTH2_PROXY_FIELDS`` (identity assertion
only see the constant's comment) may be mapped; any other
mapping is rejected at request time so the misconfiguration
surfaces loudly rather than as a silent privesc.
""" """
from litellm.proxy.proxy_server import general_settings, premium_user from litellm.proxy.proxy_server import general_settings
if premium_user is not True:
raise ValueError(
"Oauth2 proxy auth is an enterprise-only feature. "
+ CommonProxyErrors.not_premium_user.value
)
verbose_proxy_logger.debug("Handling oauth2 proxy request") verbose_proxy_logger.debug("Handling oauth2 proxy request")
oauth2_config_mappings: Dict[str, str] = ( oauth2_config_mappings: Dict[str, str] = (

View file

@ -47,17 +47,15 @@ def _request_with_headers(headers: dict) -> Request:
@pytest.fixture @pytest.fixture
def configure_proxy(monkeypatch): def configure_proxy(monkeypatch):
""" """
Yields a callable that sets ``premium_user`` and Yields a callable that sets ``oauth2_config_mappings`` on the
``oauth2_config_mappings`` on the proxy_server module for the proxy_server module for the duration of one test. Default mapping
duration of one test. Default is premium=True with a single is a single ``user_id -> x-user-id`` (identity-only).
``user_id -> x-user-id`` mapping.
""" """
import litellm.proxy.proxy_server as proxy_server import litellm.proxy.proxy_server as proxy_server
def _configure(*, premium=True, mappings=None): def _configure(*, mappings=None):
if mappings is None: if mappings is None:
mappings = {"user_id": "x-user-id"} mappings = {"user_id": "x-user-id"}
monkeypatch.setattr(proxy_server, "premium_user", premium, raising=False)
monkeypatch.setattr( monkeypatch.setattr(
proxy_server, proxy_server,
"general_settings", "general_settings",
@ -79,15 +77,6 @@ async def test_returns_auth_for_simple_user_id_mapping(configure_proxy):
assert auth.user_role is None assert auth.user_role is None
@pytest.mark.asyncio
async def test_rejects_when_not_premium(configure_proxy):
configure_proxy(premium=False)
request = _request_with_headers({"x-user-id": "alice"})
with pytest.raises(ValueError, match="enterprise"):
await handle_oauth2_proxy_request(request)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"privileged_field", "privileged_field",
[ [