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:
Saswat 2026-06-05 03:31:06 -07:00 • committed by GitHub
parent 38f2660c64
commit dacce6e65f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 1 deletions

View file

@ -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:

View file

@ -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"""