From 00e17c81a1f3d47b2b44ce1ffa350ef3bcbdc7ee Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 25 Nov 2025 09:36:24 -0800 Subject: [PATCH] Add enforce user param functionality (#17088) * feat: Add reject_metadata_tags to proxy config Co-authored-by: krrishdholakia * Refactor: Rename reject_metadata_tags to reject_clientside_metadata_tags Co-authored-by: krrishdholakia --------- Co-authored-by: Cursor Agent --- docs/my-website/docs/proxy/config_settings.md | 2 + .../proxy/reject_clientside_metadata_tags.md | 120 ++++++++++++ litellm/proxy/_types.py | 4 + litellm/proxy/auth/auth_checks.py | 18 ++ ...eject_clientside_metadata_tags_config.yaml | 14 ++ .../proxy/auth/test_auth_checks.py | 179 ++++++++++++++++++ 6 files changed, 337 insertions(+) create mode 100644 docs/my-website/docs/proxy/reject_clientside_metadata_tags.md create mode 100644 litellm/proxy/example_config_yaml/reject_clientside_metadata_tags_config.yaml diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 4d1bc549e0..5a586035bc 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -104,6 +104,7 @@ general_settings: disable_responses_id_security: boolean # turn off response ID security checks that prevent users from accessing other users' responses enable_jwt_auth: boolean # allow proxy admin to auth in via jwt tokens with 'litellm_proxy_admin' in claims enforce_user_param: boolean # requires all openai endpoint requests to have a 'user' param + reject_clientside_metadata_tags: boolean # if true, rejects requests with client-side 'metadata.tags' to prevent users from influencing budgets allowed_routes: ["route1", "route2"] # list of allowed proxy API routes - a user can access. (currently JWT-Auth only) key_management_system: google_kms # either google_kms or azure_kms master_key: string @@ -201,6 +202,7 @@ router_settings: | disable_responses_id_security | boolean | If true, disables response ID security checks that prevent users from accessing response IDs from other users. When false (default), response IDs are encrypted with user information to ensure users can only access their own responses. Applies to /v1/responses endpoints | | enable_jwt_auth | boolean | allow proxy admin to auth in via jwt tokens with 'litellm_proxy_admin' in claims. [Doc on JWT Tokens](token_auth) | | enforce_user_param | boolean | If true, requires all OpenAI endpoint requests to have a 'user' param. [Doc on call hooks](call_hooks)| +| reject_clientside_metadata_tags | boolean | If true, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata. | | allowed_routes | array of strings | List of allowed proxy API routes a user can access [Doc on controlling allowed routes](enterprise#control-available-public-private-routes)| | key_management_system | string | Specifies the key management system. [Doc Secret Managers](../secret) | | master_key | string | The master key for the proxy [Set up Virtual Keys](virtual_keys) | diff --git a/docs/my-website/docs/proxy/reject_clientside_metadata_tags.md b/docs/my-website/docs/proxy/reject_clientside_metadata_tags.md new file mode 100644 index 0000000000..534c65939e --- /dev/null +++ b/docs/my-website/docs/proxy/reject_clientside_metadata_tags.md @@ -0,0 +1,120 @@ +# Reject Client-Side Metadata Tags + +## Overview + +The `reject_clientside_metadata_tags` setting allows you to prevent users from passing client-side `metadata.tags` in their API requests. This ensures that tags are only inherited from the API key metadata and cannot be overridden by users to potentially influence budget tracking or routing decisions. + +## Use Case + +This feature is particularly useful in multi-tenant scenarios where: +- You want to enforce strict budget tracking based on API key tags +- You want to prevent users from manipulating routing decisions by sending custom client-side tags +- You need to ensure consistent tag-based filtering and reporting + +## Configuration + +Add the following to your `config.yaml`: + +```yaml +general_settings: + reject_clientside_metadata_tags: true # Default is false/null +``` + +## Behavior + +### When `reject_clientside_metadata_tags: true` + +**Rejected Request Example:** +```bash +curl -X POST http://localhost:4000/chat/completions \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": { + "tags": ["custom-tag"] # This will be rejected + } + }' +``` + +**Error Response:** +```json +{ + "error": { + "message": "Client-side 'metadata.tags' not allowed in request. 'reject_clientside_metadata_tags'=True. Tags can only be set via API key metadata.", + "type": "bad_request_error", + "param": "metadata.tags", + "code": 400 + } +} +``` + +**Allowed Request Example:** +```bash +curl -X POST http://localhost:4000/chat/completions \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": { + "custom_field": "value" # Other metadata fields are allowed + } + }' +``` + +### When `reject_clientside_metadata_tags: false` or not set + +All requests are allowed, including those with client-side `metadata.tags`. + +## Setting Tags via API Key + +When `reject_clientside_metadata_tags` is enabled, tags should be set on the API key metadata: + +```bash +curl -X POST http://localhost:4000/key/generate \ + -H "Authorization: Bearer sk-master-key" \ + -H "Content-Type: application/json" \ + -d '{ + "metadata": { + "tags": ["team-a", "production"] + } + }' +``` + +These tags will be automatically inherited by all requests made with that API key. + +## Complete Example Configuration + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo + api_key: os.environ/OPENAI_API_KEY + +general_settings: + master_key: sk-1234 + database_url: "postgresql://user:password@localhost:5432/litellm" + + # Reject client-side tags + reject_clientside_metadata_tags: true + + # Optional: Also enforce user parameter + enforce_user_param: true +``` + +## Similar Features + +- `enforce_user_param` - Requires all requests to include a 'user' parameter +- Tag-based routing - Use tags for intelligent request routing +- Budget tracking - Track spending per tag + +## Notes + +- This check only applies to LLM API routes (e.g., `/chat/completions`, `/embeddings`) +- Management endpoints (e.g., `/key/generate`) are not affected +- The check validates that client-side `metadata.tags` is not present in the request body +- Other metadata fields can still be passed in requests +- Tags set on API keys will still be applied to all requests diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 8a8bbdfe2e..d209ac52ae 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1889,6 +1889,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): allowed_routes: Optional[List] = Field( None, description="Proxy API Endpoints you want users to be able to access" ) + reject_clientside_metadata_tags: Optional[bool] = Field( + None, + description="When set to True, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata.", + ) enable_public_model_hub: bool = Field( default=False, description="Public model hub for users to see what models they have access to, supported openai params, etc.", diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 32795a1874..c9774b18b8 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -186,6 +186,24 @@ async def common_checks( raise Exception( f"'user' param not passed in. 'enforce_user_param'={general_settings['enforce_user_param']}" ) + + # 6.1 [OPTIONAL] If 'reject_clientside_metadata_tags' enabled - reject request if it has client-side 'metadata.tags' + if ( + general_settings.get("reject_clientside_metadata_tags", None) is not None + and general_settings["reject_clientside_metadata_tags"] is True + ): + if ( + RouteChecks.is_llm_api_route(route=route) + and "metadata" in request_body + and isinstance(request_body["metadata"], dict) + and "tags" in request_body["metadata"] + ): + raise ProxyException( + message=f"Client-side 'metadata.tags' not allowed in request. 'reject_clientside_metadata_tags'={general_settings['reject_clientside_metadata_tags']}. Tags can only be set via API key metadata.", + type=ProxyErrorTypes.bad_request_error, + param="metadata.tags", + code=status.HTTP_400_BAD_REQUEST, + ) # 7. [OPTIONAL] If 'litellm.max_budget' is set (>0), is proxy under budget if ( litellm.max_budget > 0 diff --git a/litellm/proxy/example_config_yaml/reject_clientside_metadata_tags_config.yaml b/litellm/proxy/example_config_yaml/reject_clientside_metadata_tags_config.yaml new file mode 100644 index 0000000000..3c43c3c537 --- /dev/null +++ b/litellm/proxy/example_config_yaml/reject_clientside_metadata_tags_config.yaml @@ -0,0 +1,14 @@ +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo + api_key: os.environ/OPENAI_API_KEY + +general_settings: + master_key: sk-1234 + database_url: "postgresql://user:password@localhost:5432/litellm" + + # Reject requests that contain client-side metadata.tags + # This prevents users from influencing budgets by sending different tags + # Tags can only be inherited from the API key metadata + reject_clientside_metadata_tags: true diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 057b56ce31..6cd4bec3f1 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -732,3 +732,182 @@ async def test_get_team_object_raises_404_when_not_found(): assert exc_info.value.status_code == 404 assert "Team doesn't exist in db" in str(exc_info.value.detail) + + +# Reject Client-Side Metadata Tags Tests + + +@pytest.mark.asyncio +async def test_reject_clientside_metadata_tags_enabled_with_tags(): + """Test that common_checks rejects request when reject_clientside_metadata_tags is True and metadata.tags is present""" + from litellm.proxy.auth.auth_checks import common_checks + from fastapi import Request + + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "test"}], + "metadata": {"tags": ["custom-tag"]}, + } + + general_settings = {"reject_clientside_metadata_tags": True} + + # Create a mock request object + mock_request = MagicMock(spec=Request) + + with pytest.raises(ProxyException) as exc_info: + await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=None, + request=mock_request, + ) + + assert exc_info.value.type == ProxyErrorTypes.bad_request_error + assert "metadata.tags" in exc_info.value.message + assert exc_info.value.param == "metadata.tags" + assert exc_info.value.code == 400 + + +@pytest.mark.asyncio +async def test_reject_clientside_metadata_tags_enabled_without_tags(): + """Test that common_checks allows request when reject_clientside_metadata_tags is True but no metadata.tags is present""" + from litellm.proxy.auth.auth_checks import common_checks + from fastapi import Request + + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "test"}], + "metadata": {"custom_field": "value"}, # No tags field + } + + general_settings = {"reject_clientside_metadata_tags": True} + + # Create a mock request object + mock_request = MagicMock(spec=Request) + + # Should not raise an exception + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=None, + request=mock_request, + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_reject_clientside_metadata_tags_disabled_with_tags(): + """Test that common_checks allows request with metadata.tags when reject_clientside_metadata_tags is False""" + from litellm.proxy.auth.auth_checks import common_checks + from fastapi import Request + + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "test"}], + "metadata": {"tags": ["custom-tag"]}, + } + + general_settings = {"reject_clientside_metadata_tags": False} + + # Create a mock request object + mock_request = MagicMock(spec=Request) + + # Should not raise an exception + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=None, + request=mock_request, + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_reject_clientside_metadata_tags_not_set_with_tags(): + """Test that common_checks allows request with metadata.tags when reject_clientside_metadata_tags is not set""" + from litellm.proxy.auth.auth_checks import common_checks + from fastapi import Request + + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "test"}], + "metadata": {"tags": ["custom-tag"]}, + } + + general_settings = {} # No reject_clientside_metadata_tags setting + + # Create a mock request object + mock_request = MagicMock(spec=Request) + + # Should not raise an exception + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=None, + request=mock_request, + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_reject_clientside_metadata_tags_non_llm_route(): + """Test that reject_clientside_metadata_tags check only applies to LLM API routes""" + from litellm.proxy.auth.auth_checks import common_checks + from fastapi import Request + + request_body = { + "metadata": {"tags": ["custom-tag"]}, + } + + general_settings = {"reject_clientside_metadata_tags": True} + + # Create a mock request object + mock_request = MagicMock(spec=Request) + + # Should not raise an exception for non-LLM route + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/key/generate", # Management route, not LLM route + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=None, + request=mock_request, + ) + + assert result is True