Product: SciPhi-AI/R2R Component: POST /v3/retrieval/completion and POST /v3/retrieval/agent ( generation_config.api_base + generation_config.model ) Type: CWE-918 Server-Side Request Forgery (SSRF); CWE-201 Insertion of Sensitive Information Into Sent Data Affected: R2R 3.6.6 ( pyproject.toml ), Git main at 9c5a94d , Docker image sciphiai/r2r:latest (default completion.provider = "r2r" ) Tested: 2026-08-25 against a default docker compose deployment (unauthenticated; dummy OPENAI_API_KEY ) CVSS 3.1: 8.6 High — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N CVSS 4.0: 9.2 Critical — CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N
File this as a private GitHub Security Advisory (or the project's confidential vulnerability issue), not as a public gist, until a patch is released.
File this as a private GitHub Security Advisory (or the project's confidential vulnerability issue), not as a public gist, until a patch is released.
R2R accepts a client-controlled generation_config.api_base on the public completion and agent APIs. When the model string is routed to LiteLLM (any prefix other than openai/ , azure/ , deepseek/ , ollama/ , lmstudio/ , anthropic/ , azure-foundry/ , or an unprefixed name), LiteLLM issues an HTTP request to that api_base .
On a default install the caller is unauthenticated. I pointed api_base at a listener the R2R container can reach ( or /v1 ). The container POSTed /v1/chat/completions with User-Agent: litellm/… .
Whether the server also attaches OPENAI_API_KEY depends on the LiteLLM provider selected by the model prefix (see “Key forwarding” below). With custom/gpt-4o-mini and together_ai/gpt-2 , the listener received Authorization: Bearer . With huggingface/gpt2 , the same SSRF fired without that header.
openai/… models do not take this path: OpenAICompletionProvider talks to api.openai.com and ignores api_base . The SSRF is the LiteLLM fallback, which the client selects by choosing the model prefix.
GenerationConfig exposes api_base on the wire ( shared/abstractions/llm.py ). POST /v3/retrieval/completion takes generation_config: GenerationConfig = Body(...) .
R2RCompletionProvider._choose_subprovider_by_model() :
Default r2r.toml has require_authentication = false , so no login is required. The same generation_config is accepted on /v3/retrieval/agent .
LiteLLM does not always send OPENAI_API_KEY . It picks an API key from the provider implied by the model prefix ( HUGGINGFACE_API_KEY , TOGETHER_API_KEY , …). Prefixes that LiteLLM treats as OpenAI-compatible (including custom/ and, in this build, together_ai/ when talking to a custom api_base ) copy OPENAI_API_KEY into Authorization: Bearer … on the outbound request. huggingface/ does not.
Default R2R at .
An HTTP listener the R2R container can reach (Compose service mock-llm:8080 , or any collaborator/OAST).
OPENAI_API_KEY set in the R2R process (Compose dummy key is enough to prove the header).
No Authorization header to R2R.
1. Negative control ( openai/ prefix — not this bug)
Actual: HTTP 500 from api.openai.com ( invalid_api_key ). The mock receives no request. OpenAICompletionProvider ignores api_base .
2. SSRF without key (LiteLLM huggingface/ prefix)
This proves the server-side fetch. It does not attach OPENAI_API_KEY .
Use if the listener is the Compose mock-llm service.
Actual: HTTP 200. Listener:
No Authorization header. huggingface/ is still SSRF ( User-Agent: litellm/… is enough).
3. SSRF + OPENAI_API_KEY forward (LiteLLM OpenAI-compatible prefix)
R2R must have OPENAI_API_KEY in its environment (official verify Compose uses sk-r2r-verify-dummy-key ; a real key is worse). Change only model :
together_ai/gpt-2 produced the same header in the lab.
Actual: HTTP 200. Listener now includes the process secret:
Lab log line (Compose mock): authorization_prefix: "Bearer sk-r2r-verify-dum" .
If the header is still missing, the R2R container has an empty OPENAI_API_KEY ( docker exec printenv OPENAI_API_KEY ).
4. Same sink on the agent
The listener again received /v1/chat/completions from the R2R container. Use custom/gpt-4o-mini if the goal is key forwarding, not huggingface/gpt2 .
Replace api_base with an OAST URL when reproducing without Docker DNS ( host.docker.internal / mock-llm ).
Key forwarding (why huggingface/ shows no API key)
SSRF and secret theft are the same parameter but two LiteLLM behaviors :
SSRF — any LiteLLM-routed prefix + attacker api_base → R2R opens an HTTP connection ( User-Agent: litellm/… ). Confirmed with huggingface/gpt2 (no Authorization ).
Secret theft — LiteLLM also attaches a provider key. For OpenAI-compatible custom bases ( custom/… , and together_ai/… against a custom api_base in this version) that key is os.environ["OPENAI_API_KEY"] , sent as Authorization: Bearer … . Confirmed with custom/gpt-4o-mini and together_ai/gpt-2 .
huggingface/ looks up HUGGINGFACE_API_KEY (unset in the default image), so the outbound POST has no Bearer header. That does not mean the key is safe: the client chooses the prefix. Switching model from huggingface/gpt2 to custom/gpt-4o-mini on the same api_base is enough to exfiltrate OPENAI_API_KEY .
An unauthenticated client (default config) makes the R2R process send HTTP to an arbitrary URL:
Attacker listener, without a key: huggingface/gpt2 (SSRF only).
Attacker listener, with OPENAI_API_KEY in Authorization : custom/gpt-4o-mini / together_ai/gpt-2 (demonstrated).
Internal Compose/K8s HTTP services on the same network ( unstructured , graph_clustering , hatchet-engine ; cloud metadata is the same parameter).
I did not demonstrate IMDSv1/v2 retrieval. Confirmed impact is server-side fetch , and exfiltration of OPENAI_API_KEY when the model prefix causes LiteLLM to treat the custom base as OpenAI-compatible .
openai/ models are a different code path and are not this CVE.
Do not accept api_base (or provider API keys) from the client. Take them only from server config / environment.
If a custom base URL is required, allowlist scheme + host, and never attach the production OPENAI_API_KEY to a non-allowlisted host.
Default require_authentication = true .
py/shared/abstractions/llm.py ( GenerationConfig.api_base )
py/core/providers/llm/r2r_llm.py ( _choose_subprovider_by_model )
py/core/providers/llm/litellm.py ( _get_base_args )
py/core/providers/llm/openai.py ( OpenAI() with no base_url from generation_config )
py/core/main/api/v3/retrieval_router.py ( completion , agent_app )
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
