← All vulnerabilities
CRITICALSupply chain

GHSA-v2f8-6655-7grj

PyPI · vibe-trading-ai

Summary

Vibe-Trading FastAPI endpoints permit unauthenticated access, file upload, and an RCE chain

Advisory 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).


Shared baseline (applies to all 5 findings)

The shipped agent/.env.example line 112 ships # API_AUTH_KEY= commented out. require_auth() at agent/api_server.py line 303 executes if not api_key: return and returns None immediately when API_AUTH_KEY is unset, so every endpoint decorated with dependencies=[Depends(require_auth)] operates as unauthenticated. The shipped Dockerfile does not contain a USER directive, so the FastAPI process runs as uid=0(root) inside the container (verified: docker exec id returns uid=0(root) gid=0(root)). The docker-compose.yml binds 0.0.0.0:8899 with no network restriction.

The only operator action required beyond a clean install is supplying a working LLM API key so the agent loop can complete its tool-call round trip — this is the normal first step to make the agent functional, not an additional security opt-in. F-A4 and F-A5 do not require an LLM key (see per-finding notes); F1 and F3 do not require an LLM key for the unauth surface itself, only for the chained RCE demonstration in F1.

Reproducer environment (common)

git clone https://github.com/HKUDS/Vibe-Trading.git
cd Vibe-Trading
git checkout 7452610113a75529b5d55fd2217bb17f7bec66f7   # v0.1.6 + 1 frontend fix; same vuln state as v0.1.6
cp agent/.env.example agent/.env
# (For F1 chained demo only:) edit agent/.env to set OPENROUTER_API_KEY=<real key>
docker compose up -d
# port 8899 is now reachable; HOST below is the docker host's IP from the attacker's perspective

Note on the HOST placeholder used throughout the per-finding "Steps to observe" blocks below: replace HOST with the address you reach the docker host on — typically localhost (or 127.0.0.1) if you are running the reproducer on the same machine as the container. All curl commands below assume this substitution.


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: return early return in require_auth()
  • agent/api_server.py:736 — @app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)]) (the dependency is a no-op when API_AUTH_KEY is unset)
  • agent/src/tools/bash_tool.py:44-46 — subprocess.run(command, shell=True, cwd=cwd) with command read from kwargs['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:

  1. Start the server per the shared reproducer above (with a working OPENROUTER_API_KEY set in agent/.env). Confirm curl -fs http://HOST:8899/health returns 200.
  2. 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'])")
  3. 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."}'
  4. Wait ~5–15 seconds, then curl -s "http://HOST:8899/sessions/$SID/messages" and observe the BashTool result message containing uid=0(root), the kernel version, and the container hostname — all returned with no Authorization header 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 2 — High: Read endpoints return full session history with no authentication, even when API_AUTH_KEY is set

  • Severity: High
  • CVSS v3.1: 7.5 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
  • CVSS v4.0: 8.7 — AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
  • CWE: CWE-862 (Missing Authorization)

Affected file: agent/api_server.py — require_auth() docstring at line 289 states "Only write endpoints (POST/PUT/DELETE/PATCH) use this dependency." Read endpoints with no Depends(require_auth):

  • line 804 — @app.get("/runs", response_model=List[RunInfo])
  • line 788 — @app.get("/runs/{run_id}", response_model=RunResponse)
  • line 748 — @app.get("/runs/{run_id}/code")
  • line 769 — @app.get("/runs/{run_id}/pine")
  • line 1153 — @app.get("/sessions", response_model=List[SessionResponse])
  • line 1173 — @app.get("/sessions/{session_id}", response_model=SessionResponse)
  • line 1251 — @app.get("/sessions/{session_id}/messages", response_model=List[MessageResponse])
  • line 1272 — @app.get("/sessions/{session_id}/events")
  • line 1444 — @app.get("/swarm/runs")

Intent vs actual: When API_AUTH_KEY is configured, the implicit operator expectation is that all session data is protected. The actual design is documented in the docstring at line 289 — read endpoints have no Depends(require_auth), so they remain unauthenticated even with API_AUTH_KEY set. A runtime probe with API_AUTH_KEY=any-secret-value configured: a session was created with a valid Bearer token, a message containing BROKER_TOKEN=ts-secret-deadbeef-real-private-data was posted, then GET /sessions/{id}/messages was issued with no Authorization header and returned HTTP 200 with the broker token string verbatim in the response — confirming the gap persists when authentication is enabled.

Steps to observe:

  1. Set API_AUTH_KEY=any-secret-value in agent/.env. Under docker compose, add a bind-mount on the vibe-trading service so the container reads the change: volumes: - ./agent/.env:/app/agent/.env:ro. Then docker compose up -d --force-recreate (a plain restart reuses the existing process env and will not pick up the change).
  2. SID=$(curl -s -X POST http://HOST:8899/sessions -H 'Authorization: Bearer any-secret-value' -H 'Content-Type: application/json' -d '{}' | python3 -c "import json,sys;print(json.load(sys.stdin)['session_id'])")
  3. curl -s -X POST "http://HOST:8899/sessions/$SID/messages" -H 'Authorization: Bearer any-secret-value' -H 'Content-Type: application/json' -d '{"content":"BROKER_TOKEN=ts-secret-test-value"}' (this requires the Bearer token because POST is auth-protected)
  4. No-auth read — curl -s "http://HOST:8899/sessions/$SID/messages" (no Authorization header). Observe HTTP 200 with the broker token visible.
  5. Also: curl -s "http://HOST:8899/runs" returns all run records (including their prompt fields) with no auth.

Impact: An unauthenticated caller can enumerate the full history of every agent session — including any broke

References