GHSA-v2f8-6655-7grj: Infoleak

Published Oct 2, 2026
·
Updated

Summary: 5 findings — unauthenticated full-API exposure (F1, lead Critical), read-side authorization gap that persists even with APIAUTHKEY 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 masksecret() (F-A5).

---

Shared baseline (applies to all 5 findings)

The shipped agent/.env.example line 112 ships # APIAUTHKEY= commented out. requireauth() at agent/apiserver.py line 303 executes if not apikey: return and returns None immediately when APIAUTHKEY is unset, so every endpoint decorated with dependencies=[Depends(requireauth)] 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)

sh 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 OPENROUTERAPIKEY=<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/apiserver.py:303 — if not apikey: return early return in requireauth() - agent/apiserver.py:736 — @app.post("/sessions/{sessionid}/messages", dependencies=[Depends(requireauth)]) (the dependency is a no-op when APIAUTHKEY is unset) - agent/src/tools/bashtool.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 APIAUTHKEY is unset, requireauth() returns None immediately and dependencies=[Depends(requireauth)] 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 OPENROUTERAPIKEY 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)['sessionid'])") 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 APIAUTHKEY 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/apiserver.py — requireauth() docstring at line 289 states "Only write endpoints (POST/PUT/DELETE/PATCH) use this dependency." Read endpoints with no Depends(requireauth): - line 804 — @app.get("/runs", responsemodel=List[RunInfo]) - line 788 — @app.get("/runs/{runid}", responsemodel=RunResponse) - line 748 — @app.get("/runs/{runid}/code") - line 769 — @app.get("/runs/{runid}/pine") - line 1153 — @app.get("/sessions", responsemodel=List[SessionResponse]) - line 1173 — @app.get("/sessions/{sessionid}", responsemodel=SessionResponse) - line 1251 — @app.get("/sessions/{sessionid}/messages", responsemodel=List[MessageResponse]) - line 1272 — @app.get("/sessions/{sessionid}/events") - line 1444 — @app.get("/swarm/runs")

Intent vs actual: When APIAUTHKEY 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(requireauth), so they remain unauthenticated even with APIAUTHKEY set. A runtime probe with APIAUTHKEY=any-secret-value configured: a session was created with a valid Bearer token, a message containing BROKERTOKEN=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 APIAUTHKEY=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)['sessionid'])") 3. curl -s -X POST "http://HOST:8899/sessions/$SID/messages" -H 'Authorization: Bearer any-secret-value' -H 'Content-Type: application/json' -d '{"content":"BROKERTOKEN=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 broker tokens, LLM API keys, or trading account details the operator has pasted into prompts. The gap persists when the operator believes their write operations are protected, making it deceptive for operators who have followed the SECURITY.md spirit and turned auth on.

---

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/apiserver.py:1310-1321 — BLOCKEDUPLOADEXT set 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, or Dockerfile - agent/apiserver.py:1347 — @app.post("/upload", dependencies=[Depends(requireauth)]) (no-op when APIAUTHKEY unset)

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 "filepath", so the attacker does not need to guess paths.

Steps to observe (no LLM key required):

1. Start the server in default config (no APIAUTHKEY). 2. curl -s -X POST http://HOST:8899/upload -F 'file=@/dev/stdin;filename=payload.py' <<< 'print("ATTACKERCONTROLLED")' 3. Observe HTTP 200 with JSON containing "status":"ok" and "filepath":"/app/agent/uploads/<uuid>.py". 4. Repeat with filename=config.yaml and filename=run.sh to 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 execmodule premature exec) for a non-bash RCE path.

---

Finding A4 — Medium: Default-permissive CORS allowlist + loopback-only check on /settings let any localhost-served browser page drive credentialed cross-origin requests

- Severity: Medium - CVSS v3.1: 7.7 — AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:N - CVSS v4.0: 6.3 — AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N - CWE: CWE-942 (Permissive Cross-domain Policy with Untrusted Domains); CWE-346 (Origin Validation Error)

Affected file: - agent/apiserver.py:252-264 — CORSORIGINS = os.getenv(...) defaults to a list of six localhost origins (http://localhost:3000, :5173, :8000; same for 127.0.0.1); CORSMiddleware is added with allowcredentials=True, allowmethods=[""], allowheaders=[""] - agent/apiserver.py:309-336 — islocalclient() and requirelocalorauth(): the loopback check examines request.client.host (TCP peer IP), which is 127.0.0.1 for any browser request from the same machine, regardless of the page's origin - agent/apiserver.py:908 — dependencies=[Depends(requirelocalorauth)] on /settings/llm and related endpoints (granted to any browser request from the host)

Intent vs actual: The CORS allowlist is intended to permit the bundled Vite/React frontend to make credentialed API calls during development. The intent is a development-convenience configuration. The actual configuration permits any local web page served from one of the six listed origins — which includes any other application, IDE preview pane, local web tool, or static HTML file served by another process listening on those ports — to issue credentialed cross-origin POSTs/GETs and read the responses in JavaScript. Because the loopback check at islocalclient() examines the TCP peer IP (always 127.0.0.1 for browser requests from the host), browser-driven cross-origin requests from a whitelisted origin also bypass the loopback restriction on /settings/, which would otherwise have been the only barrier.

Steps to observe (no LLM key required):

1. Start the server in default config. 2. Preflight: curl -s -X OPTIONS "http://HOST:8899/sessions" -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: POST" -i — observe the response includes access-control-allow-credentials: true, access-control-allow-origin: http://localhost:3000, and access-control-allow-methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT. 3. Confirm a real POST /sessions with Origin: http://localhost:3000 returns the same allow-credentials header in the response so a browser can read the body. 4. Preflight against a settings endpoint: curl -s -X OPTIONS "http://HOST:8899/settings/llm" -H "Origin: http://localhost:3000" -i. Then curl -s "http://HOST:8899/settings/llm" -H "Origin: http://localhost:3000" and observe the response is allowed because request.client.host = 127.0.0.1 satisfies islocalclient(). 5. Negative control: curl -s -X OPTIONS "http://HOST:8899/sessions" -H "Origin: http://evil.example.com" -i — observe no CORS headers, demonstrating the allowlist is functional for non-localhost origins.

Impact: Any malicious or compromised local web page on a whitelisted localhost port can silently drive the Vibe-Trading API in the operator's browser context — creating sessions, posting messages (which chain into F1's BashTool RCE), reading session histories (F2), and reading settings including the partial-key hint exposed by F-A5. Required user interaction is limited to the operator visiting a page that issues background fetch() calls. This expands the F1-F3 surface from direct TCP attackers to browser-mediated attackers that share a host with a developer running the agent.

---

Finding A5 — Medium: Settings endpoints expose first-4 + last-4 characters of every configured API key via masksecret()

- 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/apiserver.py:467-474 — masksecret() returns f"{value[:4]}...{value[-4:]}" for any string longer than 8 characters - agent/apiserver.py:372 — LLMAPIKEYPLACEHOLDERS filters out shipped placeholders so the leak fires only when a real key is configured - agent/apiserver.py:505-522 — GET /settings/llm returns apikeyhint = masksecret(apikey) if apikeyconfigured else None - agent/apiserver.py:561 — GET /settings/data-sources returns tusharetokenhint=masksecret(token) if tokenconfigured else None - agent/testsettingsapi.py:106 — assertion apikeyhint == "or-s...alue" for input or-secret-value confirms 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 OPENROUTERAPIKEY=sk-or-v1-AbCdEfGhXyZ12345fakekey and TUSHARETOKEN=ts-real-shaped-token-1234567890ab and observed apikeyhint='sk-o...ekey' and tusharetokenhint='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):

1. Edit agent/.env to replace the placeholder with a real-shaped key, e.g. OPENROUTERAPIKEY=sk-or-v1-TestKeyAbcde12345. Restart the server. 2. From any loopback client or via the F-A4 cross-origin path, curl -s "http://127.0.0.1:8899/settings/llm" (no Authorization header). 3. Observe HTTP 200 with apikeyconfigured=true and apikeyhint containing the first 4 and last 4 characters of the real key. 4. curl -s "http://127.0.0.1:8899/settings/data-sources" to observe tusharetokenhint follow 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.

---

Suggested remediation (per finding)

1. F1 / F3 — requireauth() must fail closed when APIAUTHKEY is not set. Either raise on startup if the env var is empty, or generate a random per-install key and emit a one-time bootstrap message. Replace the if not apikey: return shortcut at line 303 with explicit handling of dev-vs-production. 2. F2 — Apply Depends(requireauth) to all read-side @app.get() decorators (/runs, /sessions, /swarm/runs). The current docstring at line 289 ("Only write endpoints use this dependency") describes the bug rather than design intent. 3. F3 — Convert BLOCKEDUPLOADEXT to an allowlist (.csv, .tsv, .json, .pdf, .txt, .xlsx, .docx etc.) rather than a denylist; add .py, .sh, .yaml, .j2, Dockerfile to the rejection set in any case. Place uploads in a directory outside the agent's tool-discovery envelope. 4. F-A4 — Tighten CORSORIGINS default to only http://localhost:5173 (the bundled Vite dev port). Decouple islocalclient from request.client.host — require either a Bearer token or a positive Origin header check, not the TCP peer IP. Document loud that adding any port to CORSORIGINS grants full credentialed cross-origin API access. 5. F-A5 — Replace masksecret() with a fixed placeholder ("•••configured•••") or a boolean presence flag. If the UI requires a hint, hash the key (truncated SHA-256) so the hint cannot be inverted to bytes of the original.

---

Affected Software

1 affected componentFixes available
pip/vibe-trading-ai>=0.1.0<0.1.7
0.1.7

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/vibe-trading-ai to a version that resolves this vulnerability.

    Fixed in 0.1.7
  2. Configuration

    Replace the `if not api_key: return` shortcut so requests are rejected when API_AUTH_KEY is unset; alternatively, raise at startup or generate a random per-install key and emit a one-time bootstrap message.

    API_AUTH_KEY authentication require_auth() behavior when API_AUTH_KEY is unset = fail closed
  3. Configuration

    Apply `Depends(require_auth)` to all read-side endpoints, including `/runs*`, `/sessions*`, and `/swarm/runs*`.

    Session and run read endpoints Depends(require_auth) = enabled
  4. Configuration

    Tighten the default CORS allowlist to only `http://localhost:5173`, the bundled Vite development port.

    CORS _CORS_ORIGINS = http://localhost:5173
  5. Configuration

    Decouple authorization from `request.client.host`; require either a Bearer token or a positive Origin header check instead of treating a loopback TCP peer as sufficient.

    Settings authorization _is_local_client()/require_local_or_auth() = Bearer token or positive Origin validation
  6. Configuration

    Replace the first-4-plus-last-4 API-key disclosure with the fixed placeholder `•••configured•••` or a boolean presence flag; if a hint is required, use a truncated SHA-256 hash rather than bytes of the original key.

    Settings API-key display _mask_secret() = •••configured•••
  7. Configuration

    Convert upload validation from a denylist to an allowlist, permitting only explicitly approved types such as `.csv`, `.tsv`, `.json`, `.pdf`, `.txt`, `.xlsx`, and `.docx`; reject `.py`, `.sh`, `.yaml`, `.j2`, and `Dockerfile` in any case.

    Upload file validation _BLOCKED_UPLOAD_EXT = allowlist of permitted extensions
  8. Compensating control

    Place uploaded files in a directory outside the LLM agent's tool-discovery envelope.

Event History

Oct 2, 2026
Advisory Published
via GitHub·10:44 PM
Data Sourced
via GitHub·10:44 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed by default?

A clean installation is exposed if API_AUTH_KEY remains unset, because the shipped .env.example leaves it commented out and require_auth() returns without enforcing authentication. The default docker-compose configuration binds the API to 0.0.0.0:8899 without a network restriction.

2

Does setting API_AUTH_KEY fully address the authorization issues?

No. The reported read-side authorization gap persists even when API_AUTH_KEY is configured. The advisory also identifies partial API-key disclosure through _mask_secret().

3

What level of access does an attacker need?

The findings include unauthenticated full API exposure and an unauthenticated file-write issue, so no credentials are required where API_AUTH_KEY is unset. The file-write issue permits .py, .sh, and .yaml files to be written to a server-returned path.

4

What makes the file-write impact more serious in the supplied container configuration?

The Dockerfile has no USER directive, and the FastAPI process runs as root inside the container. Combined with unauthenticated file writing, this increases the potential impact within the container.

5

What temporary mitigations are supported by the advisory details?

Set API_AUTH_KEY rather than leaving it unset, but do not treat that as a complete fix because a read-side authorization issue remains. Restrict exposure of port 8899 rather than using the supplied 0.0.0.0 binding, and avoid allowing untrusted browser pages access to the API because the default-permissive CORS behavior can grant credentialed cross-origin access from whitelisted localhost ports.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203