fix: block SSRF fields in RAG ingest vector_store config

aws_sts_endpoint, aws_web_identity_token, and aws_bedrock_runtime_endpoint
in ingest_options.vector_store were passed directly to the Bedrock ingestion
class, which reads them into boto3 STS client construction. Any authenticated
caller could redirect AssumeRole calls to an attacker-controlled server,
leaking the proxy's instance profile credentials.

Calls is_request_body_safe() on ingest_options["vector_store"] before
forwarding to litellm.aingest(). Same banned-params list and admin opt-in
escape hatch (allow_client_side_credentials) as the /chat/completions path.
ValueError from the safety check is caught and re-raised as HTTP 400.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-05-13 18:10:04 -07:00
parent b95130eb32
commit f1d07c13e5
2 changed files with 74 additions and 0 deletions

View File

@ -23,6 +23,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
_safe_get_request_headers,
get_form_data,
)
from litellm.proxy.auth.auth_utils import is_request_body_safe
from litellm.proxy.vector_store_endpoints.utils import (
assert_user_can_access_vector_store_id,
)
@ -469,6 +470,16 @@ async def rag_ingest(
user_api_key_dict=user_api_key_dict,
)
try:
is_request_body_safe(
request_body=ingest_options.get("vector_store", {}),
general_settings=general_settings,
llm_router=llm_router,
model="",
)
except ValueError as e:
raise HTTPException(status_code=400, detail={"error": str(e)})
# Add litellm data
request_data: Dict[str, Any] = {}
request_data = await add_litellm_data_to_request(

View File

@ -128,3 +128,66 @@ def test_internal_user_rag_ingest_without_vector_store_id_allowed(client_interna
f"internal_user should be allowed to create new vector stores. "
f"Response: {response.json()}"
)
class TestRagIngestSSRFBlocked:
"""
aws_sts_endpoint and related credential-redirect fields must be rejected
in ingest_options.vector_store. Without this guard, any authenticated
client can coerce the proxy to make a signed STS AssumeRole call to an
attacker-controlled server, leaking the instance profile credentials.
"""
@pytest.mark.parametrize(
"field,value",
[
("aws_sts_endpoint", "https://attacker.example/sts"),
("aws_web_identity_token", "fake-token"),
("aws_bedrock_runtime_endpoint", "https://attacker.example/bedrock"),
],
)
def test_ssrf_field_in_vector_store_config_rejected(
self, field, value, client_internal_user
):
payload = {
"file_url": "https://example.com/doc.pdf",
"ingest_options": {
"vector_store": {
"custom_llm_provider": "bedrock",
field: value,
}
},
}
response = client_internal_user.post(
"/v1/rag/ingest",
json=payload,
)
assert response.status_code == 400, (
f"{field} in ingest_options.vector_store should be rejected (400), "
f"got {response.status_code}: {response.json()}"
)
body = response.json()
detail = body.get("detail", {})
error_text = (
detail.get("error", "") if isinstance(detail, dict) else str(detail)
)
assert field in error_text, f"Error should name the offending field: {error_text}"
def test_clean_bedrock_ingest_options_not_rejected(self, client_internal_user):
with patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new_callable=AsyncMock,
return_value={"vector_store_id": "vs_bedrock", "file_id": "file_123"},
):
response = client_internal_user.post(
"/v1/rag/ingest",
json={
"file_url": "https://example.com/doc.pdf",
"ingest_options": {
"vector_store": {"custom_llm_provider": "bedrock"}
},
},
)
assert response.status_code != 400, (
f"Clean Bedrock ingest_options should not be rejected: {response.json()}"
)