From b4ab76ecd53cf08eb4a01bce43f8f16638895a73 Mon Sep 17 00:00:00 2001 From: lior-k Date: Mon, 8 Jun 2026 18:21:54 +0300 Subject: [PATCH] fix(guardrails): Alice WonderFence get_metadata coerces non-dict metadata buckets A caller can send `metadata` as a non-object value (string, list). The old `caller or litellm_md or {}` returned that non-dict verbatim, so resolve_api_key / resolve_app_id then called `.get()` on it and raised; with `fail_open=True` the guardrail swallowed the error and skipped scanning entirely, and the proxy-injected `litellm_metadata` admin pins were dropped on routes like /v1/responses where the caller bucket is separate. Coerce each bucket to {} when it is not a dict before merging, so a malformed caller `metadata` can neither bypass scanning nor shadow admin-pinned credentials. Added regression tests (non-dict caller metadata: get_metadata preserves the litellm_metadata admin pin; resolve_* succeeds from the pin instead of raising). --- .../alice_wonderfence/credentials.py | 6 +-- .../alice_wonderfence/test_credentials.py | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py b/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py index f282ae41197..48ceeedd73d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py +++ b/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py @@ -49,9 +49,9 @@ def get_metadata(request_data: dict) -> dict: """ caller = request_data.get("metadata") litellm_md = request_data.get("litellm_metadata") - if isinstance(caller, dict) and isinstance(litellm_md, dict): - return {**caller, **litellm_md} - return caller or litellm_md or {} + caller = caller if isinstance(caller, dict) else {} + litellm_md = litellm_md if isinstance(litellm_md, dict) else {} + return {**caller, **litellm_md} def resolve_api_key( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py index a5605bdbd14..f5aaf6cf37c 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py @@ -231,6 +231,45 @@ def test_get_metadata_returns_empty_when_both_absent(): assert get_metadata({}) == {} +def test_get_metadata_ignores_non_dict_caller_metadata(): + """A caller can send ``metadata`` as a non-object value. It must be coerced + away rather than returned verbatim, so the proxy-injected ``litellm_metadata`` + (carrying the admin pins) is preserved.""" + data = { + "metadata": "not-a-dict", + "litellm_metadata": { + "user_api_key_metadata": {"alice_wonderfence_app_id": "admin-pinned"} + }, + } + assert get_metadata(data) == { + "user_api_key_metadata": {"alice_wonderfence_app_id": "admin-pinned"} + } + + +def test_non_dict_caller_metadata_does_not_bypass_resolution(): + """Regression: a non-dict ``metadata`` once propagated to ``resolve_*`` and + raised on ``.get()``, which ``fail_open=True`` would swallow into a skipped + scan. Resolution must instead succeed from the admin pin in + ``litellm_metadata``.""" + data = { + "model": "gpt-4", + "metadata": ["unexpected", "list"], + "litellm_metadata": { + "user_api_key_metadata": { + "alice_wonderfence_app_id": "admin-pinned", + "alice_wonderfence_api_key": "admin-key", + } + }, + } + assert resolve_app_id(data, allow_request_metadata_override=True) == "admin-pinned" + assert ( + resolve_api_key( + data, default_api_key=None, allow_request_metadata_override=True + ) + == "admin-key" + ) + + def test_responses_route_admin_pin_beats_caller_metadata(): """Mirror the /v1/responses shape: caller `metadata` carries a request-override app_id while the admin pin lives in