{"repro_id":"REPRO-2026-00316","version":6,"title":"marimo Pre-Auth RCE via Terminal WebSocket Authentication Bypass (/terminal/ws missing validate_auth)","repro_type":"security","status":"published","severity":"critical","description":"Pre-authentication remote code execution in marimo (Python notebook, ~19.6k GitHub stars). The terminal WebSocket endpoint /terminal/ws completely skips authentication validation, allowing an unauthenticated attacker to obtain a full interactive PTY shell and execute arbitrary OS commands with the privileges of the marimo process (often root in Docker).","root_cause":"# RCA Report — CVE-2026-39987: marimo Pre-Auth RCE via /terminal/ws\n\n## Summary\n\nmarimo's interactive terminal WebSocket endpoint (`/terminal/ws`) completely skipped\nauthentication validation. marimo relies on Starlette's `AuthenticationMiddleware`,\nwhich only *marks* failed-auth connections as `UnauthenticatedUser` without actively\nrejecting WebSocket connections; real enforcement depends on endpoint-level checks.\nWhile the main `/ws` endpoint validates credentials, `/terminal/ws` had neither a\n`@requires(\"edit\")` decorator nor a `validate_auth()` call, so an unauthenticated\nattacker could open a WebSocket and be handed a full interactive PTY shell running\nwith the privileges of the marimo process — pre-authentication remote code execution.\n\n## Impact\n\n- **Package/component:** `marimo` (Python notebook server),\n  `marimo/_server/api/endpoints/terminal.py` — `/terminal/ws` WebSocket endpoint.\n- **Affected versions:** all versions `< 0.23.0` (verified on `0.22.5`).\n- **Risk level:** Critical (CVSS 9.3, EPSS 0.953, CISA KEV 2026-04-23, exploited in\n  the wild). Consequences: unauthenticated remote attacker obtains an interactive\n  OS shell with the marimo process's privileges (frequently root in Docker\n  deployments), enabling reconnaissance, credential theft (e.g. `.env` cloud keys),\n  lateral movement, and full host compromise.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** pre-authentication remote code execution\n  (interactive PTY shell, arbitrary OS commands).\n- **Reproduced impact from this run:** identical — from a raw, credential-less\n  WebSocket client we obtained a PTY shell and executed arbitrary commands\n  (`echo PRUVA_VULN_A<n>_$(id -u)_$(id -un)`), observing marker output\n  `PRUVA_VULN_A1_1000_vscode` proving execution as the marimo server user (uid 1000).\n- **Parity:** `full`.\n- **Not demonstrated:** nothing material — the claim is fully reproduced, including\n  the fixed-version negative control.\n\n## Root Cause\n\n`marimo/_server/api/endpoints/terminal.py::websocket_endpoint` (vulnerable code at\nfix-commit parent `c24d4806398f30be6b12acd6c60d1d7c68cfd12a^`) performed only two\nchecks before `websocket.accept()` and `pty.fork()`:\n\n1. `app_state.mode != SessionMode.EDIT` → close.\n2. `supports_terminal()` → close.\n\nThere was **no authentication check**. Because Starlette's\n`AuthenticationMiddleware` does not reject unauthenticated WebSocket upgrades (it\nonly attaches an `UnauthenticatedUser`), the absence of an explicit\n`validate_auth(websocket)` call meant anyone could reach the PTY-spawning code.\n\nFix commit `c24d4806398f30be6b12acd6c60d1d7c68cfd12a` (PR #9098, released in\n0.23.0) adds exactly:\n\n```python\nfrom marimo._server.api.auth import validate_auth\n...\nif app_state.enable_auth and not validate_auth(websocket):\n    await websocket.close(WebSocketCodes.UNAUTHORIZED, \"MARIMO_UNAUTHORIZED\")\n    return\n```\n\naligning `/terminal/ws` with the auth validation used by the other WebSocket\nendpoints. Verified in this run: the patch hunk exists at the fixed commit, the\nparent commit lacks it, the installed 0.22.5 package lacks `validate_auth` in\n`terminal.py`, and the installed 0.23.0 package contains it.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (helper: `bundle/repro/ws_exploit_client.py`).\n2. The script:\n   - clones/uses the marimo source checkout and verifies the fix patch hunk;\n   - creates two virtualenvs: `marimo==0.22.5` (vulnerable) and `marimo==0.23.0` (fixed);\n   - starts each real server with token auth enabled\n     (`marimo edit --headless --token-password topsecretpw`);\n   - proves the HTTP auth gate is active (unauthenticated `/` → HTTP 303 login redirect);\n   - as an unauthenticated attacker, opens a raw WebSocket to `/terminal/ws`\n     (no token/cookie/header) and sends a shell command — **twice** against the\n     vulnerable build (both yield PTY output with the unique marker) and **twice**\n     against the fixed build (both rejected with HTTP 403 during the WS upgrade);\n   - runs an authenticated positive control on the fixed build (valid\n     `access_token` → terminal works), proving the fix blocks only unauthenticated\n     access;\n   - writes `bundle/repro/runtime_manifest.json`.\n3. Expected evidence: vulnerable attempts print `RCE_CONFIRMED` with marker\n   `PRUVA_VULN_A<n>_<uid>_<user>` in PTY output; fixed attempts print\n   `CONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403`.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full run transcript (verdict line:\n  `vuln RCE attempts OK=2/2, fixed rejects OK=2/2, fixed auth control=1`).\n- `bundle/logs/server_vuln.log`, `bundle/logs/server_fixed.log` — server startup\n  showing token auth (`URL: http://localhost:2718?access_token=topsecretpw`).\n- `bundle/logs/vuln_unauth_attempt1.log` / `...attempt2.log` — key excerpt:\n\n  ```\n  echo PRUVA_VULN_A1_$(id -u)_$(id -un)\n  vscode ➜ /tmp $ echo PRUVA_VULN_A1_$(id -u)_$(id -un)\n  PRUVA_VULN_A1_1000_vscode\n  RESULT: RCE_CONFIRMED marker observed in PTY output\n  ```\n\n  (unauthenticated WS accepted → interactive shell → arbitrary command executed as\n  uid 1000 `vscode`, the marimo process user).\n- `bundle/logs/fixed_unauth_attempt1.log` / `...attempt2.log` —\n  `CONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403`.\n- `bundle/logs/fixed_auth_control.log` — authenticated request on the fixed build\n  still obtains the terminal (`PRUVA_FIXED_AUTH_1000_vscode`).\n- `bundle/logs/patch_hunk.txt` — the added `validate_auth` lines from the fix commit.\n- `bundle/repro/runtime_manifest.json` — `entrypoint_kind=endpoint`,\n  `service_started=true`, `healthcheck_passed=true`, `target_path_reached=true`.\n- Environment: Python 3.14.4, pip-installed `marimo==0.22.5` / `marimo==0.23.0`,\n  `websockets` client library, Linux x86_64. Script verified idempotent by two\n  consecutive successful runs (exit 0 both times).\n\n## Recommendations / Next Steps\n\n- **Upgrade** to marimo ≥ 0.23.0 immediately (fix: PR #9098 /\n  commit `c24d4806398f30be6b12acd6c60d1d7c68cfd12a`).\n- **Fix approach (already upstream):** call `validate_auth(websocket)` and close\n  with `WebSocketCodes.UNAUTHORIZED` before `websocket.accept()` whenever\n  `enable_auth` is true — for every WebSocket endpoint, not just `/ws`.\n- **Defense in depth:** never expose `marimo edit` to untrusted networks; put it\n  behind an authenticating reverse proxy; run it as an unprivileged user; audit\n  any deployment that ran < 0.23.0 with a reachable port for compromise\n  (unexpected PTY child processes, shell history, `.env` access).\n- **Testing:** add a regression test asserting unauthenticated `/terminal/ws`\n  upgrades are rejected (upstream added one in `tests/_server/api/endpoints/test_terminal.py`).\n\n## Additional Notes\n\n- **Idempotency:** the script is fully self-contained (installs its own venvs,\n  manages server lifecycle with bounded waits and process-group cleanup) and\n  passed twice consecutively with exit 0.\n- **Edge cases:** the vulnerability requires edit mode (`SessionMode.EDIT`,\n  i.e. `marimo edit`, the default) and a POSIX platform with `pty` support —\n  both are the standard deployment shape. Auth must be enabled (non-empty token),\n  which is marimo's default when a token is generated or `--token-password` is set;\n  with auth disabled the impact is identical but by design.\n- The advisory body text (\"<= 0.20.4\") understates the range; the structured range\n  `< 0.23.0` is correct — 0.22.5 was confirmed vulnerable here.\n","cve_id":"CVE-2026-39987","cwe_id":"CWE-306","source_url":"https://github.com/marimo-team/marimo/security/advisories/GHSA-2679-6mx9-h9xc","package":{"name":"marimo","ecosystem":"github","affected_versions":"<0.23.0","fixed_version":"0.23.0","tested_vulnerable":"0.22.5","tested_patched":"0.23.0, 0.23.15"},"reproduced_at":"2026-07-30T07:54:02.195329+00:00","duration_secs":1459.0,"tool_calls":134,"handoffs":2,"total_cost_usd":2.29215,"agent_costs":{"claim_matcher":0.012488,"judge":0.271112,"learning_policy":0.010542,"repro":0.979765,"support":0.084824,"vuln_variant":0.933419},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.012488},"judge":{"gpt-5.6-sol":0.271112},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.010542},"repro":{"accounts/fireworks/models/kimi-k3":0.979765},"support":{"accounts/fireworks/models/kimi-k3":0.084824},"vuln_variant":{"accounts/fireworks/models/kimi-k3":0.933419}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-30T07:54:02.739550+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":10606,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7548,"category":"analysis"},{"path":"bundle/repro/ws_exploit_client.py","filename":"ws_exploit_client.py","size":1923,"category":"script"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":681,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":4579,"category":"log"},{"path":"bundle/logs/vuln_unauth_attempt1.log","filename":"vuln_unauth_attempt1.log","size":317,"category":"log"},{"path":"bundle/logs/fixed_unauth_attempt1.log","filename":"fixed_unauth_attempt1.log","size":78,"category":"log"},{"path":"bundle/logs/server_vuln.log","filename":"server_vuln.log","size":258,"category":"log"},{"path":"bundle/logs/server_fixed.log","filename":"server_fixed.log","size":258,"category":"log"},{"path":"bundle/logs/vuln_unauth_attempt2.log","filename":"vuln_unauth_attempt2.log","size":317,"category":"log"},{"path":"bundle/logs/fixed_unauth_attempt2.log","filename":"fixed_unauth_attempt2.log","size":78,"category":"log"},{"path":"bundle/logs/fixed_auth_control.log","filename":"fixed_auth_control.log","size":326,"category":"log"},{"path":"bundle/logs/patch_hunk.txt","filename":"patch_hunk.txt","size":115,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":842,"category":"other"}]}