mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(openmeter): add OPENMETER_TRUST_REQUEST_USER to prevent forged attribution (#29650)
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).
This commit is contained in:
parent
38f2660c64
commit
dacce6e65f
2 changed files with 79 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue