From dacce6e65f9ccbf54bc9dd466b62a601738a8d9b Mon Sep 17 00:00:00 2001 From: Saswat Date: Fri, 5 Jun 2026 03:31:06 -0700 Subject: [PATCH] feat(openmeter): add OPENMETER_TRUST_REQUEST_USER to prevent forged attribution (#29650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenMeter callback resolves the CloudEvent subject from kwargs["user"] first, then falls back to the key-bound user_api_key_user_id. For multi-tenant proxy deployments, a client can set `"user": "..."` in the request body and cause their usage to be attributed to that arbitrary string — a billing-attribution forgery risk. Adds OPENMETER_TRUST_REQUEST_USER env var (default "true" for backward compatibility). When set to "false", the request-supplied `user` field is ignored and the subject is resolved solely from user_api_key_user_id. Matches the existing env-var-driven config pattern in this file (OPENMETER_API_KEY, OPENMETER_API_ENDPOINT, OPENMETER_EVENT_TYPE). --- litellm/integrations/openmeter.py | 10 ++- .../integrations/test_openmeter.py | 70 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/openmeter.py b/litellm/integrations/openmeter.py index 5a8ab4bcc9f..b234ab11ddb 100644 --- a/litellm/integrations/openmeter.py +++ b/litellm/integrations/openmeter.py @@ -65,7 +65,15 @@ class OpenMeterLogger(CustomLogger): "total_tokens": response_obj["usage"].get("total_tokens"), } - user_param = kwargs.get("user", None) # end-user passed in via 'user' param + # OPENMETER_TRUST_REQUEST_USER (default "true"): when set to "false", + # the request-supplied `user` field is ignored and the subject is + # resolved solely from the key-bound user_api_key_user_id. Proxies + # serving multi-tenant traffic enable this to prevent clients from + # forging attribution by setting `user` in the request body. + trust_request_user = ( + os.getenv("OPENMETER_TRUST_REQUEST_USER", "true").lower() != "false" + ) + user_param = kwargs.get("user", None) if trust_request_user else None # If no user provided directly, try to get it from token user_id if user_param is None: diff --git a/tests/test_litellm/integrations/test_openmeter.py b/tests/test_litellm/integrations/test_openmeter.py index 66dfc8e1ee7..248b9b34909 100644 --- a/tests/test_litellm/integrations/test_openmeter.py +++ b/tests/test_litellm/integrations/test_openmeter.py @@ -23,6 +23,7 @@ class TestOpenMeterIntegration: os.environ.pop("OPENMETER_API_KEY", None) os.environ.pop("OPENMETER_API_ENDPOINT", None) os.environ.pop("OPENMETER_EVENT_TYPE", None) + os.environ.pop("OPENMETER_TRUST_REQUEST_USER", None) def test_openmeter_logger_initialization(self): """Test that OpenMeterLogger initializes correctly with required env vars""" @@ -388,6 +389,75 @@ class TestOpenMeterIntegration: assert isinstance(result["subject"], str) assert result["subject"] == "12345" + def test_common_logic_trust_request_user_false_ignores_request_user(self): + """OPENMETER_TRUST_REQUEST_USER=false makes the key-bound user_id win + over a request-supplied `user` (forge-attribution mitigation).""" + os.environ["OPENMETER_TRUST_REQUEST_USER"] = "false" + logger = OpenMeterLogger() + + kwargs = { + "user": "forged-by-client", + "model": "gpt-4", + "response_cost": 0.002, + "litellm_call_id": "test-call-id", + "litellm_params": { + "metadata": {"user_api_key_user_id": "real-tenant-id"} + }, + } + + response_obj = { + "id": "test-response-id", + "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, + } + + result = logger._common_logic(kwargs, response_obj) + + assert result["subject"] == "real-tenant-id" + assert result["subject"] != "forged-by-client" + + def test_common_logic_trust_request_user_false_still_raises_without_key_user(self): + """OPENMETER_TRUST_REQUEST_USER=false still raises when no + user_api_key_user_id is available — the request `user` is not a + fallback in this mode.""" + os.environ["OPENMETER_TRUST_REQUEST_USER"] = "false" + logger = OpenMeterLogger() + + kwargs = { + "user": "would-have-worked-without-the-flag", + "model": "gpt-3.5-turbo", + "response_cost": 0.001, + "litellm_call_id": "test-call-id", + } + + response_obj = {"id": "test-response-id"} + + with pytest.raises(Exception, match="OpenMeter: user is required"): + logger._common_logic(kwargs, response_obj) + + def test_common_logic_trust_request_user_default_preserves_behavior(self): + """Default (unset OPENMETER_TRUST_REQUEST_USER) keeps request `user` + taking priority — backward compatibility.""" + # OPENMETER_TRUST_REQUEST_USER intentionally unset + logger = OpenMeterLogger() + + kwargs = { + "user": "request-user", + "model": "gpt-4", + "response_cost": 0.002, + "litellm_call_id": "test-call-id", + "litellm_params": { + "metadata": {"user_api_key_user_id": "key-user"} + }, + } + + response_obj = { + "id": "test-response-id", + "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, + } + + result = logger._common_logic(kwargs, response_obj) + assert result["subject"] == "request-user" + @patch("litellm.integrations.openmeter.HTTPHandler") def test_integration_token_user_id_scenario(self, mock_http_handler): """Integration test simulating the exact scenario that was failing"""