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
- 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.
- Make the server prove itself first (challenge/HMAC over the shared token) so
dor never emits the token to an unproven peer.
- 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.
Found by the nightly security audit's
application-securitypass (see SECURITY.md → CI Validation Contract), verified by hand. Three related weaknesses in thedorcontrol 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
The standalone host does the same with
dormouse-{pid}-dor(standalone/src-tauri/src/lib.rs). A PID is guessable and enumerable, and bothos.tmpdir()and the Windows named-pipe namespace are writable by other principals.2. The client authenticates to the server, never the reverse
DorControlClientwrites 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
So whoever holds the path receives
DORMOUSE_CONTROL_TOKENfrom the firstdorinvocation 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-
listenunlinkSyncrethrows anything that is notENOENT, so a squatted path stops startup. On Windows there is no unlink, and a name already taken surfaces as alistenerror that is logged and then swallowed:Keeping the sidecar alive is right — PTY work should survive a dead control channel. The problem is that
getDorRuntimeEnvkeeps handingDORMOUSE_CONTROL_SOCKETandDORMOUSE_CONTROL_TOKENto 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.tsdefends this exact threat for the peer channel:0o700, with the uid and mode re-checked on every use (peer-link.ts:167,:183-200) rather than trustingtmpdir();: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
0700directory with the same checks aspeerDirPath(), and stop deriving the name from the PID alone.dornever emits the token to an unproven peer.DORMOUSE_CONTROL_TOKENinto spawned shells' environments, and surface it in the UI rather than only on stderr.docs/specs/dor-cli.mdowns 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.