# RCA Report — CVE-2026-39987: marimo Pre-Auth RCE via /terminal/ws

## Summary

marimo's interactive terminal WebSocket endpoint (`/terminal/ws`) completely skipped
authentication validation. marimo relies on Starlette's `AuthenticationMiddleware`,
which only *marks* failed-auth connections as `UnauthenticatedUser` without actively
rejecting WebSocket connections; real enforcement depends on endpoint-level checks.
While the main `/ws` endpoint validates credentials, `/terminal/ws` had neither a
`@requires("edit")` decorator nor a `validate_auth()` call, so an unauthenticated
attacker could open a WebSocket and be handed a full interactive PTY shell running
with the privileges of the marimo process — pre-authentication remote code execution.

## Impact

- **Package/component:** `marimo` (Python notebook server),
  `marimo/_server/api/endpoints/terminal.py` — `/terminal/ws` WebSocket endpoint.
- **Affected versions:** all versions `< 0.23.0` (verified on `0.22.5`).
- **Risk level:** Critical (CVSS 9.3, EPSS 0.953, CISA KEV 2026-04-23, exploited in
  the wild). Consequences: unauthenticated remote attacker obtains an interactive
  OS shell with the marimo process's privileges (frequently root in Docker
  deployments), enabling reconnaissance, credential theft (e.g. `.env` cloud keys),
  lateral movement, and full host compromise.

## Impact Parity

- **Disclosed/claimed maximum impact:** pre-authentication remote code execution
  (interactive PTY shell, arbitrary OS commands).
- **Reproduced impact from this run:** identical — from a raw, credential-less
  WebSocket client we obtained a PTY shell and executed arbitrary commands
  (`echo PRUVA_VULN_A<n>_$(id -u)_$(id -un)`), observing marker output
  `PRUVA_VULN_A1_1000_vscode` proving execution as the marimo server user (uid 1000).
- **Parity:** `full`.
- **Not demonstrated:** nothing material — the claim is fully reproduced, including
  the fixed-version negative control.

## Root Cause

`marimo/_server/api/endpoints/terminal.py::websocket_endpoint` (vulnerable code at
fix-commit parent `c24d4806398f30be6b12acd6c60d1d7c68cfd12a^`) performed only two
checks before `websocket.accept()` and `pty.fork()`:

1. `app_state.mode != SessionMode.EDIT` → close.
2. `supports_terminal()` → close.

There was **no authentication check**. Because Starlette's
`AuthenticationMiddleware` does not reject unauthenticated WebSocket upgrades (it
only attaches an `UnauthenticatedUser`), the absence of an explicit
`validate_auth(websocket)` call meant anyone could reach the PTY-spawning code.

Fix commit `c24d4806398f30be6b12acd6c60d1d7c68cfd12a` (PR #9098, released in
0.23.0) adds exactly:

```python
from marimo._server.api.auth import validate_auth
...
if app_state.enable_auth and not validate_auth(websocket):
    await websocket.close(WebSocketCodes.UNAUTHORIZED, "MARIMO_UNAUTHORIZED")
    return
```

aligning `/terminal/ws` with the auth validation used by the other WebSocket
endpoints. Verified in this run: the patch hunk exists at the fixed commit, the
parent commit lacks it, the installed 0.22.5 package lacks `validate_auth` in
`terminal.py`, and the installed 0.23.0 package contains it.

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (helper: `bundle/repro/ws_exploit_client.py`).
2. The script:
   - clones/uses the marimo source checkout and verifies the fix patch hunk;
   - creates two virtualenvs: `marimo==0.22.5` (vulnerable) and `marimo==0.23.0` (fixed);
   - starts each real server with token auth enabled
     (`marimo edit --headless --token-password topsecretpw`);
   - proves the HTTP auth gate is active (unauthenticated `/` → HTTP 303 login redirect);
   - as an unauthenticated attacker, opens a raw WebSocket to `/terminal/ws`
     (no token/cookie/header) and sends a shell command — **twice** against the
     vulnerable build (both yield PTY output with the unique marker) and **twice**
     against the fixed build (both rejected with HTTP 403 during the WS upgrade);
   - runs an authenticated positive control on the fixed build (valid
     `access_token` → terminal works), proving the fix blocks only unauthenticated
     access;
   - writes `bundle/repro/runtime_manifest.json`.
3. Expected evidence: vulnerable attempts print `RCE_CONFIRMED` with marker
   `PRUVA_VULN_A<n>_<uid>_<user>` in PTY output; fixed attempts print
   `CONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403`.

## Evidence

- `bundle/logs/reproduction_steps.log` — full run transcript (verdict line:
  `vuln RCE attempts OK=2/2, fixed rejects OK=2/2, fixed auth control=1`).
- `bundle/logs/server_vuln.log`, `bundle/logs/server_fixed.log` — server startup
  showing token auth (`URL: http://localhost:2718?access_token=topsecretpw`).
- `bundle/logs/vuln_unauth_attempt1.log` / `...attempt2.log` — key excerpt:

  ```
  echo PRUVA_VULN_A1_$(id -u)_$(id -un)
  vscode ➜ /tmp $ echo PRUVA_VULN_A1_$(id -u)_$(id -un)
  PRUVA_VULN_A1_1000_vscode
  RESULT: RCE_CONFIRMED marker observed in PTY output
  ```

  (unauthenticated WS accepted → interactive shell → arbitrary command executed as
  uid 1000 `vscode`, the marimo process user).
- `bundle/logs/fixed_unauth_attempt1.log` / `...attempt2.log` —
  `CONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403`.
- `bundle/logs/fixed_auth_control.log` — authenticated request on the fixed build
  still obtains the terminal (`PRUVA_FIXED_AUTH_1000_vscode`).
- `bundle/logs/patch_hunk.txt` — the added `validate_auth` lines from the fix commit.
- `bundle/repro/runtime_manifest.json` — `entrypoint_kind=endpoint`,
  `service_started=true`, `healthcheck_passed=true`, `target_path_reached=true`.
- Environment: Python 3.14.4, pip-installed `marimo==0.22.5` / `marimo==0.23.0`,
  `websockets` client library, Linux x86_64. Script verified idempotent by two
  consecutive successful runs (exit 0 both times).

## Recommendations / Next Steps

- **Upgrade** to marimo ≥ 0.23.0 immediately (fix: PR #9098 /
  commit `c24d4806398f30be6b12acd6c60d1d7c68cfd12a`).
- **Fix approach (already upstream):** call `validate_auth(websocket)` and close
  with `WebSocketCodes.UNAUTHORIZED` before `websocket.accept()` whenever
  `enable_auth` is true — for every WebSocket endpoint, not just `/ws`.
- **Defense in depth:** never expose `marimo edit` to untrusted networks; put it
  behind an authenticating reverse proxy; run it as an unprivileged user; audit
  any deployment that ran < 0.23.0 with a reachable port for compromise
  (unexpected PTY child processes, shell history, `.env` access).
- **Testing:** add a regression test asserting unauthenticated `/terminal/ws`
  upgrades are rejected (upstream added one in `tests/_server/api/endpoints/test_terminal.py`).

## Additional Notes

- **Idempotency:** the script is fully self-contained (installs its own venvs,
  manages server lifecycle with bounded waits and process-group cleanup) and
  passed twice consecutively with exit 0.
- **Edge cases:** the vulnerability requires edit mode (`SessionMode.EDIT`,
  i.e. `marimo edit`, the default) and a POSIX platform with `pty` support —
  both are the standard deployment shape. Auth must be enabled (non-empty token),
  which is marimo's default when a token is generated or `--token-password` is set;
  with auth disabled the impact is identical but by design.
- The advisory body text ("<= 0.20.4") understates the range; the structured range
  `< 0.23.0` is correct — 0.22.5 was confirmed vulnerable here.
