DEV Community

Artemii Amelin
Artemii Amelin

Posted on

DeepSeek Harness Trusted a Header the Caller Wrote. shell.online 0.16.1 Stops Trusting Its Relay on Replays and Read-Only

On September 8, OX Security published CVE-2026-82533 in DeepSeek Harness, CVSS 9.4. The harness gated its local agent-control API on 127.0.0.1:3080 with one function, isTrustedApiRequest, which read the Host request header and, in OX's words, "never compared that value with the connection's actual peer address". A sandboxed agent could run a single shell command against that API and raise its own session to danger-full-access with approval set to never. DeepSeek fixed it in 0.1.2-alpha.1 on August 27.

The CWE assigned is 807, reliance on untrusted inputs in a security decision. The check asked the caller whether the caller was trusted.

We shipped shell.online 0.16.1 today, and most of its security changes are the same bug class in our own code. shell.online puts a running terminal behind a browser link. The CLI on your machine and the viewer in the browser share a key derived from the session password. The relay between them forwards ciphertext and never holds that key, so it cannot read frames. Before today it could still do three things it should not have been trusted with.

The relay could send a keystroke twice

The previous SECURITY.md said this outright: "Relay-side dropping, delaying, and replaying of valid ciphertext are documented protocol limits rather than confidentiality claims." Envelope v1 was the opcode, a version byte, a 12-byte random nonce, then AES-256-GCM ciphertext with the opcode as the only associated data. That is 29 bytes of overhead and nothing in it says when a frame was sent. A relay that kept a copy of the sealed frame carrying rm -rf build\n could deliver it again an hour later and the CLI would write it to the PTY again.

Envelope v2, in internal/e2ee/e2ee.go of the shell.online source, adds a direction byte, an 8-byte random stream id generated once per cipher, and an 8-byte big-endian sequence number that starts at 1. Overhead goes from 29 to 46 bytes. All of the new fields plus the opcode go into the GCM associated data, so changing any of them fails authentication.

The receiver keeps a 64-frame sliding window per stream: a max and a 64-bit bitmap. Sequence zero is rejected, anything 64 or more behind max is rejected as stale, and a set bit means duplicate. It is a window and not a strict counter because frames do arrive slightly out of order. The code comment names the cause: "concurrent output and snapshot writers". The Go test seals three frames, opens them in the order 2, 0, 1, then replays frame 1 and expects an error.

Ordering matters here. acceptSequence runs after aead.Open succeeds, never before. If the window advanced on unauthenticated input, anyone able to inject bytes could send a garbage frame with a huge sequence number and push every real frame out of the window.

This is the same structure we use in Pilot Protocol tunnels, with different numbers. In the Pilot Protocol source, pkg/daemon/keyexchange/crypto.go sets ReplayWindowSize = 256. Those tunnels run over UDP, where reordering is routine, so the window is wider. It splits the check in two: WouldAcceptNonce is a read-only pre-check that skips the AEAD work for obvious replays, and CheckAndRecordNonce is only called after the frame authenticates. The comment there describes the failure it prevents, a forged high-counter frame "pinning MaxRecvNonce and wedging every subsequent genuine frame out of the window".

On the browser side a replayed frame throws E2EEReplayError and is dropped silently. That needed its own branch, because every other decrypt failure in the viewer is treated as a wrong password and reopens the prompt.

Read-only was enforced in one place, and it was the relay

shell --read-only was enforced by the relay. viewerFrameAction in shared/session-access.ts maps the Input and ConfirmedEOF opcodes to blocked-input when the session is read-only. The CLI did not check. readRelay in cmd/shell/session_unix.go wrote any Input frame that decrypted straight to the PTY.

A read-only viewer holds the same password as everyone else, so an Input frame they seal is valid ciphertext. The only thing between that frame and the shell was the relay's opcode check. In 0.16.1 readRelay takes the session's readOnly flag and acceptsViewerInput refuses both opcodes on the host. The machine that set the flag now enforces it, and the relay check stays as a second layer.

The relay could tell the viewer the session was plaintext

In the standalone viewer, web/main.ts initialised encryptedSession to false and set it from a relay control message. A relay reporting encrypted: false for a link whose fragment carried key material meant incoming frames skipped frameCipher.open and rendered as sent, under a "Transport only" badge. The fragment never reaches the relay, which makes it the one statement of intent the relay cannot edit. Now encryptedSession starts from the fragment, and an encrypted: false message on such a link closes the socket with code 4003 and the text "The relay reported this encrypted link as plaintext. The connection was blocked."

One more from the same release, on the daemon. When a browser-started command arrives, the daemon launches shell with that command line. It now inserts -- first, so a command supplied by the service cannot be parsed as shell.online's own flags. The comment in agent_loop.go is direct about the limit: the opted-in service "may choose what program runs, but it may not silently weaken E2EE or widen file sharing on the wrapper itself".

What v2 does not cover

A relay can still drop or delay frames. No envelope fixes that.

The replay window lives in process memory. For an ordinary session that is fine, because the key dies with the process. shell --persistent is different: persistentSessionState stores the encryption key and no sequence state, and resuming calls e2ee.New(key), which starts with an empty window. A frame captured during one run of a persistent session would be accepted once by the next run under the same key. shell password rotate replaces the key and ends that exposure.

Old CLIs keep working: the browser still opens v1 frames, answers in v1, and remembers up to 4,096 seen nonces for them, but those sessions do not get the sequence guarantee until the CLI is upgraded. A 0.16.1 CLI rejects anything that is not v2.

The updated SECURITY.md also gained a section titled "Hosted-service trust boundary". It lists what E2EE on a shared terminal link does not make trustless: the web app's JavaScript while it is open, first-seen account keys, and an installer whose checksums come from the same release path as the binary.

Top comments (0)