Skip to content

dor control socket: predictable path, no server authentication, and a non-fatal bind failure on Windows #431

Description

@nedtwigg

Found by the nightly security audit's application-security pass (see SECURITY.md → CI Validation Contract), verified by hand. Three related weaknesses in the dor control channel; the third is what makes the first two reachable.

1. The path is predictable and lives in a shared namespace

https://github.com/diffplug/dormouse/blob/main/vscode-ext/src/pty-manager.ts#L182-L184

const dorControlSocket = process.platform === 'win32'
  ? `\\\\.\\pipe\\dormouse-vscode-${process.pid}-dor`
  : path.join(os.tmpdir(), `dormouse-vscode-${process.pid}-dor.sock`);

The standalone host does the same with dormouse-{pid}-dor (standalone/src-tauri/src/lib.rs). A PID is guessable and enumerable, and both os.tmpdir() and the Windows named-pipe namespace are writable by other principals.

2. The client authenticates to the server, never the reverse

DorControlClient writes the token as the first bytes on the wire, before the peer has proven anything about itself:

https://github.com/diffplug/dormouse/blob/main/dor/src/control-client.ts#L110-L116

socket.on('connect', () => {
  socket.write(`${JSON.stringify({ requestId, token: this.token, surfaceId, method, params })}\n`);
});

So whoever holds the path receives DORMOUSE_CONTROL_TOKEN from the first dor invocation that connects. The token itself is fine — randomBytes(24) — but it is a bearer credential handed to an unauthenticated peer, and it grants the full surface-control API: dor split, dor send (arbitrary keystrokes into any pane), dor read (screen contents and scrollback), dor kill.

3. On Windows, losing the bind is not fatal — and shells still get the token

https://github.com/diffplug/dormouse/blob/main/standalone/sidecar/dor-control-server.js#L129-L148

On POSIX the pre-listen unlinkSync rethrows anything that is not ENOENT, so a squatted path stops startup. On Windows there is no unlink, and a name already taken surfaces as a listen error that is logged and then swallowed:

server.on('error', (error) => {
  console.error(`[dor-control] ${error.message}`);
  rejectReady(error);
});
ready.catch(() => {
  // ... keeps the sidecar alive for normal PTY work.
});

Keeping the sidecar alive is right — PTY work should survive a dead control channel. The problem is that getDorRuntimeEnv keeps handing DORMOUSE_CONTROL_SOCKET and DORMOUSE_CONTROL_TOKEN to every shell it spawns regardless. So an attacker who wins the pipe race gets Dormouse to keep feeding it clients and tokens, while the only sign is one line on stderr.

The pattern to copy is already in the repo

vscode-ext/src/peer-link.ts defends this exact threat for the peer channel:

  • a per-uid parent directory created 0o700, with the uid and mode re-checked on every use (peer-link.ts:167, :183-200) rather than trusting tmpdir();
  • the server opens with a challenge and the client proves over that specific nonce (:786, :803, :674-684), so a squatter learns nothing by accepting a connection. The comment at :1010 — "Answering a challenge proves nothing about the challenger" — is precisely the property the control socket lacks.

Suggested shape

  1. Move the POSIX socket into a uid-owned 0700 directory with the same checks as peerDirPath(), and stop deriving the name from the PID alone.
  2. Make the server prove itself first (challenge/HMAC over the shared token) so dor never emits the token to an unproven peer.
  3. On Windows, treat a lost bind as fatal to the control channel: keep the sidecar alive for PTY work, but stop putting DORMOUSE_CONTROL_TOKEN into spawned shells' environments, and surface it in the UI rather than only on stderr.

docs/specs/dor-cli.md owns the control-socket plumbing and should be updated alongside.

Severity

Local attacker only — someone already able to run code as another account on the machine, or as the same user. On a single-user laptop that is a low bar to clear only because the attacker already has a foothold; on a shared or multi-user Windows box it is more interesting, since the pipe namespace is machine-wide.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions