Vulnerability GHSA-v2f8-6655-7grj
Summary
Vibe-Trading FastAPI endpoints permit unauthenticated access, file upload, and an RCE chain
Details
Summary:
5 findings — unauthenticated full-API exposure (F1, lead Critical), read-side authorization gap that persists even with API_AUTH_KEY set (F2), unauthenticated file write of .py/.sh/.yaml to a server-returned path (F3), default-permissive CORS that combines with a loopback-only check to grant any browser page on whitelisted localhost ports credentialed cross-origin access (F-A4), and partial API-key disclosure via _mask_secret() (F-A5).
Finding 1 — Critical: Unauthenticated network client reaches shell execution via POST /sessions/{id}/messages
- Severity: Critical
- CVSS v3.1: 9.8 —
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H - CVSS v4.0: 10.0 —
AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H - CWE: CWE-306 (Missing Authentication for Critical Function); chains into CWE-78 (Group B, F6)
Affected files:
agent/api_server.py:303—if not api_key: returnearly return inrequire_auth()agent/api_server.py:736—@app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)])(the dependency is a no-op whenAPI_AUTH_KEYis unset)agent/src/tools/bash_tool.py:44-46—subprocess.run(command, shell=True, cwd=cwd)with command read fromkwargs['command'](full bug detail tracked in GHSA-2 / Group B / F6)
Intent vs actual: The session API is intended to serve authenticated users only. When API_AUTH_KEY is unset, require_auth() returns None immediately and dependencies=[Depends(require_auth)] becomes a no-op. Any anonymous TCP client to port 8899 can therefore create a session, post a message, and receive the LLM agent's response. The LLM ReAct agent — given a natural-language request to run a command — selects BashTool from the auto-discovered registry, which calls subprocess.run(command, shell=True) with the LLM-emitted string. The container has no USER directive, so the resulting process runs as uid=0(root).
Steps to observe:
- Start the server per the shared reproducer above (with a working
OPENROUTER_API_KEYset inagent/.env). Confirmcurl -fs http://HOST:8899/healthreturns 200. SID=$(curl -s -X POST http://HOST:8899/sessions -H 'Content-Type: application/json' -d '{}' | python3 -c "import json,sys;print(json.load(sys.stdin)['session_id'])")curl -s -X POST "http://HOST:8899/sessions/$SID/messages" -H 'Content-Type: application/json' -d '{"content":"Execute the shell command '\''id && uname -a'\'' and report the output verbatim."}'- Wait ~5–15 seconds, then
curl -s "http://HOST:8899/sessions/$SID/messages"and observe the BashTool result message containinguid=0(root), the kernel version, and the container hostname — all returned with noAuthorizationheader on any of the three requests.
Impact: An unauthenticated caller with TCP access to port 8899 can execute arbitrary shell commands as root inside the container. This is the top-severity entry point of the RCE chain. Combined with the absent USER directive in the Dockerfile, the blast radius is full container takeover.
Finding 3 — High: Unauthenticated POST /upload writes arbitrary .py/.sh/.yaml files to server filesystem with full path returned
- Severity: High
- CVSS v3.1: 8.1 —
AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:N - CVSS v4.0: 8.7 —
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:H/SI:H/SA:N - CWE: CWE-434 (Unrestricted Upload of File with Dangerous Type)
Affected file:
agent/api_server.py:1310-1321—_BLOCKED_UPLOAD_EXTset rejects.exe,.msi,.bat,.cmd,.com,.scr,.app,.dmg,.so,.dll,.dylib,.zip,.rar,.7z,.tar,.gz,.tgz,.bz2,.xz— but not.py,.sh,.yaml,.j2,.json,.html, orDockerfileagent/api_server.py:1347—@app.post("/upload", dependencies=[Depends(require_auth)])(no-op whenAPI_AUTH_KEYunset)
Intent vs actual: POST /upload is intended as an authenticated file-staging mechanism. In default config the auth dependency is a no-op (per F1). The extension blocklist is a denylist not an allowlist, so dangerous executable-adjacent types pass through. The uploaded file is saved as <uuid>.<ext> and the full resolved server path is returned in the response body under "file_path", so the attacker does not need to guess paths.
Steps to observe (no LLM key required):
- Start the server in default config (no
API_AUTH_KEY). curl -s -X POST http://HOST:8899/upload -F 'file=@/dev/stdin;filename=payload.py' <<< 'print("ATTACKER_CONTROLLED")'- Observe HTTP 200 with JSON containing
"status":"ok"and"file_path":"/app/agent/uploads/<uuid>.py". - Repeat with
filename=config.yamlandfilename=run.shto confirm multiple executable-adjacent types pass.
Impact: Any unauthenticated caller can write arbitrary Python scripts, shell scripts, or YAML configuration to a known, server-returned path. F3 is independent of any LLM API key — it is the cleanest unauth-write primitive in the codebase. The uploaded file is reachable from the LLM agent's tool envelope and chains into Group B / F8 (backtest exec_module premature exec) for a non-bash RCE path.
Finding A5 — Medium: Settings endpoints expose first-4 + last-4 characters of every configured API key via _mask_secret()
- Severity: Medium
- CVSS v3.1: 5.3 —
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N - CVSS v4.0: 6.9 —
AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N - CWE: CWE-200 (Exposure of Sensitive Information)
Affected files:
agent/api_server.py:467-474—_mask_secret()returnsf"{value[:4]}...{value[-4:]}"for any string longer than 8 charactersagent/api_server.py:372—LLM_API_KEY_PLACEHOLDERSfilters out shipped placeholders so the leak fires only when a real key is configuredagent/api_server.py:505-522—GET /settings/llmreturnsapi_key_hint = _mask_secret(api_key) if api_key_configured else Noneagent/api_server.py:561—GET /settings/data-sourcesreturnstushare_token_hint=_mask_secret(token) if token_configured else Noneagent/test_settings_api.py:106— assertionapi_key_hint == "or-s...alue"for inputor-secret-valueconfirms the 4+4 reveal is the intended API contract
Intent vs actual: The frontend needs to confirm to the operator that an API key is configured, so the appropriate hint is a boolean presence indicator or a fixed placeholder. The actual implementation reveals the first 4 and last 4 characters. For structured provider keys with predictable prefixes (sk-or-v1-, gsk_, xoxb-, ts-), the leading 4 bytes are largely fixed and the entropy leak is concentrated in the trailing 4 — which can be material for tokens with bounded total entropy (e.g. Tushare tokens). A runtime probe configured OPENROUTER_API_KEY=sk-or-v1-AbCdEfGhXyZ12345fakekey and TUSHARE_TOKEN=ts-real-shaped-token-1234567890ab and observed api_key_hint='sk-o...ekey' and tushare_token_hint='ts-r...90ab' from the corresponding GET endpoints.
Steps to observe (no LLM key required other than the configured value being non-placeholder; the call itself does not consume the LLM):
- Edit
agent/.envto replace the placeholder with a real-shaped key, e.g.OPENROUTER_API_KEY=sk-or-v1-TestKeyAbcde12345. Restart the server. - From any loopback client or via the F-A4 cross-origin path,
curl -s "http://127.0.0.1:8899/settings/llm"(noAuthorizationheader). - Observe HTTP 200 with
api_key_configured=trueandapi_key_hintcontaining the first 4 and last 4 characters of the real key. curl -s "http://127.0.0.1:8899/settings/data-sources"to observetushare_token_hintfollow the same pattern.
Impact: Every functional deployment leaks structured bytes of the configured API keys. Combined with offline guessing against bounded-entropy provider keys (notably Tushare tokens), the partial reveal can narrow the attack space to a tractable bruteforce window. The leak is reachable from the F-A4 cross-origin browser path, so it does not require even loopback TCP access — only an operator who visits a malicious local web page in a browser running on the same host as the agent.
Related Vulnerabilities
Other vulnerabilities affecting the same packages