Skip to content
6c1fd0ef98900fbbfa383faa20f731f6

6c1fd0ef98900fbbfa383faa20f731f6

gist.github.com • October 4, 2026

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 )

Extracted Entities