fix(proxy): authorize every Responses API id, not only the ones the proxy issued (#39548)

* fix(proxy): authorize every Responses API id, not only the ones the proxy issued

The ownership check on the Responses API only ran when the id arrived in the
proxy's own encrypted format. An id in any other shape skipped the check and
was forwarded upstream, so a key that did not own the response could retrieve,
cancel, delete, or chain off it.

Every addressed id now goes through one authorization step shared by retrieve,
cancel, delete, list-input-items, and create's previous_response_id. An id the
proxy did not issue is refused with 403 unless the deployment opts in with
general_settings.allow_unmanaged_response_ids, has responses id security
disabled, has no signing key configured, or the caller is a proxy admin.

* fix(proxy): re-authorize the retained responses id instead of trusting it
This commit is contained in:
Mateo Wang 2026-09-11 11:47:05 -07:00 committed by GitHub
parent 2c78e28c24
commit 1dc0e363b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 338 additions and 29 deletions

View file

@ -2857,6 +2857,25 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"UI username/password login. Default is False."
),
)
disable_responses_id_security: bool | None = Field(
None,
description=(
"If True, disables ownership enforcement on Responses API ids. "
"Keys may then retrieve, cancel, delete, and chain from any response id, "
"including ids belonging to another user or team and ids this proxy never issued. "
"WARNING: this removes tenant isolation on /v1/responses"
),
)
allow_unmanaged_response_ids: bool | None = Field(
None,
description=(
"If True, lets keys address Responses API ids that this proxy did not issue "
"(raw provider ids, or ids issued before response-id encryption was configured). "
"Such an id carries no owner, so no ownership check can run on it; ids this proxy "
"did issue keep full ownership enforcement. Off by default, in which case an "
"unrecognized response id is rejected with 403"
),
)
disable_env_credential_login: bool | None = Field(
None,
description=(

View file

@ -32,6 +32,29 @@ if TYPE_CHECKING:
_RESPONSES_API_PROVIDER_PREFIX: Final = "/openai"
_RESPONSES_API_CREATE_ROUTES: Final = frozenset({"/v1/responses", "/responses"})
_ADDRESSED_RESPONSE_ID_KEY: Final = "_litellm_addressed_response_id"
_UNMANAGED_RESPONSE_ID_DETAIL: Final = (
"Forbidden. This response id was not issued by this proxy, so the proxy cannot tell who owns it. "
"To let keys address responses this proxy did not issue, set "
"general_settings::allow_unmanaged_response_ids to True in the config.yaml file."
)
_PROXY_ADMIN_ROLES: Final = frozenset({LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN.value})
def _proxy_general_settings() -> Mapping[str, Any]:
from litellm.proxy.proxy_server import general_settings
return general_settings
def _proxy_signing_key() -> str | None:
import os
from litellm.proxy.proxy_server import master_key
salt_key: Final = os.getenv("LITELLM_SALT_KEY", None)
return master_key if salt_key is None else salt_key
_RESPONSE_PAYLOAD_ADAPTER: Final = TypeAdapter(Mapping[str, object])
@ -83,8 +106,13 @@ def _is_responses_api_create_route(request_route: str | None) -> bool:
class ResponsesIDSecurity(CustomLogger):
def __init__(self):
pass
def __init__(
self,
general_settings_reader: Callable[[], Mapping[str, Any]] = _proxy_general_settings,
signing_key_reader: Callable[[], str | None] = _proxy_signing_key,
) -> None:
self._general_settings_reader: Final = general_settings_reader
self._signing_key_reader: Final = signing_key_reader
async def async_pre_call_hook(
self,
@ -103,30 +131,51 @@ class ResponsesIDSecurity(CustomLogger):
}
if call_type not in responses_api_call_types:
return None
if call_type == "aresponses":
# check 'previous_response_id' if present in the data
previous_response_id: Final = data.get("previous_response_id")
if previous_response_id and self._is_encrypted_response_id(previous_response_id):
original_response_id, user_id, team_id = self._decrypt_response_id(previous_response_id)
self.check_user_access_to_response_id(user_id, team_id, user_api_key_dict)
data["previous_response_id"] = original_response_id
elif call_type in {"aget_responses", "adelete_responses", "acancel_responses", "alist_input_items"}:
response_id: Final = data.get("response_id")
if response_id and self._is_encrypted_response_id(response_id):
original_response_id, user_id, team_id = self._decrypt_response_id(response_id)
self.check_user_access_to_response_id(user_id, team_id, user_api_key_dict)
data["response_id"] = original_response_id
addressed_id_field: Final = "previous_response_id" if call_type == "aresponses" else "response_id"
retained_id: Final = data.get(_ADDRESSED_RESPONSE_ID_KEY)
addressed_id: Final = (
retained_id if isinstance(retained_id, str) and retained_id else data.get(addressed_id_field)
)
if not isinstance(addressed_id, str) or not addressed_id:
return data
authorized_id: Final = self._authorize_response_id(addressed_id, user_api_key_dict)
data[addressed_id_field] = authorized_id
data[_ADDRESSED_RESPONSE_ID_KEY] = addressed_id
return data
def _authorize_response_id(
self,
response_id: str,
user_api_key_dict: "UserAPIKeyAuth",
) -> str:
if self._is_encrypted_response_id(response_id):
original_response_id, user_id, team_id = self._decrypt_response_id(response_id)
self.check_user_access_to_response_id(user_id, team_id, user_api_key_dict)
return original_response_id
if self._unmanaged_response_ids_allowed(user_api_key_dict):
return response_id
raise HTTPException(status_code=403, detail=_UNMANAGED_RESPONSE_ID_DETAIL)
def _unmanaged_response_ids_allowed(self, user_api_key_dict: "UserAPIKeyAuth") -> bool:
general_settings: Final = self._general_settings_reader()
if general_settings.get("disable_responses_id_security", False):
return True
if general_settings.get("allow_unmanaged_response_ids", False):
return True
if self._get_signing_key() is None:
return True
return user_api_key_dict.user_role in _PROXY_ADMIN_ROLES
def check_user_access_to_response_id(
self,
response_id_user_id: str | None,
response_id_team_id: str | None,
user_api_key_dict: "UserAPIKeyAuth",
) -> bool:
from litellm.proxy.proxy_server import general_settings
general_settings: Final = self._general_settings_reader()
if (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
@ -219,15 +268,7 @@ class ResponsesIDSecurity(CustomLogger):
return response_id, None, None
def _get_signing_key(self) -> str | None:
"""Get the signing key for encryption/decryption."""
import os
from litellm.proxy.proxy_server import master_key
salt_key = os.getenv("LITELLM_SALT_KEY", None)
if salt_key is None:
salt_key = master_key
return salt_key
return self._signing_key_reader()
def _encrypt_response_id(
self,
@ -274,7 +315,7 @@ class ResponsesIDSecurity(CustomLogger):
This method adds response IDs to an in-memory queue, which are then
batch-processed by the DBSpendUpdateWriter during regular database update cycles.
"""
from litellm.proxy.proxy_server import general_settings
general_settings: Final = self._general_settings_reader()
if general_settings.get("disable_responses_id_security", False):
return response
@ -288,7 +329,7 @@ class ResponsesIDSecurity(CustomLogger):
async def async_post_call_streaming_iterator_hook(
self, user_api_key_dict: "UserAPIKeyAuth", response: Any, request_data: dict
) -> AsyncGenerator[BaseLiteLLMOpenAIResponseObject, None]:
from litellm.proxy.proxy_server import general_settings
general_settings: Final = self._general_settings_reader()
# Create a request-scoped cache for consistent encryption across streaming chunks.
request_encryption_cache: Final[dict[str, str]] = {}

View file

@ -855,3 +855,242 @@ class TestAsyncPostCallSuccessHook:
)
assert result == mock_response
_FABRICATED_PROVIDER_RESPONSE_ID = "resp_fabricatedprovideridaaaaaaaaaaaaaaaa"
_FABRICATED_UNMANAGED_ID = "resp_fabricatedunmanagedidbbbbbbbbbbbbbbbb"
_UNIT_TEST_SALT_KEY = "lit6837-unit-test-salt-key"
_ADDRESSED_ID_FIELD_BY_CALL_TYPE = {
"aresponses": "previous_response_id",
"aget_responses": "response_id",
"adelete_responses": "response_id",
"acancel_responses": "response_id",
"alist_input_items": "response_id",
}
@pytest.fixture
def salt_key_env(monkeypatch):
"""Give the encrypt/decrypt helpers a real salt key so ids round-trip for real."""
monkeypatch.setenv("LITELLM_SALT_KEY", _UNIT_TEST_SALT_KEY)
return _UNIT_TEST_SALT_KEY
def _hook(general_settings=None, signing_key=_UNIT_TEST_SALT_KEY):
settings = general_settings if general_settings is not None else {}
return ResponsesIDSecurity(
general_settings_reader=lambda: settings,
signing_key_reader=lambda: signing_key,
)
def _auth(user_id="owner-user", team_id="owner-team", user_role=None):
from litellm.proxy._types import UserAPIKeyAuth
return UserAPIKeyAuth(user_id=user_id, team_id=team_id, user_role=user_role)
def _issue_managed_id(hook, owner, provider_response_id=_FABRICATED_PROVIDER_RESPONSE_ID):
"""Mint an id exactly the way the proxy hands one to a client on create."""
issued = hook._encrypt_response_id(
ResponsesAPIResponse(
id=provider_response_id, created_at=1234567890, output=[], status="completed"
),
owner,
)
return issued.id
class TestUnrecognizedResponseIdIsRejected:
"""An id this proxy never issued carries no owner, so it must not reach the provider."""
@pytest.mark.asyncio
@pytest.mark.parametrize("call_type", sorted(_ADDRESSED_ID_FIELD_BY_CALL_TYPE))
async def test_unmanaged_id_is_rejected_and_not_forwarded(self, mock_cache, salt_key_env, call_type):
field = _ADDRESSED_ID_FIELD_BY_CALL_TYPE[call_type]
data = {field: _FABRICATED_UNMANAGED_ID}
with pytest.raises(HTTPException) as exc_info:
await _hook().async_pre_call_hook(
user_api_key_dict=_auth(),
cache=mock_cache,
data=data,
call_type=call_type,
)
assert exc_info.value.status_code == 403
assert "allow_unmanaged_response_ids" in exc_info.value.detail
assert data[field] == _FABRICATED_UNMANAGED_ID
@pytest.mark.asyncio
async def test_owner_can_still_address_the_id_the_proxy_issued_it(self, mock_cache, salt_key_env):
hook = _hook()
owner = _auth()
data = {"response_id": _issue_managed_id(hook, owner)}
result = await hook.async_pre_call_hook(
user_api_key_dict=owner,
cache=mock_cache,
data=data,
call_type="aget_responses",
)
assert result["response_id"] == _FABRICATED_PROVIDER_RESPONSE_ID
@pytest.mark.asyncio
async def test_stranger_cannot_address_an_id_issued_to_someone_else(self, mock_cache, salt_key_env):
hook = _hook()
issued_id = _issue_managed_id(hook, _auth())
data = {"response_id": issued_id}
with pytest.raises(HTTPException) as exc_info:
await hook.async_pre_call_hook(
user_api_key_dict=_auth(user_id="stranger-user", team_id="stranger-team"),
cache=mock_cache,
data=data,
call_type="aget_responses",
)
assert exc_info.value.status_code == 403
assert data["response_id"] == issued_id
@pytest.mark.asyncio
async def test_unmanaged_previous_response_id_cannot_seed_a_new_response(self, mock_cache, salt_key_env):
data = {"model": "gpt-fake", "previous_response_id": _FABRICATED_UNMANAGED_ID}
with pytest.raises(HTTPException) as exc_info:
await _hook().async_pre_call_hook(
user_api_key_dict=_auth(),
cache=mock_cache,
data=data,
call_type="aresponses",
)
assert exc_info.value.status_code == 403
assert data["previous_response_id"] == _FABRICATED_UNMANAGED_ID
@pytest.mark.asyncio
async def test_re_entering_the_hook_on_the_same_request_does_not_reject(self, mock_cache, salt_key_env):
"""The rate-limit fallback retry runs pre-call twice over one already-rewritten dict."""
hook = _hook()
owner = _auth()
data = {"model": "gpt-fake", "previous_response_id": _issue_managed_id(hook, owner)}
first = await hook.async_pre_call_hook(
user_api_key_dict=owner, cache=mock_cache, data=data, call_type="aresponses"
)
second = await hook.async_pre_call_hook(
user_api_key_dict=owner, cache=mock_cache, data=first, call_type="aresponses"
)
assert second["previous_response_id"] == _FABRICATED_PROVIDER_RESPONSE_ID
class TestUnmanagedResponseIdEscapeHatches:
"""Deployments that pass provider ids through on purpose must keep working."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"general_settings",
[{"allow_unmanaged_response_ids": True}, {"disable_responses_id_security": True}],
)
async def test_opted_in_settings_forward_the_id_untouched(self, mock_cache, salt_key_env, general_settings):
data = {"response_id": _FABRICATED_UNMANAGED_ID}
result = await _hook(general_settings=general_settings).async_pre_call_hook(
user_api_key_dict=_auth(),
cache=mock_cache,
data=data,
call_type="aget_responses",
)
assert result["response_id"] == _FABRICATED_UNMANAGED_ID
@pytest.mark.asyncio
async def test_proxy_without_a_signing_key_forwards_the_id_untouched(self, mock_cache, monkeypatch):
monkeypatch.delenv("LITELLM_SALT_KEY", raising=False)
data = {"response_id": _FABRICATED_UNMANAGED_ID}
result = await _hook(signing_key=None).async_pre_call_hook(
user_api_key_dict=_auth(),
cache=mock_cache,
data=data,
call_type="aget_responses",
)
assert result["response_id"] == _FABRICATED_UNMANAGED_ID
@pytest.mark.asyncio
async def test_proxy_admin_may_address_an_unmanaged_id(self, mock_cache, salt_key_env):
from litellm.proxy._types import LitellmUserRoles
data = {"response_id": _FABRICATED_UNMANAGED_ID}
result = await _hook().async_pre_call_hook(
user_api_key_dict=_auth(user_role=LitellmUserRoles.PROXY_ADMIN),
cache=mock_cache,
data=data,
call_type="aget_responses",
)
assert result["response_id"] == _FABRICATED_UNMANAGED_ID
class TestClientSuppliedRetainedIdCannotBypassAuthorization:
"""The retained-id key travels in the request body, so it is re-authorized, never trusted."""
@pytest.mark.asyncio
@pytest.mark.parametrize("call_type", sorted(_ADDRESSED_ID_FIELD_BY_CALL_TYPE))
async def test_forged_retained_id_is_still_authorized(self, mock_cache, salt_key_env, call_type):
field = _ADDRESSED_ID_FIELD_BY_CALL_TYPE[call_type]
data = {
field: _FABRICATED_UNMANAGED_ID,
"_litellm_addressed_response_id": _FABRICATED_UNMANAGED_ID,
}
with pytest.raises(HTTPException) as exc_info:
await _hook().async_pre_call_hook(
user_api_key_dict=_auth(),
cache=mock_cache,
data=data,
call_type=call_type,
)
assert exc_info.value.status_code == 403
assert data[field] == _FABRICATED_UNMANAGED_ID
@pytest.mark.asyncio
@pytest.mark.parametrize("forged", [{"nested": "value"}, ["list"], 42, "", None])
async def test_non_string_retained_id_falls_back_to_the_addressed_field(self, mock_cache, salt_key_env, forged):
data = {"response_id": _FABRICATED_UNMANAGED_ID, "_litellm_addressed_response_id": forged}
with pytest.raises(HTTPException) as exc_info:
await _hook().async_pre_call_hook(
user_api_key_dict=_auth(),
cache=mock_cache,
data=data,
call_type="aget_responses",
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_stranger_forging_their_own_id_never_reaches_someone_elses_response(
self, mock_cache, salt_key_env
):
hook = _hook()
stranger = _auth(user_id="stranger-user", team_id="stranger-team")
stranger_id = _issue_managed_id(hook, stranger, provider_response_id="resp_strangerownprovideridcccccccc")
victim_provider_id = "resp_victimprovideriddddddddddddddddddddd"
data = {"response_id": victim_provider_id, "_litellm_addressed_response_id": stranger_id}
result = await hook.async_pre_call_hook(
user_api_key_dict=stranger,
cache=mock_cache,
data=data,
call_type="aget_responses",
)
assert result["response_id"] == "resp_strangerownprovideridcccccccc"
assert result["response_id"] != victim_provider_id

View file

@ -25629,6 +25629,11 @@ export interface components {
* @description opt-in to RFC 8628 verification_uri_complete for the CLI SSO device flow, pre-filling the user_code in the browser. Off by default; intended for same-host clients where the device that starts the flow and the browser run on the same machine
*/
allow_cli_sso_verification_uri_complete?: boolean | null;
/**
* Allow Unmanaged Response Ids
* @description If True, lets keys address Responses API ids that this proxy did not issue (raw provider ids, or ids issued before response-id encryption was configured). Such an id carries no owner, so no ownership check can run on it; ids this proxy did issue keep full ownership enforcement. Off by default, in which case an unrecognized response id is rejected with 403
*/
allow_unmanaged_response_ids?: boolean | null;
/**
* Allowed Routes
* @description Proxy API Endpoints you want users to be able to access
@ -25748,6 +25753,11 @@ export interface components {
* @description If True and SSO is configured (MICROSOFT_CLIENT_ID, GOOGLE_CLIENT_ID, GENERIC_CLIENT_ID, or SAML_IDP_METADATA_URL/XML), disables username/password login on /login, /v2/login, and /v3/login so SSO is the only way to reach the Admin UI. An admin locked out of the UI can still administer the proxy over the API with the master key; unset this setting and restart the proxy to restore UI username/password login. Default is False.
*/
disable_password_login_when_sso_enabled?: boolean | null;
/**
* Disable Responses Id Security
* @description If True, disables ownership enforcement on Responses API ids. Keys may then retrieve, cancel, delete, and chain from any response id, including ids belonging to another user or team and ids this proxy never issued. WARNING: this removes tenant isolation on /v1/responses
*/
disable_responses_id_security?: boolean | null;
/**
* Enable Openai Websocket Passthrough
* @description Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.