Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@
*/

import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { readToken, TOKEN_HEADER } from '../token.js';
import type { BrowserSessionInfo } from '../types.js';

const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;

export function authHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'X-OpenCLI': '1' };
const token = readToken();
if (token) headers[TOKEN_HEADER] = token;
return headers;
}

let _idCounter = 0;

function generateId(): string {
Expand Down Expand Up @@ -46,7 +54,7 @@ export async function isDaemonRunning(): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
headers: authHeaders(),
signal: controller.signal,
});
clearTimeout(timer);
Expand All @@ -64,7 +72,7 @@ export async function isExtensionConnected(): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
headers: authHeaders(),
signal: controller.signal,
});
clearTimeout(timer);
Expand Down Expand Up @@ -97,7 +105,7 @@ export async function sendCommand(

const res = await fetch(`${DAEMON_URL}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(command),
signal: controller.signal,
});
Expand Down
4 changes: 2 additions & 2 deletions src/browser/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { isDaemonRunning } from './daemon-client.js';
import { isDaemonRunning, authHeaders } from './daemon-client.js';

export { isDaemonRunning };

Expand All @@ -20,7 +20,7 @@ export async function checkDaemonStatus(): Promise<{
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const res = await fetch(`http://127.0.0.1:${port}/status`, {
headers: { 'X-OpenCLI': '1' },
headers: authHeaders(),
});
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
return { running: true, extensionConnected: data.extensionConnected };
Expand Down
34 changes: 29 additions & 5 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* 3. No CORS headers — responses never include Access-Control-Allow-Origin
* 4. Body size limit — 1 MB max to prevent OOM
* 5. WebSocket verifyClient — reject upgrade before connection is established
* 6. Token auth — random secret in ~/.opencli/token required on all connections
*
* Lifecycle:
* - Auto-spawned by opencli on first browser command
Expand All @@ -22,8 +23,10 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket, type RawData } from 'ws';
import { DEFAULT_DAEMON_PORT } from './constants.js';
import { getOrCreateToken, verifyToken, TOKEN_HEADER } from './token.js';

const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_TOKEN = getOrCreateToken();
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes

// ─── State ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -110,6 +113,15 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
return;
}

// Token auth — require the shared secret from ~/.opencli/token.
// This blocks local processes that don't have filesystem access to the
// token file, even if they know the port and X-OpenCLI header.
const clientToken = req.headers[TOKEN_HEADER] as string | undefined;
if (!verifyToken(clientToken, DAEMON_TOKEN)) {
jsonResponse(res, 401, { ok: false, error: 'Unauthorized: invalid or missing token' });
return;
}

const url = req.url ?? '/';
const pathname = url.split('?')[0];

Expand Down Expand Up @@ -184,12 +196,24 @@ const wss = new WebSocketServer({
server: httpServer,
path: '/ext',
verifyClient: ({ req }: { req: IncomingMessage }) => {
// Block browser-originated WebSocket connections. Browsers don't
// enforce CORS on WebSocket, so a malicious webpage could connect to
// ws://localhost:19825/ext and impersonate the Extension. Real Chrome
// Extensions send origin chrome-extension://<id>.
// 1. Block browser-originated WebSocket connections. Browsers don't
// enforce CORS on WebSocket, so a malicious webpage could connect to
// ws://localhost:19825/ext and impersonate the Extension. Real Chrome
// Extensions send origin chrome-extension://<id>.
const origin = req.headers['origin'] as string | undefined;
return !origin || origin.startsWith('chrome-extension://');
if (origin && !origin.startsWith('chrome-extension://')) return false;

// 2. Token auth — require the shared secret on WebSocket connections too.
// The token is passed via the Sec-WebSocket-Protocol header or a query
// parameter, since custom headers aren't available in the WebSocket
// constructor.
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
const tokenFromQuery = url.searchParams.get('token');
const tokenFromProtocol = req.headers['sec-websocket-protocol'] as string | undefined;
const clientToken = tokenFromQuery ?? tokenFromProtocol;
if (!verifyToken(clientToken, DAEMON_TOKEN)) return false;

return true;
},
});

Expand Down
126 changes: 126 additions & 0 deletions src/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Daemon authentication token — shared secret between CLI, daemon, and extension.
*
* On first run, a random token is generated and stored at ~/.opencli/token.
* The daemon requires this token on all HTTP and WebSocket connections.
* The CLI and extension read the file to authenticate.
*/

import { randomBytes, timingSafeEqual } from 'node:crypto';
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';

const TOKEN_DIR = path.join(os.homedir(), '.opencli');
const TOKEN_PATH = path.join(TOKEN_DIR, 'token');
const TOKEN_LENGTH = 32; // 32 random bytes → 64-char hex string
const TOKEN_REGEX = /^[0-9a-f]{64}$/; // exactly 64 hex chars

/**
* Constant-time token comparison to prevent timing attacks.
* Returns false if either value is missing or they differ in length.
*/
export function verifyToken(clientToken: string | undefined | null, serverToken: string): boolean {
if (!clientToken) return false;
const a = Buffer.from(clientToken, 'utf-8');
const b = Buffer.from(serverToken, 'utf-8');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

/**
* Restrict file/directory to current user only on Windows.
* On Unix, mode 0o600/0o700 set during creation is sufficient.
*/
function restrictPermissions(filePath: string): void {
if (process.platform !== 'win32') return;
try {
execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, {
stdio: 'ignore',
windowsHide: true,
});
} catch {
console.error(`[token] Warning: could not restrict permissions on ${filePath}`);
}
}

/**
* Get the current daemon token, creating one if it doesn't exist.
* Uses O_EXCL for atomic creation to prevent race conditions when
* multiple daemon processes start simultaneously.
*/
export function getOrCreateToken(): string {
// If token file exists and is valid, return it
try {
const existing = fs.readFileSync(TOKEN_PATH, 'utf-8').trim();
if (TOKEN_REGEX.test(existing)) return existing;
// File exists but is corrupted — remove it so O_EXCL create succeeds
console.error('[token] Token file corrupted, regenerating');
try { fs.unlinkSync(TOKEN_PATH); } catch { /* already gone */ }
} catch {
// File doesn't exist or can't be read — create a new one
}

const token = randomBytes(TOKEN_LENGTH).toString('hex');

// Ensure directory exists with restrictive permissions
try {
fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
restrictPermissions(TOKEN_DIR);
} catch (err) {
throw new Error(
`Cannot create token directory ${TOKEN_DIR}: ${(err as Error).message}. ` +
`Ensure the home directory is writable.`,
);
}

try {
// O_CREAT | O_EXCL | O_WRONLY — fails atomically if file already exists
const fd = fs.openSync(TOKEN_PATH, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
fs.writeSync(fd, token);
fs.closeSync(fd);
restrictPermissions(TOKEN_PATH);
return token;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
// Another process won the race — read their token
const existing = fs.readFileSync(TOKEN_PATH, 'utf-8').trim();
if (TOKEN_REGEX.test(existing)) return existing;
}
throw new Error(
`Cannot write token file ${TOKEN_PATH}: ${(err as Error).message}. ` +
`Ensure ${TOKEN_DIR} is writable.`,
);
}
}

/**
* Read the existing token. Returns null if no token file exists or is invalid.
* Used by clients that should not create a token (only the daemon creates it).
*/
export function readToken(): string | null {
try {
const token = fs.readFileSync(TOKEN_PATH, 'utf-8').trim();
return TOKEN_REGEX.test(token) ? token : null;
} catch {
return null;
}
}

/**
* Generate a new token, replacing the existing one.
* Running daemons must be restarted to pick up the new token.
*/
export function rotateToken(): string {
const token = randomBytes(TOKEN_LENGTH).toString('hex');
fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
const tmpPath = TOKEN_PATH + '.tmp';
fs.writeFileSync(tmpPath, token, { mode: 0o600 });
fs.renameSync(tmpPath, TOKEN_PATH);
restrictPermissions(TOKEN_PATH);
return token;
}

/** Header name used to pass the token */
export const TOKEN_HEADER = 'x-opencli-token';