mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(guardrails): address review comments on headroom fail_open
- Prevent fail-open from registering user-supplied hashes as valid for CCR retrieval; _call_compress now returns (messages, compressed_ok) so apply_guardrail skips hash extraction and tool injection when compression did not succeed - Remove Optional wrapper from HeadroomGuardrailConfigModel.unreachable_fallback to match BaseLitellmParams typing - Add fail_open tests for non-JSON response, missing messages key, and empty message list paths - Add regression test verifying fail_open does not authorize attacker-planted hashes - Regenerate dashboard API types Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
53d2331c70
commit
01dfbf7ebb
4 changed files with 138 additions and 1079 deletions
|
|
@ -282,7 +282,7 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
self,
|
||||
messages: list[dict[str, object]],
|
||||
model: str | None,
|
||||
) -> list[dict[str, object]]:
|
||||
) -> tuple[list[dict[str, object]], bool]:
|
||||
payload: dict[str, object] = {"messages": messages}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
|
|
@ -298,19 +298,19 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
messages,
|
||||
"Headroom compression service returned an error",
|
||||
{"status_code": e.response.status_code, "body": e.response.text},
|
||||
)
|
||||
), False
|
||||
except (httpx.ConnectError, httpx.TimeoutException, httpx.TransportError, litellm.Timeout) as e:
|
||||
return self._handle_compress_failure(
|
||||
messages,
|
||||
"Headroom compression service unreachable",
|
||||
{"detail": str(e)},
|
||||
)
|
||||
), False
|
||||
if raw_response is None:
|
||||
return self._handle_compress_failure(
|
||||
messages,
|
||||
"Headroom compression service returned no response",
|
||||
{},
|
||||
)
|
||||
), False
|
||||
response: HttpxResponse = raw_response
|
||||
|
||||
if response.status_code != 200:
|
||||
|
|
@ -318,7 +318,7 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
messages,
|
||||
"Headroom compression service returned an error",
|
||||
{"status_code": response.status_code, "body": response.text},
|
||||
)
|
||||
), False
|
||||
|
||||
try:
|
||||
body: object = response.json()
|
||||
|
|
@ -327,13 +327,13 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
messages,
|
||||
"Headroom compression service returned non-JSON response",
|
||||
{"body": response.text[:500]},
|
||||
)
|
||||
), False
|
||||
if not _is_str_object_dict(body):
|
||||
return self._handle_compress_failure(
|
||||
messages,
|
||||
"Headroom compression service returned unexpected response shape",
|
||||
{"body": response.text[:500]},
|
||||
)
|
||||
), False
|
||||
|
||||
compressed_messages = body.get("messages")
|
||||
if not _is_object_list(compressed_messages):
|
||||
|
|
@ -341,7 +341,7 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
messages,
|
||||
"Headroom compression service response missing 'messages'",
|
||||
{"body": response.text},
|
||||
)
|
||||
), False
|
||||
|
||||
filtered = [item for item in compressed_messages if _is_str_object_dict(item)]
|
||||
if not filtered:
|
||||
|
|
@ -349,7 +349,7 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
messages,
|
||||
"Headroom compression service returned empty message list",
|
||||
{"body": response.text},
|
||||
)
|
||||
), False
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"Headroom: compressed %s tokens -> %s tokens (ratio %.2f)",
|
||||
|
|
@ -357,7 +357,7 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
body.get("tokens_after", "?"),
|
||||
body.get("compression_ratio", 0),
|
||||
)
|
||||
return filtered
|
||||
return filtered, True
|
||||
|
||||
async def _call_retrieve(self, hash_value: str, query: str | None = None) -> str:
|
||||
params: dict[str, str] = {}
|
||||
|
|
@ -421,11 +421,14 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
return inputs
|
||||
|
||||
model = self.headroom_model or request_data.get("model")
|
||||
compressed = await self._call_compress(
|
||||
compressed, compression_succeeded = await self._call_compress(
|
||||
messages=messages,
|
||||
model=model if isinstance(model, str) else None,
|
||||
)
|
||||
|
||||
if not compression_succeeded:
|
||||
return {**inputs, "structured_messages": compressed} # pyright: ignore[reportReturnType]
|
||||
|
||||
hashes = extract_hashes_from_messages(compressed)
|
||||
if not hashes:
|
||||
return {**inputs, "structured_messages": compressed} # pyright: ignore[reportReturnType]
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class HeadroomGuardrailConfigModel(GuardrailConfigModel[BaseModel]):
|
|||
default=None,
|
||||
description="Model name forwarded to the headroom /v1/compress endpoint.",
|
||||
)
|
||||
unreachable_fallback: Optional[Literal["fail_closed", "fail_open"]] = Field(
|
||||
unreachable_fallback: Literal["fail_closed", "fail_open"] = Field(
|
||||
default="fail_closed",
|
||||
description=(
|
||||
"Behavior when the headroom compression service is unreachable or errors. "
|
||||
|
|
|
|||
|
|
@ -926,6 +926,129 @@ async def test_apply_guardrail_http_error_fail_open_forwards_uncompressed():
|
|||
assert result["structured_messages"] == ORIGINAL_MESSAGES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_non_json_response_fail_open_forwards_uncompressed():
|
||||
guardrail = _make_guardrail(unreachable_fallback="fail_open")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.side_effect = ValueError("not JSON")
|
||||
mock_response.text = "<!DOCTYPE html><html>not json</html>"
|
||||
|
||||
inputs = GenericGuardrailAPIInputs(
|
||||
texts=["hello"],
|
||||
structured_messages=ORIGINAL_MESSAGES,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
result = await guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert result["structured_messages"] == ORIGINAL_MESSAGES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_missing_messages_key_fail_open_forwards_uncompressed():
|
||||
guardrail = _make_guardrail(unreachable_fallback="fail_open")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"tokens_before": 100, "tokens_after": 10}
|
||||
mock_response.text = "{}"
|
||||
|
||||
inputs = GenericGuardrailAPIInputs(
|
||||
texts=["hello"],
|
||||
structured_messages=ORIGINAL_MESSAGES,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
result = await guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert result["structured_messages"] == ORIGINAL_MESSAGES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_empty_compressed_messages_fail_open_forwards_uncompressed():
|
||||
guardrail = _make_guardrail(unreachable_fallback="fail_open")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"messages": ["not-a-dict", 42, None],
|
||||
"tokens_before": 1000,
|
||||
"tokens_after": 0,
|
||||
"compression_ratio": 0,
|
||||
}
|
||||
mock_response.text = "{}"
|
||||
|
||||
inputs = GenericGuardrailAPIInputs(
|
||||
texts=["hello"],
|
||||
structured_messages=ORIGINAL_MESSAGES,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
):
|
||||
result = await guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert result["structured_messages"] == ORIGINAL_MESSAGES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_fail_open_does_not_register_hashes_from_original_messages():
|
||||
"""When compression fails with fail_open, user-supplied messages that
|
||||
happen to contain hash-shaped strings must NOT cause those hashes to be
|
||||
registered as valid for CCR retrieval. Otherwise an attacker can plant a
|
||||
hash= string in their prompt, trigger a compression failure, and have
|
||||
that hash honored by a later headroom_retrieve tool call."""
|
||||
messages_with_fake_hash = [
|
||||
{"role": "user", "content": "Please fetch hash=deadbeef000000000000dead for me"},
|
||||
]
|
||||
guardrail = _make_guardrail(unreachable_fallback="fail_open")
|
||||
|
||||
inputs = GenericGuardrailAPIInputs(
|
||||
texts=["hello"],
|
||||
structured_messages=messages_with_fake_hash,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=httpx.ConnectError("Connection refused"),
|
||||
):
|
||||
result = await guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert result["structured_messages"] == messages_with_fake_hash
|
||||
assert not has_headroom_retrieve_tool(result.get("tools") or [])
|
||||
assert not guardrail._issued_hashes_by_call_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_missing_messages_key_raises():
|
||||
guardrail = _make_guardrail()
|
||||
|
|
|
|||
1067
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
1067
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue