fix(purview): exclude caller-controlled end_user_id from blocking DLP

Blocking Purview checks now use only API-key/JWT-bound user_id, not
end_user_id populated from request user/metadata/safety_identifier.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sameer Kankute 2026-05-22 22:01:53 +05:30
parent 93e4f4aea9
commit 061b292b2a
No known key found for this signature in database
3 changed files with 41 additions and 19 deletions

View file

@ -280,18 +280,25 @@ class PurviewGuardrailBase:
) -> Optional[str]:
"""Resolve the Entra user object ID from request data or auth context.
Trust order (strongest first) so client ``metadata[user_id_field]`` cannot
impersonate another Entra user for Purview ``protectionScopes`` / ``processContent``:
Trust order (strongest first). Blocking DLP uses only
``_resolve_trusted_user_id`` (API-key-bound ``user_id``). This resolver
also supports audit/logging fallbacks:
1. ``user_api_key_dict.user_id`` — LiteLLM key / internal user
2. ``user_api_key_dict.end_user_id`` — end-user on the API key
3. ``metadata["user_api_key_user_id"]`` — proxy-injected from the key (when present)
4. ``metadata[user_id_field]`` — caller-supplied; used only when none of the above apply
1. ``user_api_key_dict.user_id`` — LiteLLM key / JWT-bound user
2. ``user_api_key_dict.end_user_id`` — request-derived; audit only
3. ``metadata["user_api_key_user_id"]`` — proxy-injected from the key
4. ``metadata[user_id_field]`` — caller-supplied; audit only
"""
trusted = self._resolve_trusted_user_id(data, user_api_key_dict)
if trusted:
return trusted
if (
hasattr(user_api_key_dict, "end_user_id")
and user_api_key_dict.end_user_id
):
return str(user_api_key_dict.end_user_id)
metadata = data.get("metadata") or data.get("litellm_metadata") or {}
uid = metadata.get("user_api_key_user_id")
if uid:
@ -315,20 +322,23 @@ class PurviewGuardrailBase:
def _resolve_trusted_user_id(
self, data: Dict[str, Any], user_api_key_dict: Any
) -> Optional[str]:
"""Resolve user ID from trusted (proxy-authenticated) sources only.
"""Resolve user ID from API-key/JWT-bound identity for blocking DLP.
Uses only ``UserAPIKeyAuth.user_id`` and ``UserAPIKeyAuth.end_user_id``.
Intentionally omits ``metadata[user_id_field]`` and
``metadata["user_api_key_user_id"]`` because those can be supplied or
spoofed by the caller when the API key has no bound user.
Uses only ``UserAPIKeyAuth.user_id`` (bound on the LiteLLM key or JWT).
Intentionally omits ``UserAPIKeyAuth.end_user_id`` because the proxy sets
it from caller-controlled request fields (``user``, ``metadata.user_id``,
``safety_identifier``, custom headers, etc.) via
``get_end_user_id_from_request_body``.
Also omits ``metadata[user_id_field]`` and
``metadata["user_api_key_user_id"]`` for the same impersonation risk when
the key has no bound user.
Returns ``None`` when no authenticated identity is available. Blocking
hooks must fail closed rather than skip the DLP check.
"""
if hasattr(user_api_key_dict, "user_id") and user_api_key_dict.user_id:
return str(user_api_key_dict.user_id)
if hasattr(user_api_key_dict, "end_user_id") and user_api_key_dict.end_user_id:
return str(user_api_key_dict.end_user_id)
return None

View file

@ -261,11 +261,13 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
"""Resolve user ID for blocking (pre_call / post_call) DLP hooks.
Uses only trusted proxy-authenticated sources (``_resolve_trusted_user_id``).
Caller-supplied ``metadata[user_id_field]`` is rejected (fail closed) because
it can impersonate another Entra user's Purview policy.
Caller-supplied ``UserAPIKeyAuth.end_user_id`` (from request ``user``,
``metadata.user_id``, ``safety_identifier``, etc.) and
``metadata[user_id_field]`` are rejected (fail closed) because they can
impersonate another Entra user's Purview policy.
Raises ``HTTPException`` when no trusted identity exists or when only
caller-supplied metadata is available (fail closed).
Raises ``HTTPException`` when no API-key-bound ``user_id`` exists or when
only caller-influenceable identity fields are available (fail closed).
"""
trusted_id = self._resolve_trusted_user_id(data, user_api_key_dict)
if trusted_id:

View file

@ -1803,10 +1803,11 @@ class TestResolveTrustedUserId:
auth = UserAPIKeyAuth(api_key="test", user_id="auth-user-111")
assert guardrail._resolve_trusted_user_id({}, auth) == "auth-user-111"
def test_trusted_user_id_from_end_user_id(self):
def test_end_user_id_not_trusted_for_blocking(self):
"""end_user_id is request-derived; must not be used for blocking DLP."""
guardrail = _make_guardrail()
auth = UserAPIKeyAuth(api_key="test", end_user_id="end-user-222")
assert guardrail._resolve_trusted_user_id({}, auth) == "end-user-222"
assert guardrail._resolve_trusted_user_id({}, auth) is None
def test_metadata_user_api_key_user_id_not_trusted_without_auth(self):
"""Metadata user_api_key_user_id is not trusted when the key has no user_id."""
@ -1906,6 +1907,15 @@ class TestResolveUserIdForBlocking:
assert exc_info.value.status_code == 400
assert "bind user_id" in str(exc_info.value.detail)
def test_end_user_id_only_raises_for_blocking(self):
"""Request-derived end_user_id cannot drive blocking Purview checks."""
guardrail = _make_guardrail()
auth = UserAPIKeyAuth(api_key="test", end_user_id="caller-end-user")
with pytest.raises(HTTPException) as exc_info:
guardrail._resolve_user_id_for_blocking({}, auth)
assert exc_info.value.status_code == 400
assert "proxy-authenticated" in str(exc_info.value.detail)
# ---------------------------------------------------------------
# Token-id prompt handling in pre_call blocking mode