fix(guardrails): Alice WonderFence — admin metadata wins over request metadata

Caller-supplied metadata.alice_wonderfence_app_id / alice_wonderfence_api_key
no longer outrank admin-pinned key/team metadata. Adds
allow_request_metadata_override (default False) as an explicit opt-in for
trusted-gateway deployments — even when enabled, key/team metadata still
wins. Closes the high-severity precedence inversion flagged on PR #26901
(review comment r3226452019).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lior-k 2026-05-14 12:38:26 +03:00
parent d79dd99f63
commit 41278a27b1
No known key found for this signature in database
5 changed files with 234 additions and 75 deletions

View file

@ -46,6 +46,10 @@ def initialize_guardrail(
init_kwargs["block_message"] = litellm_params.block_message
if litellm_params.debug is not None:
init_kwargs["debug"] = litellm_params.debug
if litellm_params.allow_request_metadata_override is not None:
init_kwargs["allow_request_metadata_override"] = (
litellm_params.allow_request_metadata_override
)
wonderfence_guardrail = WonderFenceGuardrail(**init_kwargs)

View file

@ -59,20 +59,27 @@ class WonderFenceBlockedError(Exception):
class WonderFenceGuardrail(CustomGuardrail):
"""Alice WonderFence guardrail handler using the V2 SDK client.
``api_key`` and ``app_id`` are resolved per request from request metadata,
API-key metadata, or team metadata. ``api_key`` falls back to a configured
default; ``app_id`` has no default and must be supplied per request.
``api_key`` and ``app_id`` are resolved per request from API-key metadata,
team metadata, optionally request metadata, with ``api_key`` falling back
to a configured default. ``app_id`` has no default.
Resolution order for ``api_key``:
1. Request metadata: ``metadata.alice_wonderfence_api_key``
2. API key metadata: ``user_api_key_metadata.alice_wonderfence_api_key``
3. Team metadata: ``user_api_key_team_metadata.alice_wonderfence_api_key``
1. API key metadata: ``user_api_key_metadata.alice_wonderfence_api_key``
2. Team metadata: ``user_api_key_team_metadata.alice_wonderfence_api_key``
3. Request metadata: ``metadata.alice_wonderfence_api_key`` (only when
``allow_request_metadata_override=True``)
4. Default: configured ``api_key`` or ``ALICE_API_KEY`` env var
Resolution order for ``app_id`` (no default — error if missing):
1. Request metadata: ``metadata.alice_wonderfence_app_id``
2. API key metadata: ``user_api_key_metadata.alice_wonderfence_app_id``
3. Team metadata: ``user_api_key_team_metadata.alice_wonderfence_app_id``
1. API key metadata: ``user_api_key_metadata.alice_wonderfence_app_id``
2. Team metadata: ``user_api_key_team_metadata.alice_wonderfence_app_id``
3. Request metadata: ``metadata.alice_wonderfence_app_id`` (only when
``allow_request_metadata_override=True``)
Admin-pinned credentials (key/team metadata) always win over request
metadata so a caller cannot bypass their assigned WonderFence app.
``allow_request_metadata_override`` defaults to False; enable only for
trusted-gateway deployments that need request-level overrides.
A V2 SDK client is cached per resolved ``api_key`` (LRU).
"""
@ -89,6 +96,7 @@ class WonderFenceGuardrail(CustomGuardrail):
debug: bool = False,
max_cached_clients: Optional[int] = None,
connection_pool_limit: Optional[int] = None,
allow_request_metadata_override: bool = False,
event_hook: Optional[
Union[GuardrailEventHooks, List[GuardrailEventHooks], Mode]
] = None,
@ -113,6 +121,11 @@ class WonderFenceGuardrail(CustomGuardrail):
keyed by api_key). Default 10. Env: ALICE_MAX_CACHED_CLIENTS.
connection_pool_limit: Max connections per SDK client HTTP pool.
Env: ALICE_CONNECTION_POOL_LIMIT.
allow_request_metadata_override: When True, allow per-request
``metadata.alice_wonderfence_api_key`` /
``metadata.alice_wonderfence_app_id`` as a last-resort source
(after API-key and team metadata). Defaults to False so
caller-controlled fields cannot bypass admin-pinned credentials.
event_hook: Event hook mode.
default_on: Whether the guardrail is enabled by default.
"""
@ -142,6 +155,7 @@ class WonderFenceGuardrail(CustomGuardrail):
self.platform = platform
self.fail_open = fail_open
self.block_message = block_message
self.allow_request_metadata_override = allow_request_metadata_override
if debug:
logger.setLevel(logging.DEBUG)
@ -216,7 +230,13 @@ class WonderFenceGuardrail(CustomGuardrail):
)
def _resolve_api_key(self, request_data: dict) -> str:
"""Resolve api_key from request → key → team metadata, falling back to default.
"""Resolve api_key from key → team → (request, when opt-in) → default.
Admin-pinned sources (API-key and team metadata) take precedence over
request-body metadata so a caller cannot bypass their assigned
WonderFence credentials. Request metadata is consulted only when
``allow_request_metadata_override`` is True, and even then only after
the admin-controlled sources.
The LiteLLM framework copies key/team metadata from ``UserAPIKeyAuth``
into ``data['metadata']`` under ``user_api_key_metadata`` and
@ -225,10 +245,6 @@ class WonderFenceGuardrail(CustomGuardrail):
"""
metadata = self._get_metadata(request_data)
req_api_key = metadata.get("alice_wonderfence_api_key")
if req_api_key:
return req_api_key
key_metadata = metadata.get("user_api_key_metadata") or {}
if isinstance(key_metadata, dict) and key_metadata.get(
"alice_wonderfence_api_key"
@ -241,21 +257,28 @@ class WonderFenceGuardrail(CustomGuardrail):
):
return team_metadata["alice_wonderfence_api_key"]
if self.allow_request_metadata_override:
req_api_key = metadata.get("alice_wonderfence_api_key")
if req_api_key:
return req_api_key
if self.api_key:
return self.api_key
raise WonderFenceMissingSecrets(
"No alice_wonderfence_api_key found in request metadata, API-key "
"metadata, team metadata, or default config (ALICE_API_KEY)."
"No alice_wonderfence_api_key found in API-key metadata, team "
"metadata, request metadata (when allow_request_metadata_override "
"is enabled), or default config (ALICE_API_KEY)."
)
def _resolve_app_id(self, request_data: dict) -> str:
"""Resolve app_id from request → key → team metadata. No default — raise if missing."""
metadata = self._get_metadata(request_data)
"""Resolve app_id from key → team → (request, when opt-in). No default.
req_app_id = metadata.get("alice_wonderfence_app_id")
if req_app_id:
return req_app_id
Admin-pinned sources win over request-body metadata; request metadata
is only consulted when ``allow_request_metadata_override`` is True.
Raises ``WonderFenceMissingSecrets`` when nothing resolves.
"""
metadata = self._get_metadata(request_data)
key_metadata = metadata.get("user_api_key_metadata") or {}
if isinstance(key_metadata, dict) and key_metadata.get(
@ -269,9 +292,15 @@ class WonderFenceGuardrail(CustomGuardrail):
):
return team_metadata["alice_wonderfence_app_id"]
if self.allow_request_metadata_override:
req_app_id = metadata.get("alice_wonderfence_app_id")
if req_app_id:
return req_app_id
raise WonderFenceMissingSecrets(
"No alice_wonderfence_app_id found in request metadata, API-key "
"metadata, or team metadata. app_id must be provided per request."
"No alice_wonderfence_app_id found in API-key metadata, team "
"metadata, or request metadata (when allow_request_metadata_override "
"is enabled). app_id must be provided per request."
)
def _build_analysis_context(self, request_data: dict) -> Any:

View file

@ -9,9 +9,15 @@
# ALICE_CONNECTION_POOL_LIMIT - Optional: HTTP pool size per client
# OPENAI_API_KEY - API key for OpenAI
#
# Per-request / per-key / per-team metadata keys:
# Per-key / per-team metadata keys (admin-controlled):
# alice_wonderfence_api_key - overrides default API key (optional)
# alice_wonderfence_app_id - REQUIRED — must be set on request, key, or team
# alice_wonderfence_app_id - REQUIRED — must be set on key or team
#
# Per-request metadata keys (caller-controlled, OFF by default):
# metadata.alice_wonderfence_api_key / metadata.alice_wonderfence_app_id are
# ignored unless allow_request_metadata_override is True on the guardrail.
# Even when enabled, key/team metadata still wins — request metadata is a
# last-resort source only.
model_list:
- model_name: gpt-4
@ -22,7 +28,7 @@ model_list:
guardrails:
# Combined pre + post with advanced knobs
- guardrail_name: "alice-wonderfence-full-guard"
- guardrail_name: "alice-wonderfence"
litellm_params:
guardrail: alice_wonderfence
mode: ["pre_call", "post_call"]
@ -37,9 +43,41 @@ guardrails:
# connection_pool_limit: 20
# Enable only for trusted-gateway deployments that need to forward a
# per-tenant app_id/api_key from the request body. Even with this on,
# admin-pinned key/team metadata still wins; request metadata is a
# last-resort source only. Default is False — leave it off unless your
# callers are themselves trusted infrastructure.
allow_request_metadata_override: true
# Example usage
#
# 1. Request-level app_id override (every request must supply app_id somewhere):
# 1. Per-API-key app_id (set at key creation — recommended default):
#
# curl -X POST http://localhost:4000/key/generate \
# -H "Authorization: Bearer sk-admin" \
# -H "Content-Type: application/json" \
# -d '{
# "metadata": {
# "alice_wonderfence_app_id": "tenant-A-app",
# "alice_wonderfence_api_key": "wf-key-for-tenant-A"
# }
# }'
#
# 2. Per-team app_id (set at team creation):
#
# curl -X POST http://localhost:4000/team/new \
# -H "Authorization: Bearer sk-admin" \
# -H "Content-Type: application/json" \
# -d '{
# "team_alias": "team-billing",
# "metadata": {
# "alice_wonderfence_app_id": "team-billing-app"
# }
# }'
#
# 3. Request-level app_id (only when allow_request_metadata_override: true on
# the guardrail config — and even then, key/team metadata still wins):
#
# curl -X POST http://localhost:4000/chat/completions \
# -H "Authorization: Bearer sk-xxx" \
@ -53,29 +91,7 @@ guardrails:
# }
# }'
#
# 2. Per-API-key app_id (set at key creation, no per-request metadata needed):
#
# curl -X POST http://localhost:4000/key/generate \
# -H "Authorization: Bearer sk-admin" \
# -H "Content-Type: application/json" \
# -d '{
# "metadata": {
# "alice_wonderfence_app_id": "tenant-A-app",
# "alice_wonderfence_api_key": "wf-key-for-tenant-A"
# }
# }'
#
# 3. Per-team app_id (set at team creation):
#
# curl -X POST http://localhost:4000/team/new \
# -H "Authorization: Bearer sk-admin" \
# -H "Content-Type: application/json" \
# -d '{
# "team_alias": "team-billing",
# "metadata": {
# "alice_wonderfence_app_id": "team-billing-app"
# }
# }'
#
# Resolution priority (highest first): request metadata > key metadata > team metadata > config default.
# api_key falls back to config / ALICE_API_KEY env. app_id has NO default.
# Resolution priority (highest first): key metadata > team metadata >
# request metadata (only if allow_request_metadata_override=true) >
# config default. api_key falls back to config / ALICE_API_KEY env;
# app_id has NO default.

View file

@ -10,15 +10,25 @@ from .base import GuardrailConfigModel
class WonderFenceGuardrailConfigModel(GuardrailConfigModel):
"""Configuration parameters for the Alice WonderFence guardrail.
Per-request ``api_key`` and ``app_id`` are read from request / API-key /
team metadata using these keys: ``alice_wonderfence_api_key``,
``alice_wonderfence_app_id``. ``api_id`` has no default. ``api_key`` falls
back to the value below or the ``ALICE_API_KEY`` env var.
Resolution order for ``api_key`` and ``app_id`` (highest first):
API-key metadata → team metadata → request metadata (only when
``allow_request_metadata_override`` is True) → ``api_key`` falls back to
the configured default below or the ``ALICE_API_KEY`` env var; ``app_id``
has no default.
By default, request-body metadata is ignored so a caller cannot bypass
an admin-pinned WonderFence ``app_id`` / ``api_key`` on their virtual
key. Enable ``allow_request_metadata_override`` for trusted-gateway
deployments that legitimately need request-level overrides.
"""
api_key: Optional[str] = Field(
default=None,
description="Default API key for WonderFence (overridable per request via metadata.alice_wonderfence_api_key). Env: ALICE_API_KEY.",
description="Default API key for WonderFence. Overridable via API-key / team metadata, or via request metadata (alice_wonderfence_api_key) only when allow_request_metadata_override is True. Env: ALICE_API_KEY.",
)
allow_request_metadata_override: Optional[bool] = Field(
default=False,
description="When True, allow alice_wonderfence_api_key and alice_wonderfence_app_id in request metadata as a last-resort source (after API-key and team metadata). Default False so caller-controlled request fields cannot bypass admin-pinned credentials.",
)
api_base: Optional[str] = Field(
default=None,

View file

@ -56,9 +56,18 @@ def _make_guardrail(monkeypatch, **overrides):
def _request_data(**overrides):
"""Build a request-data dict.
Default metadata pins ``alice_wonderfence_app_id`` on
``user_api_key_metadata`` (admin-controlled) so the request resolves
cleanly under the safe-by-default precedence model. Tests that want to
drive the value through request metadata must (a) construct a guardrail
with ``allow_request_metadata_override=True`` and (b) pass the value via
the ``metadata`` kwarg explicitly.
"""
metadata = overrides.pop("metadata", None)
if metadata is None:
metadata = {"alice_wonderfence_app_id": "test-app"}
metadata = {"user_api_key_metadata": {"alice_wonderfence_app_id": "test-app"}}
base = {"model": "gpt-4", "metadata": metadata}
base.update(overrides)
return base
@ -67,12 +76,26 @@ def _request_data(**overrides):
# ----------------------------- resolver tests -----------------------------
def test_resolve_app_id_from_request_metadata(monkeypatch):
guardrail, _ = _make_guardrail(monkeypatch)
def test_resolve_app_id_from_request_metadata_requires_override_flag(monkeypatch):
guardrail, _ = _make_guardrail(monkeypatch, allow_request_metadata_override=True)
data = _request_data(metadata={"alice_wonderfence_app_id": "from-req"})
assert guardrail._resolve_app_id(data) == "from-req"
def test_resolve_app_id_request_metadata_ignored_when_override_disabled(monkeypatch):
"""Request metadata is caller-controlled and must not satisfy app_id when
the override flag is off — otherwise a user could bypass admin-pinned
credentials by sending their own app_id in the request body."""
from litellm.proxy.guardrails.guardrail_hooks.alice_wonderfence.alice_wonderfence import (
WonderFenceMissingSecrets,
)
guardrail, _ = _make_guardrail(monkeypatch) # override defaults False
data = _request_data(metadata={"alice_wonderfence_app_id": "from-req"})
with pytest.raises(WonderFenceMissingSecrets, match="alice_wonderfence_app_id"):
guardrail._resolve_app_id(data)
def test_resolve_app_id_from_key_metadata(monkeypatch):
guardrail, _ = _make_guardrail(monkeypatch)
data = _request_data(
@ -93,8 +116,10 @@ def test_resolve_app_id_from_team_metadata(monkeypatch):
assert guardrail._resolve_app_id(data) == "from-team"
def test_resolve_app_id_priority_request_over_key_over_team(monkeypatch):
guardrail, _ = _make_guardrail(monkeypatch)
def test_resolve_app_id_key_beats_request_even_when_override_enabled(monkeypatch):
"""With the override flag on, request metadata is still only a last-resort
source — admin-pinned key metadata wins."""
guardrail, _ = _make_guardrail(monkeypatch, allow_request_metadata_override=True)
data = _request_data(
metadata={
"alice_wonderfence_app_id": "from-req",
@ -102,7 +127,19 @@ def test_resolve_app_id_priority_request_over_key_over_team(monkeypatch):
"user_api_key_team_metadata": {"alice_wonderfence_app_id": "from-team"},
}
)
assert guardrail._resolve_app_id(data) == "from-req"
assert guardrail._resolve_app_id(data) == "from-key"
def test_resolve_app_id_team_beats_request_when_override_enabled(monkeypatch):
"""Team metadata beats request metadata even with the override flag on."""
guardrail, _ = _make_guardrail(monkeypatch, allow_request_metadata_override=True)
data = _request_data(
metadata={
"alice_wonderfence_app_id": "from-req",
"user_api_key_team_metadata": {"alice_wonderfence_app_id": "from-team"},
}
)
assert guardrail._resolve_app_id(data) == "from-team"
def test_resolve_app_id_priority_key_over_team(monkeypatch):
@ -127,12 +164,37 @@ def test_resolve_app_id_missing_raises(monkeypatch):
guardrail._resolve_app_id(data)
def test_resolve_api_key_from_request_metadata(monkeypatch):
guardrail, _ = _make_guardrail(monkeypatch, api_key="default")
def test_resolve_api_key_from_request_metadata_requires_override_flag(monkeypatch):
guardrail, _ = _make_guardrail(
monkeypatch, api_key="default", allow_request_metadata_override=True
)
data = _request_data(metadata={"alice_wonderfence_api_key": "from-req"})
assert guardrail._resolve_api_key(data) == "from-req"
def test_resolve_api_key_request_metadata_ignored_when_override_disabled(monkeypatch):
"""With override off, a caller-supplied api_key must not be honored;
falls back to the configured default instead."""
guardrail, _ = _make_guardrail(monkeypatch, api_key="default")
data = _request_data(metadata={"alice_wonderfence_api_key": "from-req"})
assert guardrail._resolve_api_key(data) == "default"
def test_resolve_api_key_key_beats_request_even_when_override_enabled(monkeypatch):
"""Admin-pinned key metadata wins over request metadata even with the
override flag enabled."""
guardrail, _ = _make_guardrail(
monkeypatch, api_key="default", allow_request_metadata_override=True
)
data = _request_data(
metadata={
"alice_wonderfence_api_key": "from-req",
"user_api_key_metadata": {"alice_wonderfence_api_key": "from-key"},
}
)
assert guardrail._resolve_api_key(data) == "from-key"
def test_resolve_api_key_from_key_metadata(monkeypatch):
guardrail, _ = _make_guardrail(monkeypatch, api_key="default")
data = _request_data(
@ -172,10 +234,15 @@ def test_resolve_api_key_missing_everywhere_raises(monkeypatch):
def test_resolve_reads_litellm_metadata_when_metadata_absent(monkeypatch):
"""``_get_metadata`` falls back to ``litellm_metadata`` when ``metadata``
is missing. Use admin-controlled key metadata so it resolves without
needing the request-override flag."""
guardrail, _ = _make_guardrail(monkeypatch)
data = {
"model": "gpt-4",
"litellm_metadata": {"alice_wonderfence_app_id": "from-litellm-md"},
"litellm_metadata": {
"user_api_key_metadata": {"alice_wonderfence_app_id": "from-litellm-md"}
},
}
assert guardrail._resolve_app_id(data) == "from-litellm-md"
@ -487,7 +554,9 @@ async def test_apply_guardrail_passes_app_id_per_call(guardrail_and_client):
await guardrail.apply_guardrail(
inputs={"texts": ["hi"]},
request_data=_request_data(metadata={"alice_wonderfence_app_id": "tenant-A"}),
request_data=_request_data(
metadata={"user_api_key_metadata": {"alice_wonderfence_app_id": "tenant-A"}}
),
input_type="request",
)
kwargs = client.evaluate_prompt.call_args.kwargs
@ -508,7 +577,9 @@ async def test_apply_guardrail_response_path_passes_app_id(monkeypatch):
await guardrail.apply_guardrail(
inputs={"texts": ["resp"]},
request_data=_request_data(metadata={"alice_wonderfence_app_id": "tenant-B"}),
request_data=_request_data(
metadata={"user_api_key_metadata": {"alice_wonderfence_app_id": "tenant-B"}}
),
input_type="response",
)
kwargs = client.evaluate_response.call_args.kwargs
@ -660,7 +731,9 @@ async def test_post_call_recovers_app_id_via_logging_obj_stash(monkeypatch):
"""Reproduces the framework gap: request body metadata is dropped before
post_call. The logging_obj stash from the prior `input_type="request"`
call must be used to resolve app_id."""
guardrail, client = _make_guardrail(monkeypatch)
guardrail, client = _make_guardrail(
monkeypatch, allow_request_metadata_override=True
)
guardrail._client_cache["default-api-key"] = client
request_obj = Mock()
request_obj.action = "NO_ACTION"
@ -702,7 +775,9 @@ async def test_post_call_recovers_app_id_via_logging_obj_stash(monkeypatch):
async def test_post_call_prefers_request_data_over_stash(monkeypatch):
"""If post_call's request_data still resolves (e.g. app_id from key/team
metadata), use it — don't fall back to the stash."""
guardrail, client = _make_guardrail(monkeypatch)
guardrail, client = _make_guardrail(
monkeypatch, allow_request_metadata_override=True
)
guardrail._client_cache["default-api-key"] = client
request_obj = Mock()
request_obj.action = "NO_ACTION"
@ -770,9 +845,17 @@ async def test_post_call_recovers_via_sibling_stash(monkeypatch):
`guardrails` array, LiteLLM only invokes one's during_call — but every
instance runs post_call. The instance whose during_call did NOT fire
must recover the stash written by the sibling that did."""
g_writer, c_writer = _make_guardrail(monkeypatch, guardrail_name="writer")
g_writer, c_writer = _make_guardrail(
monkeypatch,
guardrail_name="writer",
allow_request_metadata_override=True,
)
g_writer._client_cache["default-api-key"] = c_writer
g_reader, c_reader = _make_guardrail(monkeypatch, guardrail_name="reader")
g_reader, c_reader = _make_guardrail(
monkeypatch,
guardrail_name="reader",
allow_request_metadata_override=True,
)
g_reader._client_cache["default-api-key"] = c_reader
for c in (c_writer, c_reader):
result = Mock()
@ -807,9 +890,17 @@ async def test_post_call_recovers_via_sibling_stash(monkeypatch):
async def test_stash_keyed_per_guardrail_name(monkeypatch):
"""Two alice_wonderfence instances on the same logging_obj must not
overwrite each other's stash — they're keyed by guardrail_name."""
g1, c1 = _make_guardrail(monkeypatch, guardrail_name="alice-a")
g1, c1 = _make_guardrail(
monkeypatch,
guardrail_name="alice-a",
allow_request_metadata_override=True,
)
g1._client_cache["default-api-key"] = c1
g2, c2 = _make_guardrail(monkeypatch, guardrail_name="alice-b")
g2, c2 = _make_guardrail(
monkeypatch,
guardrail_name="alice-b",
allow_request_metadata_override=True,
)
g2._client_cache["default-api-key"] = c2
for c in (c1, c2):
result = Mock()
@ -898,6 +989,7 @@ def test_initialize_guardrail_forwards_all_params(monkeypatch):
debug=True,
max_cached_clients=5,
connection_pool_limit=20,
allow_request_metadata_override=True,
default_on=True,
)
guardrail = {"guardrail_name": "wf-init-test"}
@ -912,6 +1004,14 @@ def test_initialize_guardrail_forwards_all_params(monkeypatch):
assert g.block_message == "custom block"
assert g._client_cache_maxsize == 5
assert g._connection_pool_limit == 20
assert g.allow_request_metadata_override is True
def test_allow_request_metadata_override_defaults_false(monkeypatch):
"""New flag must default to False so request-body metadata cannot
bypass admin-pinned credentials out of the box."""
guardrail, _ = _make_guardrail(monkeypatch)
assert guardrail.allow_request_metadata_override is False
def test_initialize_guardrail_missing_name_raises(monkeypatch):