Skip to content

Commit 9624aab

Browse files
ARHAEEMclaude
andcommitted
fix(security): remove unsanitized icacls duplicate; require proven identity before kill escalation
Two review findings on PR #18, both verified real: 1. cloudflared-named-setup.js carried its own pre-existing applyPrivatePermissions copy that did NOT sanitize USERNAME/USERDOMAIN before building the icacls principal — the gap the PR's token.js fix was meant to close, and reachable via writeTunnelConfig on every named tunnel create/reconfigure. Deleted the local copy; the file now imports the shared sanitized helper from ../token.js. 2. stopDaemon's 'rejected' branch killed the lockfile pid on ANY non-2xx shutdown response. A non-2xx only proves something answered on the port — a stale lock whose port was reused by an unrelated HTTP service also rejects, while the recorded pid may belong to an innocent recycled process. Kill escalation now requires proven identity: an accepted (bearer-authenticated) shutdown, or /daemon/health echoing the lockfile's uuid. Unproven answers fall through to reclaim-without-kill like the unreachable path. Tests updated: proven-identity 401 still escalates; unproven rejection must not kill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4bfb9d7 commit 9624aab

3 files changed

Lines changed: 76 additions & 30 deletions

File tree

packages/extension/src/mcp/daemon-manager.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface DaemonStatus {
1313
bearerToken: string | null;
1414
tunnelUrl: string | null;
1515
uptime: number | null;
16+
/** Lockfile uuid — used to verify daemon identity before kill escalation. */
17+
uuid: string | null;
1618
}
1719

1820
export interface DaemonConnectionInfo {
@@ -27,7 +29,7 @@ export interface DaemonConnectionInfo {
2729

2830
const EMPTY_STATUS: DaemonStatus = {
2931
running: false, healthy: false, pid: null, port: null,
30-
port_lsp: null, bearerToken: null, tunnelUrl: null, uptime: null,
32+
port_lsp: null, bearerToken: null, tunnelUrl: null, uptime: null, uuid: null,
3133
};
3234

3335
export interface StopResult {
@@ -91,6 +93,7 @@ export class DaemonManager implements vscode.Disposable {
9193
bearerToken,
9294
tunnelUrl: typeof record.tunnelUrl === 'string' ? record.tunnelUrl : null,
9395
uptime: typeof record.startedAt === 'string' ? Date.now() - Date.parse(record.startedAt) : null,
96+
uuid: typeof record.uuid === 'string' && record.uuid.length > 0 ? record.uuid : null,
9497
};
9598
this._status = status;
9699
return status;
@@ -193,10 +196,19 @@ export class DaemonManager implements vscode.Disposable {
193196
const pid = status.pid;
194197
const pidAlive = typeof pid === 'number' && pid > 0 && this._isPidAlive(pid);
195198

196-
// 3) The port answered, so the pid in the lockfile really is the daemon —
197-
// safe to kill. When the port is unreachable we do NOT kill: after a
198-
// crash the OS may have recycled the pid onto an innocent process.
199-
if (outcome !== 'unreachable' && pidAlive && typeof pid === 'number') {
199+
// 3) Escalate to kill ONLY with proven daemon identity. An accepted
200+
// (bearer-authenticated) shutdown proves it. A rejected response does
201+
// NOT — it only proves SOMETHING answered on the port; a stale lock
202+
// whose port was reused by an unrelated HTTP service also rejects,
203+
// while the recorded pid may belong to an innocent recycled process.
204+
// For rejected, require /daemon/health to echo the lockfile's uuid.
205+
const provenOurDaemon = outcome === 'accepted' || (
206+
outcome === 'rejected'
207+
&& status.port != null && status.bearerToken != null
208+
&& await this._verifyDaemonIdentity(status.port, status.bearerToken, status.uuid)
209+
);
210+
211+
if (provenOurDaemon && pidAlive && typeof pid === 'number') {
200212
this._killPid(pid);
201213
const deadline = Date.now() + 3_000;
202214
let escalated = false;
@@ -214,19 +226,43 @@ export class DaemonManager implements vscode.Disposable {
214226
return { stopped: true, forced: true };
215227
}
216228

217-
// 4) Unreachable: the lockfile is stale (dead pid) or points at a process
218-
// that is not serving the daemon port. Reclaim the lock either way so
219-
// the dashboard stops showing a phantom daemon.
229+
// 4) Unreachable or unproven identity: the lockfile is stale (dead pid),
230+
// or whatever answers on the port could not be verified as our daemon.
231+
// Reclaim the lock so the dashboard stops showing a phantom daemon,
232+
// but leave the recorded pid untouched (PID-reuse safety).
220233
this._reclaimLockfile();
221234
return {
222235
stopped: true,
223236
forced: false,
224237
reason: pidAlive
225-
? `Removed stale daemon.lock; process ${pid} was not answering on the daemon port and was left untouched.`
238+
? `Removed stale daemon.lock; process ${pid} could not be verified as the daemon and was left untouched.`
226239
: undefined,
227240
};
228241
}
229242

243+
/**
244+
* Proof of identity for kill escalation: /daemon/health, authenticated with
245+
* the lockfile's bearer token, must echo the lockfile's uuid.
246+
*/
247+
private async _verifyDaemonIdentity(port: number, bearerToken: string, uuid: string | null): Promise<boolean> {
248+
if (!uuid) return false;
249+
const controller = new AbortController();
250+
const timeout = setTimeout(() => controller.abort(), 2_000);
251+
try {
252+
const res = await fetch(`http://127.0.0.1:${port}/daemon/health`, {
253+
headers: { Authorization: `Bearer ${bearerToken}` },
254+
signal: controller.signal,
255+
});
256+
if (!res.ok) return false;
257+
const body = await res.json().catch(() => null) as { uuid?: unknown } | null;
258+
return body?.uuid === uuid;
259+
} catch {
260+
return false;
261+
} finally {
262+
clearTimeout(timeout);
263+
}
264+
}
265+
230266
async restartDaemon(): Promise<DaemonConnectionInfo> {
231267
await this.stopDaemon();
232268
await this._delay(500);

packages/extension/src/test/daemon-manager.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,13 @@ describe('DaemonManager.stopDaemon', () => {
135135
expect(lockExists()).toBe(false);
136136
});
137137

138-
it('rejected shutdown (401): escalates to killing the recorded pid and reclaims the lock', async () => {
139-
const port = await listen((_req, res) => {
138+
it('rejected shutdown with PROVEN identity (health echoes lock uuid): escalates to kill', async () => {
139+
const port = await listen((req, res) => {
140+
if (req.method === 'GET' && req.url === '/daemon/health') {
141+
res.writeHead(200, { 'Content-Type': 'application/json' });
142+
res.end(JSON.stringify({ ok: true, uuid: 'uuid-1' }));
143+
return;
144+
}
140145
res.writeHead(401, { 'Content-Type': 'application/json' });
141146
res.end('{"error":"Unauthorized"}');
142147
});
@@ -153,6 +158,25 @@ describe('DaemonManager.stopDaemon', () => {
153158
expect(lockExists()).toBe(false);
154159
});
155160

161+
it('rejected shutdown WITHOUT proven identity: does NOT kill (PID reuse), reclaims the lock', async () => {
162+
// Simulates a stale lock whose port was reused by an unrelated HTTP
163+
// service: it answers (non-2xx) but cannot echo the lockfile uuid.
164+
const port = await listen((_req, res) => {
165+
res.writeHead(404, { 'Content-Type': 'application/json' });
166+
res.end('{"error":"Not found"}');
167+
});
168+
writeLock(port, process.pid);
169+
170+
(dm as any)._killPid = vi.fn();
171+
(dm as any)._isPidAlive = vi.fn(() => true);
172+
173+
const result = await dm.stopDaemon();
174+
expect((dm as any)._killPid).not.toHaveBeenCalled();
175+
expect(result.stopped).toBe(true);
176+
expect(result.reason).toBeTruthy();
177+
expect(lockExists()).toBe(false);
178+
});
179+
156180
it('unreachable daemon with dead pid: reclaims the stale lock without killing anything', async () => {
157181
writeLock(1, 999_999); // port 1 — nothing listening
158182
(dm as any)._killPid = vi.fn();

packages/mcp-server/src/daemon/tunnel-providers/cloudflared-named-setup.js

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
*/
1212

1313
import {
14-
chmodSync,
1514
existsSync,
1615
mkdirSync,
1716
readFileSync,
@@ -21,10 +20,10 @@ import { safeAtomicWriteFileSync } from '../../safe-write.js';
2120
import { spawn as nodeSpawn } from 'node:child_process';
2221
import { dirname, join } from 'node:path';
2322
import { homedir } from 'node:os';
24-
import { spawnSync } from 'node:child_process';
2523

2624
import { getTunnelBinaryPath } from '../install-tunnel.js';
2725
import { getHomeDir } from '../../paths.js';
26+
import { applyPrivatePermissions } from '../token.js';
2827

2928
const CONFIG_FILENAME = 'cloudflared-named.yml';
3029
const DEFAULT_LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
@@ -676,20 +675,7 @@ function unquoteYaml(value) {
676675
return value;
677676
}
678677

679-
/**
680-
* @param {string} path
681-
*/
682-
function applyPrivatePermissions(path) {
683-
if (process.platform === 'win32') {
684-
const username = process.env.USERNAME;
685-
const domain = process.env.USERDOMAIN;
686-
const target = domain && username ? `${domain}\\${username}` : username ?? '';
687-
if (!target) return;
688-
spawnSync('icacls', [path, '/inheritance:r', '/grant:r', `${target}:(R,W)`], {
689-
encoding: 'utf8',
690-
windowsHide: true,
691-
});
692-
return;
693-
}
694-
chmodSync(path, 0o600);
695-
}
678+
// applyPrivatePermissions is imported from ../token.js — the shared helper
679+
// sanitizes USERNAME/USERDOMAIN before building the icacls principal. Do not
680+
// re-introduce a local copy here (a previous unsanitized duplicate was a
681+
// security regression).

0 commit comments

Comments
 (0)