Skip to content
Merged
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
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"dompurify": "^3.4.11",
"electron-updater": "^6.3.0",
"marked": "^15.0.0",
"node-pty": "^1.0.0",
Expand Down
5 changes: 3 additions & 2 deletions resources/cli/wmux-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
Object.defineProperty(exports, "__esModule", { value: true });
const net_1 = __importDefault(require("net"));
const tool = process.argv[2] || 'unknown';
const pipePath = '\\\\.\\pipe\\wmux';
const pipePath = process.env.WMUX_PIPE || '\\\\.\\pipe\\wmux';
const token = process.env.WMUX_PIPE_TOKEN || '';

let stdinData = '';
let sent = false;
Expand Down Expand Up @@ -42,7 +43,7 @@ function sendHook() {
if (file) params.file = file;

const client = net_1.default.connect({ path: pipePath }, () => {
const msg = JSON.stringify({ method: 'hook.event', params, id: 1 });
const msg = JSON.stringify({ method: 'hook.event', params, id: 1, token });
client.write(msg + '\n', () => client.end());
});
client.on('error', () => {
Expand Down
22 changes: 21 additions & 1 deletion resources/cli/wmux.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,29 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", { value: true });
const net_1 = __importDefault(require("net"));
const fs_1 = __importDefault(require("fs"));
const os_1 = __importDefault(require("os"));
const path_1 = __importDefault(require("path"));
// Respect WMUX_PIPE when set (e.g. by a parent wmux running with WMUX_INSTANCE),
// so the CLI talks to the same instance that spawned the shell.
const PIPE_PATH = process.env.WMUX_PIPE || '\\\\.\\pipe\\wmux';
// Auth token for privileged (V2) pipe requests. wmux injects WMUX_PIPE_TOKEN
// into the shells it spawns; for CLIs launched elsewhere, fall back to the
// token file in the instance's APPDATA dir (readable only by this user).
function readPipeToken() {
const fromEnv = process.env.WMUX_PIPE_TOKEN?.trim();
if (fromEnv)
return fromEnv;
try {
const suffix = process.env.WMUX_INSTANCE?.trim() ? `-${process.env.WMUX_INSTANCE.trim()}` : '';
const base = process.env.APPDATA || path_1.default.join(os_1.default.homedir(), 'AppData', 'Roaming');
return fs_1.default.readFileSync(path_1.default.join(base, `wmux${suffix}`, 'pipe-token'), 'utf-8').trim();
}
catch {
return '';
}
}
const PIPE_TOKEN = readPipeToken();
function sendV1(command) {
return new Promise((resolve, reject) => {
const client = net_1.default.connect({ path: PIPE_PATH }, () => {
Expand All @@ -23,7 +43,7 @@ function sendV1(command) {
function sendV2(method, params = {}) {
return new Promise((resolve, reject) => {
const client = net_1.default.connect({ path: PIPE_PATH }, () => {
const request = JSON.stringify({ method, params, id: 1 });
const request = JSON.stringify({ method, params, id: 1, token: PIPE_TOKEN });
client.write(request + '\n');
});
let data = '';
Expand Down
3 changes: 2 additions & 1 deletion src/cli/wmux-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import net from 'net';

const tool = process.argv[2] || 'unknown';
const pipePath = process.env.WMUX_PIPE || '\\\\.\\pipe\\wmux';
const token = process.env.WMUX_PIPE_TOKEN || '';

const client = net.connect({ path: pipePath }, () => {
const msg = JSON.stringify({ method: 'hook.event', params: { tool }, id: 1 });
const msg = JSON.stringify({ method: 'hook.event', params: { tool }, id: 1, token });
client.write(msg + '\n', () => client.end());
});

Expand Down
21 changes: 20 additions & 1 deletion src/cli/wmux.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
#!/usr/bin/env node

import net from 'net';
import fs from 'fs';
import os from 'os';
import path from 'path';

// Respect WMUX_PIPE when set (e.g. by a parent wmux running with WMUX_INSTANCE),
// so the CLI talks to the same instance that spawned the shell.
const PIPE_PATH = process.env.WMUX_PIPE || '\\\\.\\pipe\\wmux';

// Auth token for privileged (V2) pipe requests. wmux injects WMUX_PIPE_TOKEN
// into the shells it spawns; for CLIs launched elsewhere, fall back to the
// token file in the instance's APPDATA dir (readable only by this user).
function readPipeToken(): string {
const fromEnv = process.env.WMUX_PIPE_TOKEN?.trim();
if (fromEnv) return fromEnv;
try {
const suffix = process.env.WMUX_INSTANCE?.trim() ? `-${process.env.WMUX_INSTANCE.trim()}` : '';
const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return fs.readFileSync(path.join(base, `wmux${suffix}`, 'pipe-token'), 'utf-8').trim();
} catch {
return '';
}
}
const PIPE_TOKEN = readPipeToken();

function sendV1(command: string): Promise<string> {
return new Promise((resolve, reject) => {
const client = net.connect({ path: PIPE_PATH }, () => {
Expand All @@ -22,7 +41,7 @@ function sendV1(command: string): Promise<string> {
function sendV2(method: string, params: Record<string, any> = {}): Promise<any> {
return new Promise((resolve, reject) => {
const client = net.connect({ path: PIPE_PATH }, () => {
const request = JSON.stringify({ method, params, id: 1 });
const request = JSON.stringify({ method, params, id: 1, token: PIPE_TOKEN });
client.write(request + '\n');
});
let data = '';
Expand Down
66 changes: 64 additions & 2 deletions src/main/cdp-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,52 @@ import { webContents } from 'electron';
const DEFAULT_PORT = 9222;
const MAX_PORT = 9230;

// DNS-rebinding guard. The proxy binds to loopback only, but a browser on the
// same machine can still reach it if a malicious page resolves an attacker
// domain to 127.0.0.1. Chrome's own remote-debugging endpoint rejects such
// requests by requiring the Host header to be a loopback literal (or absent,
// as with non-HTTP WebSocket/native clients). We mirror that policy so the
// full CDP surface (Runtime.evaluate ⇒ arbitrary JS in the webview) can't be
// driven from a web origin.
export function isAllowedCdpHost(hostHeader: string | undefined): boolean {
// Native CDP clients (e.g. raw ws) may omit Host — allow only when absent.
if (hostHeader === undefined) return true;
// Strip optional :port. Bracketed IPv6 arrives as "[::1]:9222"; a bare IPv6
// literal ("::1") has multiple colons and no port to strip.
let host = hostHeader.trim();
if (host.startsWith('[')) {
const end = host.indexOf(']');
host = end === -1 ? host.slice(1) : host.slice(1, end);
} else {
const colon = host.indexOf(':');
// Only treat a single trailing :port as a port (IPv4 / hostname). Multiple
// colons with no brackets ⇒ bare IPv6 literal, leave intact.
if (colon !== -1 && host.indexOf(':', colon + 1) === -1) {
host = host.slice(0, colon);
}
}
host = host.toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0:0:0:0:0:0:0:1';
}

// Origin guard for the WebSocket upgrade. The Host check alone is NOT enough:
// WebSocket connections are exempt from CORS preflight, so a malicious page in
// the user's browser can open ws://127.0.0.1:9222 directly — the browser sends
// Host: 127.0.0.1:9222 (which passes isAllowedCdpHost) but also an Origin
// header identifying the web page. Driving the proxy then yields
// Runtime.evaluate (arbitrary JS in the webview) ⇒ RCE-equivalent.
//
// Legit CDP clients (chrome-devtools-mcp / puppeteer-core / raw `ws`) do NOT
// send an Origin header, while browsers ALWAYS send one for a page-initiated
// WebSocket. So we allow only an absent Origin (plus the DevTools front-end
// scheme) and reject every web/file origin — mirroring Chrome's own
// --remote-allow-origins policy.
export function isAllowedCdpOrigin(origin: string | undefined): boolean {
if (origin === undefined || origin === '') return true;
if (origin.toLowerCase().startsWith('devtools://')) return true;
return false;
}

export class CDPProxy {
private server: http.Server | null = null;
private wss: WebSocketServer | null = null;
Expand All @@ -31,6 +77,14 @@ export class CDPProxy {
this.server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');

// Reject cross-origin (DNS-rebinding) requests before exposing any
// CDP target metadata or WebSocket debugger URLs.
if (!isAllowedCdpHost(req.headers.host)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden host' }));
return;
}

if (req.url === '/json/version') {
res.end(JSON.stringify({
Browser: 'Chrome/133.0.0.0',
Expand Down Expand Up @@ -67,8 +121,16 @@ export class CDPProxy {
res.end('{}');
});

// WebSocket server using ws library (handles handshake properly)
this.wss = new WebSocketServer({ server: this.server });
// WebSocket server using ws library (handles handshake properly).
// verifyClient applies BOTH a loopback-only Host policy AND an Origin policy
// to the WS upgrade. The Host check stops DNS-rebinding; the Origin check
// stops a page in the user's own browser from opening this debugger socket
// directly (WebSockets bypass CORS, so a passing Host isn't sufficient).
this.wss = new WebSocketServer({
server: this.server,
verifyClient: (info: { req: http.IncomingMessage }) =>
isAllowedCdpHost(info.req.headers.host) && isAllowedCdpOrigin(info.req.headers.origin),
});

this.wss.on('connection', (ws) => {
if (!this.webContentsId) {
Expand Down
72 changes: 70 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { GitPoller } from './git-poller';
import { PrPoller } from './pr-poller';
import { CDPProxy } from './cdp-proxy';
import { IPC_CHANNELS, SurfaceId } from '../shared/types';
import { getPipePath, getAppDataDir } from '../shared/instance';
import { getPipePath, getAppDataDir, ensurePipeToken } from '../shared/instance';
import { loadSession, saveSession, handleVersionChange, SessionData } from './session-persistence';
import { WindowManager } from './window-manager';
import { initAutoUpdater } from './updater';
Expand Down Expand Up @@ -43,7 +43,12 @@ async function ensureBrowserPanel(): Promise<boolean> {
}

const windowManager = new WindowManager();
const pipeServer = new PipeServer(getPipePath());
// Per-instance secret that authenticates privileged (V2) pipe requests.
// Generated/persisted once per APPDATA dir and injected into spawned shells
// as WMUX_PIPE_TOKEN so the CLI and hooks can authenticate.
const pipeToken = ensurePipeToken();
process.env.WMUX_PIPE_TOKEN = pipeToken;
const pipeServer = new PipeServer(getPipePath(), pipeToken);
const portScanner = new PortScanner();
const gitPoller = new GitPoller();
const prPoller = new PrPoller();
Expand Down Expand Up @@ -188,9 +193,56 @@ if (!gotInstanceLock) {
});
}

// ─── Webview / navigation hardening (issue #9) ────────────────────────────────
// The renderer hosts <webview> tags that load arbitrary web content. Lock down
// the attack surface so a compromised/hostile page can't escalate:
// - strip Node integration & preload from attached webviews
// - block window.open popups (route http/https to the OS browser instead)
// - prevent the top-level app window from navigating away from its own UI
function hardenWebContents(): void {
app.on('web-contents-created', (_event, contents) => {
const type = contents.getType();

if (type === 'webview') {
// Enforce safe webview preferences regardless of attributes set in the DOM.
contents.on('will-attach-webview', (_e, webPreferences, params) => {
delete (webPreferences as any).preload;
delete (webPreferences as any).preloadURL;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
(params as any).nodeintegration = 'false';
});
}

// Open new-window requests externally rather than spawning in-app windows
// with full privileges. Only http/https go to the OS browser; deny the rest.
contents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) {
void shell.openExternal(url);
}
return { action: 'deny' };
});

// The main app window (loads localhost in dev, file:// in prod) must never
// be navigated to remote content. Webviews host their own contents and are
// exempt — their navigation is the whole point.
if (type !== 'webview') {
contents.on('will-navigate', (e, url) => {
const isDevServer = url.startsWith('http://localhost:') || url.startsWith('http://127.0.0.1:');
const isLocalFile = url.startsWith('file://');
if (!isDevServer && !isLocalFile) {
e.preventDefault();
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
}
});
}
});
}

app.whenReady().then(() => {
// A losing second instance is already quitting; don't run startup side effects.
if (!gotInstanceLock) return;
hardenWebContents();
// Inject wmux instructions into ~/.claude/CLAUDE.md for Claude Code awareness
ensureClaudeContext();
ensureClaudeHooks();
Expand Down Expand Up @@ -666,6 +718,22 @@ app.whenReady().then(() => {
try {
const filePath = request.params?.filePath || request.params?.path || request.params?.file;
if (!filePath) { respondError(-32000, 'No file path provided'); return; }
// Defense-in-depth: even with a valid pipe token, only render plain
// text/markdown files and cap the size, so this can't be used to
// slurp secrets (e.g. id_rsa, .env) into the markdown viewer.
const ALLOWED_MD_EXT = new Set(['.md', '.markdown', '.mdx', '.txt', '.text', '.rst']);
const ext = path.extname(filePath).toLowerCase();
if (!ALLOWED_MD_EXT.has(ext)) {
respondError(-32602, `Unsupported file type for markdown.load_file: ${ext || '(none)'}`);
return;
}
let stat: fs.Stats;
try { stat = fs.statSync(filePath); } catch { respondError(-32000, 'File not found'); return; }
const MAX_MD_BYTES = 5 * 1024 * 1024;
if (!stat.isFile() || stat.size > MAX_MD_BYTES) {
respondError(-32602, 'File is not a regular file or exceeds 5MB limit');
return;
}
const content = fs.readFileSync(filePath, 'utf-8');
const win = BrowserWindow.getAllWindows()[0];
if (!win || win.isDestroyed()) { respondError(-32000, 'No window'); return; }
Expand Down
Loading