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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
push:
branches:
- main
- 'security-*'
pull_request:

jobs:
validate:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Type check
run: node_modules/.bin/tsc --noEmit

- name: Test
run: npm test
7 changes: 2 additions & 5 deletions src/daemon/DaemonPipeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import type { RpcRequest, RpcResponse } from '../shared/rpc';
import { secureWriteTokenFile } from '../shared/security';

const MAX_LINE_BUFFER = 1024 * 1024; // 1 MB — prevent OOM from malicious clients

Expand Down Expand Up @@ -52,11 +53,7 @@ export class DaemonPipeServer {

this.authToken = crypto.randomUUID();
// Ensure directory exists
const dir = path.dirname(tokenPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tokenPath, this.authToken, { encoding: 'utf8', mode: 0o600 });
secureWriteTokenFile(tokenPath, this.authToken);
return this.authToken;
}

Expand Down
59 changes: 59 additions & 0 deletions src/main/ipc/handlers/__tests__/fs.handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isSensitivePath, resolveAccessiblePath } from '../fs.handler';

vi.mock('electron', () => ({
ipcMain: {
removeHandler: vi.fn(),
handle: vi.fn(),
},
BrowserWindow: {
getFocusedWindow: vi.fn(),
getAllWindows: vi.fn(() => []),
},
}));

describe('fs.handler security helpers', () => {
const home = path.join('C:', 'Users', 'tester');
let realpathSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(os, 'homedir').mockReturnValue(home);
realpathSpy = vi.spyOn(fs.promises, 'realpath');
});

afterEach(() => {
vi.restoreAllMocks();
});

it('treats the daemon auth token path as sensitive', () => {
expect(isSensitivePath(path.join(home, '.wmux', 'daemon-auth-token'))).toBe(true);
});

it('rejects a symlink whose canonical target is sensitive', async () => {
realpathSpy.mockResolvedValue(path.join(home, '.ssh', 'id_rsa'));

await expect(resolveAccessiblePath(path.join(home, 'project', 'link-to-secret'))).resolves.toBeNull();
});

it('rejects a direct sensitive path before canonical lookup', async () => {
await expect(resolveAccessiblePath(path.join(home, '.wmux-auth-token'))).resolves.toBeNull();
expect(realpathSpy).not.toHaveBeenCalled();
});

it('returns the canonical path for an allowed target', async () => {
const canonical = path.join(home, 'project', 'src', 'index.ts');
realpathSpy.mockResolvedValue(canonical);

await expect(resolveAccessiblePath(path.join(home, 'project', 'src', '..', 'src', 'index.ts'))).resolves.toBe(canonical);
});

it('returns null when canonicalization fails', async () => {
realpathSpy.mockRejectedValue(new Error('ENOENT'));

await expect(resolveAccessiblePath(path.join(home, 'project', 'missing.txt'))).resolves.toBeNull();
});
});
43 changes: 26 additions & 17 deletions src/main/ipc/handlers/fs.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const BLOCKED_FILES = [
'.npmrc',
'.netrc',
'.env',
'.wmux/daemon-auth-token',
];

export function isSensitivePath(resolvedPath: string): boolean {
Expand Down Expand Up @@ -59,6 +60,21 @@ export function isSensitivePath(resolvedPath: string): boolean {
return false;
}

export async function resolveAccessiblePath(inputPath: string): Promise<string | null> {
if (!inputPath || typeof inputPath !== 'string') return null;

const resolved = path.resolve(inputPath);
if (isSensitivePath(resolved)) return null;

try {
const canonical = await fs.promises.realpath(resolved);
if (isSensitivePath(canonical)) return null;
return canonical;
} catch {
return null;
}
}

export function closeAllWatchers(): void {
for (const watcher of watchers.values()) {
watcher.close();
Expand All @@ -73,12 +89,8 @@ export function closeAllWatchers(): void {
export function registerFsHandlers(): () => void {
ipcMain.removeHandler(IPC.FS_READ_DIR);
ipcMain.handle(IPC.FS_READ_DIR, async (_event, dirPath: string): Promise<FileEntry[]> => {
// 보안: 경로 정규화 및 기본 검증
if (!dirPath || typeof dirPath !== 'string') return [];

const resolved = path.resolve(dirPath);

if (isSensitivePath(resolved)) return [];
const resolved = await resolveAccessiblePath(dirPath);
if (!resolved) return [];

try {
const entries = await fs.promises.readdir(resolved, { withFileTypes: true });
Expand Down Expand Up @@ -110,9 +122,8 @@ export function registerFsHandlers(): () => void {

ipcMain.removeHandler(IPC.FS_READ_FILE);
ipcMain.handle(IPC.FS_READ_FILE, async (_event, filePath: string): Promise<string | null> => {
if (!filePath || typeof filePath !== 'string') return null;
const resolved = path.resolve(filePath);
if (isSensitivePath(resolved)) return null;
const resolved = await resolveAccessiblePath(filePath);
if (!resolved) return null;
try {
const stat = await fs.promises.stat(resolved);
if (stat.size > 1024 * 1024) return null; // 1MB limit
Expand All @@ -123,11 +134,9 @@ export function registerFsHandlers(): () => void {
});

ipcMain.removeHandler(IPC.FS_WATCH);
ipcMain.handle(IPC.FS_WATCH, (_event, dirPath: string) => {
if (!dirPath || typeof dirPath !== 'string') return false;
const resolved = path.resolve(dirPath);

if (isSensitivePath(resolved)) return false;
ipcMain.handle(IPC.FS_WATCH, async (_event, dirPath: string) => {
const resolved = await resolveAccessiblePath(dirPath);
if (!resolved) return false;

// Clean up previous watcher for this path
if (watchers.has(resolved)) {
Expand Down Expand Up @@ -168,9 +177,9 @@ export function registerFsHandlers(): () => void {
});

ipcMain.removeHandler(IPC.FS_UNWATCH);
ipcMain.handle(IPC.FS_UNWATCH, (_event, dirPath: string) => {
if (!dirPath || typeof dirPath !== 'string') return;
const resolved = path.resolve(dirPath);
ipcMain.handle(IPC.FS_UNWATCH, async (_event, dirPath: string) => {
const resolved = await resolveAccessiblePath(dirPath);
if (!resolved) return;
const watcher = watchers.get(resolved);
if (watcher) {
watcher.close();
Expand Down
16 changes: 2 additions & 14 deletions src/main/mcp/McpRegistrar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import { getAuthTokenPath, getPipeName } from '../../shared/constants';
import { secureWriteTokenFile } from '../../shared/security';

/**
* Registers/unregisters the wmux MCP server in Claude Code's config files
Expand Down Expand Up @@ -34,20 +35,7 @@ export class McpRegistrar {
register(authToken: string): void {
try {
// Write auth token to file so MCP server can read it
fs.writeFileSync(this.authTokenPath, authToken, { encoding: 'utf8', mode: 0o600 });
// On Windows, mode 0o600 is ignored. Use icacls to enforce owner-only access.
if (process.platform === 'win32') {
try {
const { execFileSync } = require('child_process');
const icacls = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\icacls.exe`;
execFileSync(icacls, [
this.authTokenPath, '/inheritance:r',
'/grant:r', `${process.env.USERNAME}:F`
], { windowsHide: true });
} catch (aclErr) {
console.warn('[McpRegistrar] Could not set file ACL:', aclErr);
}
}
secureWriteTokenFile(this.authTokenPath, authToken);
console.log(`[McpRegistrar] Auth token written to ${this.authTokenPath}`);

const mcpScript = this.getMcpScriptPath();
Expand Down
153 changes: 153 additions & 0 deletions src/main/pipe/handlers/__tests__/browser.rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BrowserWindow } from 'electron';
import { RpcRouter } from '../../RpcRouter';
import { registerBrowserRpc } from '../browser.rpc';

const { validateResolvedNavigationUrlMock } = vi.hoisted(() => ({
validateResolvedNavigationUrlMock: vi.fn(),
}));
const { sendToRendererMock } = vi.hoisted(() => ({
sendToRendererMock: vi.fn(),
}));
const mockWebContents = {
isDestroyed: vi.fn(() => false),
canGoBack: vi.fn(() => true),
goBack: vi.fn(),
loadURL: vi.fn(),
debugger: {
sendCommand: vi.fn(),
},
};

vi.mock('electron', () => ({
webContents: {
fromId: vi.fn(() => mockWebContents),
},
}));

vi.mock('../../../security/navigationPolicy', () => ({
validateResolvedNavigationUrl: validateResolvedNavigationUrlMock,
}));

vi.mock('../_bridge', () => ({
sendToRenderer: sendToRendererMock,
}));

describe('registerBrowserRpc', () => {
beforeEach(() => {
vi.clearAllMocks();
mockWebContents.isDestroyed.mockReturnValue(false);
mockWebContents.canGoBack.mockReturnValue(true);
validateResolvedNavigationUrlMock.mockResolvedValue({ valid: true });
sendToRendererMock.mockResolvedValue({ ok: true });
});

function register(): RpcRouter {
const router = new RpcRouter();
const webviewCdpManager = {
getTarget: vi.fn(() => ({ surfaceId: 'surface-1', webContentsId: 42, targetId: 'target-1', wsUrl: 'ws://127.0.0.1/devtools/page/target-1' })),
listTargets: vi.fn(() => [{ surfaceId: 'surface-1', webContentsId: 42, targetId: 'target-1', wsUrl: 'ws://127.0.0.1/devtools/page/target-1' }]),
getCdpPort: vi.fn(() => 18800),
waitForTarget: vi.fn(),
};

registerBrowserRpc(router, (() => null) as () => BrowserWindow | null, webviewCdpManager as never);
return router;
}

it('does not expose browser.cdp.send through the RPC router', async () => {
const router = register();

const response = await router.dispatch({
id: '1',
method: 'browser.cdp.send' as never,
params: { method: 'Page.navigate' },
});

expect(response.ok).toBe(false);
if (!response.ok) {
expect(response.error).toContain('Unknown method: browser.cdp.send');
}
});

it('browser.goBack uses the reviewed navigation method instead of raw CDP', async () => {
const router = register();

const response = await router.dispatch({
id: '2',
method: 'browser.goBack',
params: {},
});

expect(response.ok).toBe(true);
expect(mockWebContents.goBack).toHaveBeenCalledTimes(1);
expect(mockWebContents.debugger.sendCommand).not.toHaveBeenCalled();
});

it('browser.cdp.info only returns minimal target metadata', async () => {
const router = register();

const response = await router.dispatch({
id: '3',
method: 'browser.cdp.info',
params: {},
});

expect(response.ok).toBe(true);
if (response.ok) {
expect(response.result).toEqual({
cdpPort: 18800,
targets: [{ surfaceId: 'surface-1', targetId: 'target-1' }],
});
}
});

it('browser.navigate rejects URLs whose resolved targets are blocked', async () => {
validateResolvedNavigationUrlMock.mockResolvedValue({
valid: false,
reason: 'Blocked resolved address 169.254.169.254: Blocked link-local/cloud metadata address (169.254.0.0/16)',
});

const router = register();
const response = await router.dispatch({
id: '4',
method: 'browser.navigate',
params: { url: 'https://metadata.example' },
});

expect(response.ok).toBe(false);
if (!response.ok) {
expect(response.error).toContain('browser.navigate: Blocked resolved address 169.254.169.254');
}
expect(mockWebContents.loadURL).not.toHaveBeenCalled();
});

it('browser.open passes the active profile partition to the renderer', async () => {
const router = register();

await router.dispatch({
id: '5',
method: 'browser.open',
params: {},
});

expect(sendToRendererMock).toHaveBeenCalledWith(expect.any(Function), 'browser.open', {
partition: 'persist:wmux-default',
});
});

it('browser.session.start applies the selected partition to renderer browser surfaces', async () => {
const router = register();

const response = await router.dispatch({
id: '6',
method: 'browser.session.start',
params: { profile: 'login' },
});

expect(response.ok).toBe(true);
expect(sendToRendererMock).toHaveBeenCalledWith(expect.any(Function), 'browser.session.applyProfile', {
partition: 'persist:wmux-login',
});
});
});
Loading
Loading