diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c935e9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/daemon/DaemonPipeServer.ts b/src/daemon/DaemonPipeServer.ts index d640b08..b9ebd49 100644 --- a/src/daemon/DaemonPipeServer.ts +++ b/src/daemon/DaemonPipeServer.ts @@ -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 @@ -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; } diff --git a/src/main/ipc/handlers/__tests__/fs.handler.test.ts b/src/main/ipc/handlers/__tests__/fs.handler.test.ts new file mode 100644 index 0000000..a0f309a --- /dev/null +++ b/src/main/ipc/handlers/__tests__/fs.handler.test.ts @@ -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; + + 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(); + }); +}); diff --git a/src/main/ipc/handlers/fs.handler.ts b/src/main/ipc/handlers/fs.handler.ts index f604f74..78d5d97 100644 --- a/src/main/ipc/handlers/fs.handler.ts +++ b/src/main/ipc/handlers/fs.handler.ts @@ -31,6 +31,7 @@ const BLOCKED_FILES = [ '.npmrc', '.netrc', '.env', + '.wmux/daemon-auth-token', ]; export function isSensitivePath(resolvedPath: string): boolean { @@ -59,6 +60,21 @@ export function isSensitivePath(resolvedPath: string): boolean { return false; } +export async function resolveAccessiblePath(inputPath: string): Promise { + 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(); @@ -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 => { - // 보안: 경로 정규화 및 기본 검증 - 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 }); @@ -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 => { - 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 @@ -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)) { @@ -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(); diff --git a/src/main/mcp/McpRegistrar.ts b/src/main/mcp/McpRegistrar.ts index 778a71b..a0775ec 100644 --- a/src/main/mcp/McpRegistrar.ts +++ b/src/main/mcp/McpRegistrar.ts @@ -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 @@ -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(); diff --git a/src/main/pipe/handlers/__tests__/browser.rpc.test.ts b/src/main/pipe/handlers/__tests__/browser.rpc.test.ts new file mode 100644 index 0000000..67845cd --- /dev/null +++ b/src/main/pipe/handlers/__tests__/browser.rpc.test.ts @@ -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', + }); + }); +}); diff --git a/src/main/pipe/handlers/browser.rpc.ts b/src/main/pipe/handlers/browser.rpc.ts index 94eda36..496df96 100644 --- a/src/main/pipe/handlers/browser.rpc.ts +++ b/src/main/pipe/handlers/browser.rpc.ts @@ -6,12 +6,12 @@ import { ProfileManager } from '../../browser-session/ProfileManager'; import { PortAllocator } from '../../browser-session/PortAllocator'; import { HumanBehavior } from '../../browser-session/HumanBehavior'; import { WebviewCdpManager } from '../../browser-session/WebviewCdpManager'; -import { validateNavigationUrl } from '../../../shared/types'; +import { validateResolvedNavigationUrl } from '../../security/navigationPolicy'; type GetWindow = () => BrowserWindow | null; -function validateUrl(url: string, method: string): void { - const result = validateNavigationUrl(url); +async function validateUrl(url: string, method: string): Promise { + const result = await validateResolvedNavigationUrl(url); if (!result.valid) { throw new Error(`${method}: ${result.reason}`); } @@ -29,15 +29,18 @@ const portAllocator = new PortAllocator(); const humanBehavior = new HumanBehavior(); export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webviewCdpManager: WebviewCdpManager): void { + const getActivePartition = (): string => profileManager.getActiveProfile().partition; + /** * browser.open * Opens a new browser surface in the active pane. * params: { url?: string } */ - router.register('browser.open', (params) => { + router.register('browser.open', async (params) => { const url = typeof params['url'] === 'string' ? params['url'] : undefined; - if (url) validateUrl(url, 'browser.open'); + if (url) await validateUrl(url, 'browser.open'); return sendToRenderer(getWindow, 'browser.open', { + partition: getActivePartition(), ...(url && { url }), }); }); @@ -64,7 +67,7 @@ export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webv if (typeof params['url'] !== 'string' || params['url'].length === 0) { throw new Error('browser.navigate: missing required param "url"'); } - validateUrl(params['url'], 'browser.navigate'); + await validateUrl(params['url'], 'browser.navigate'); const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined; // Try CDP direct navigation first @@ -88,6 +91,43 @@ export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webv }); }); + /** + * browser.goBack + * Navigate the active browser Surface back by one history entry. + * params: { surfaceId?: string } + */ + router.register('browser.goBack', async (params) => { + const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined; + + const target = webviewCdpManager.getTarget(surfaceId); + if (!target) throw new Error('browser.goBack: no webview target registered'); + + const wc = webContents.fromId(target.webContentsId); + if (!wc || wc.isDestroyed()) throw new Error('browser.goBack: WebContents unavailable'); + + const navigationHistory = (wc as Electron.WebContents & { + navigationHistory?: { + canGoBack?: () => boolean; + goBack?: () => void; + }; + canGoBack?: () => boolean; + goBack?: () => void; + }).navigationHistory; + + const canGoBack = navigationHistory?.canGoBack?.() ?? wc.canGoBack?.() ?? false; + if (!canGoBack) { + return { ok: false, reason: 'no history entry' }; + } + + if (navigationHistory?.goBack) { + navigationHistory.goBack(); + } else { + wc.goBack(); + } + + return { ok: true }; + }); + // ── Session handlers ──────────────────────────────────────────────────── /** @@ -95,7 +135,6 @@ export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webv * Start a browser session with an optional profile. * params: { profile?: string } */ - // TODO: Wire profile partition to renderer webview — currently data-only stub router.register('browser.session.start', async (params) => { const profileName = typeof params['profile'] === 'string' ? params['profile'] : 'default'; let profile = profileManager.getProfile(profileName); @@ -103,6 +142,9 @@ export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webv profile = profileManager.createProfile(profileName, true); } profileManager.setActiveProfile(profileName); + await sendToRenderer(getWindow, 'browser.session.applyProfile', { + partition: profile.partition, + }); const port = await portAllocator.allocate(); return { profile: profile.name, @@ -116,13 +158,15 @@ export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webv * browser.session.stop * Stop the active browser session and release resources. */ - // TODO: Wire profile partition to renderer webview — currently data-only stub router.register('browser.session.stop', async () => { const port = portAllocator.getPort(); if (port !== null) { portAllocator.release(port); } profileManager.setActiveProfile('default'); + await sendToRenderer(getWindow, 'browser.session.applyProfile', { + partition: getActivePartition(), + }); return { stopped: true }; }); @@ -186,7 +230,7 @@ export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webv /** * browser.cdp.info - * Returns the CDP port and all registered webview targets. + * Returns the CDP port and minimal target metadata required for Playwright attachment. * params: none */ router.register('browser.cdp.info', async () => { @@ -204,35 +248,11 @@ export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow, webv cdpPort, targets: targets.map((t) => ({ surfaceId: t.surfaceId, - webContentsId: t.webContentsId, targetId: t.targetId, - wsUrl: t.wsUrl, // Internal use only — needed by PlaywrightEngine to connect to webview })), }; }); - /** - * browser.cdp.send - * Proxy any CDP command to the webview via webContents.debugger. - * params: { method: string, params?: object, surfaceId?: string } - */ - router.register('browser.cdp.send', async (params) => { - const method = typeof params['method'] === 'string' ? params['method'] : ''; - if (!method) throw new Error('browser.cdp.send: missing "method"'); - const cdpParams = (typeof params['params'] === 'object' && params['params'] !== null) - ? params['params'] as Record - : {}; - const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined; - - const target = webviewCdpManager.getTarget(surfaceId); - if (!target) throw new Error('browser.cdp.send: no webview target registered'); - - const wc = webContents.fromId(target.webContentsId); - if (!wc || wc.isDestroyed()) throw new Error('browser.cdp.send: WebContents unavailable'); - - return await wc.debugger.sendCommand(method, cdpParams); - }); - /** * browser.screenshot * Capture a screenshot of the webview. diff --git a/src/main/security/__tests__/navigationPolicy.test.ts b/src/main/security/__tests__/navigationPolicy.test.ts new file mode 100644 index 0000000..0b4c815 --- /dev/null +++ b/src/main/security/__tests__/navigationPolicy.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateResolvedNavigationUrl } from '../navigationPolicy'; + +const { lookupMock } = vi.hoisted(() => ({ + lookupMock: vi.fn(), +})); + +vi.mock('node:dns/promises', () => ({ + lookup: lookupMock, +})); + +describe('validateResolvedNavigationUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('allows public resolved addresses', async () => { + lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + + await expect(validateResolvedNavigationUrl('https://example.com')).resolves.toEqual({ valid: true }); + }); + + it('blocks hostnames that resolve to private IPv4 space', async () => { + lookupMock.mockResolvedValue([{ address: '192.168.1.25', family: 4 }]); + + await expect(validateResolvedNavigationUrl('https://internal.example')).resolves.toEqual({ + valid: false, + reason: 'Blocked resolved address 192.168.1.25: Blocked private IP address (192.168.0.0/16)', + }); + }); + + it('blocks hostnames that resolve to cloud metadata space', async () => { + lookupMock.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]); + + await expect(validateResolvedNavigationUrl('https://metadata.example')).resolves.toEqual({ + valid: false, + reason: 'Blocked resolved address 169.254.169.254: Blocked link-local/cloud metadata address (169.254.0.0/16)', + }); + }); + + it('blocks mixed resolution results when any resolved address is private', async () => { + lookupMock.mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.9', family: 4 }, + ]); + + await expect(validateResolvedNavigationUrl('https://mixed.example')).resolves.toEqual({ + valid: false, + reason: 'Blocked resolved address 10.0.0.9: Blocked private IP address (10.0.0.0/8)', + }); + }); + + it('allows localhost without DNS resolution', async () => { + await expect(validateResolvedNavigationUrl('http://localhost:3000')).resolves.toEqual({ valid: true }); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it('blocks private IPv6 targets after hostname resolution', async () => { + lookupMock.mockResolvedValue([{ address: 'fd12:3456:789a::1', family: 6 }]); + + await expect(validateResolvedNavigationUrl('https://ipv6.example')).resolves.toEqual({ + valid: false, + reason: 'Blocked resolved address fd12:3456:789a::1: Blocked private IPv6 address (fc00::/7)', + }); + }); + + it('blocks IPv6-mapped IPv4 targets after hostname resolution', async () => { + lookupMock.mockResolvedValue([{ address: '::ffff:169.254.169.254', family: 6 }]); + + await expect(validateResolvedNavigationUrl('https://mapped-ipv4.example')).resolves.toEqual({ + valid: false, + reason: 'Blocked resolved address ::ffff:169.254.169.254: Blocked link-local/cloud metadata address (169.254.0.0/16)', + }); + }); +}); diff --git a/src/main/security/navigationPolicy.ts b/src/main/security/navigationPolicy.ts new file mode 100644 index 0000000..778eaea --- /dev/null +++ b/src/main/security/navigationPolicy.ts @@ -0,0 +1,158 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import { validateNavigationUrl } from '../../shared/types'; + +interface ValidationResult { + valid: boolean; + reason?: string; +} + +function validateIpv4Address(address: string): ValidationResult { + const octets = address.split('.').map((part) => Number.parseInt(part, 10)); + if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) { + return { valid: false, reason: `Invalid IPv4 address: ${address}` }; + } + + if (octets.every((octet) => octet === 0)) { + return { valid: false, reason: 'Blocked null address (0.0.0.0)' }; + } + if (octets[0] === 10) { + return { valid: false, reason: 'Blocked private IP address (10.0.0.0/8)' }; + } + if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) { + return { valid: false, reason: 'Blocked private IP address (172.16.0.0/12)' }; + } + if (octets[0] === 192 && octets[1] === 168) { + return { valid: false, reason: 'Blocked private IP address (192.168.0.0/16)' }; + } + if (octets[0] === 169 && octets[1] === 254) { + return { valid: false, reason: 'Blocked link-local/cloud metadata address (169.254.0.0/16)' }; + } + if (octets[0] === 127) { + return { valid: true }; + } + + return { valid: true }; +} + +function expandIpv6Address(address: string): string[] | null { + let normalized = address.toLowerCase(); + const lastColon = normalized.lastIndexOf(':'); + if (normalized.includes('.') && lastColon !== -1) { + const embeddedIpv4 = normalized.slice(lastColon + 1); + const ipv4Validation = validateIpv4Address(embeddedIpv4); + if (ipv4Validation.reason?.startsWith('Invalid IPv4 address:')) { + return null; + } + + const octets = embeddedIpv4.split('.').map((part) => Number.parseInt(part, 10)); + const hi = ((octets[0] << 8) | octets[1]).toString(16); + const lo = ((octets[2] << 8) | octets[3]).toString(16); + normalized = `${normalized.slice(0, lastColon)}:${hi}:${lo}`; + } + + const [head, tail] = normalized.split('::'); + + if (normalized.split('::').length > 2) return null; + + const headParts = head ? head.split(':').filter(Boolean) : []; + const tailParts = tail ? tail.split(':').filter(Boolean) : []; + + if ([...headParts, ...tailParts].some((part) => !/^[0-9a-f]{1,4}$/.test(part))) { + return null; + } + + if (!normalized.includes('::')) { + return headParts.length === 8 ? headParts.map((part) => part.padStart(4, '0')) : null; + } + + const missingGroups = 8 - (headParts.length + tailParts.length); + if (missingGroups < 1) return null; + + return [ + ...headParts.map((part) => part.padStart(4, '0')), + ...Array.from({ length: missingGroups }, () => '0000'), + ...tailParts.map((part) => part.padStart(4, '0')), + ]; +} + +function validateIpv6Address(address: string): ValidationResult { + const expanded = expandIpv6Address(address); + if (!expanded) { + return { valid: false, reason: `Invalid IPv6 address: ${address}` }; + } + + if (expanded.slice(0, 5).every((group) => group === '0000') && expanded[5] === 'ffff') { + const hi = Number.parseInt(expanded[6], 16); + const lo = Number.parseInt(expanded[7], 16); + const ipv4 = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; + return validateIpv4Address(ipv4); + } + + const compact = expanded.join(':'); + if (compact === '0000:0000:0000:0000:0000:0000:0000:0000') { + return { valid: false, reason: 'Blocked null IPv6 address (equivalent to 0.0.0.0)' }; + } + if (compact === '0000:0000:0000:0000:0000:0000:0000:0001') { + return { valid: true }; + } + + const firstGroup = Number.parseInt(expanded[0], 16); + if ((firstGroup & 0xfe00) === 0xfc00) { + return { valid: false, reason: 'Blocked private IPv6 address (fc00::/7)' }; + } + if ((firstGroup & 0xffc0) === 0xfe80) { + return { valid: false, reason: 'Blocked link-local IPv6 address (fe80::/10)' }; + } + + return { valid: true }; +} + +function validateResolvedAddress(address: string): ValidationResult { + const family = isIP(address); + if (family === 4) return validateIpv4Address(address); + if (family === 6) return validateIpv6Address(address); + return { valid: false, reason: `Resolved non-IP address: ${address}` }; +} + +export async function validateResolvedNavigationUrl(url: string): Promise { + const basic = validateNavigationUrl(url); + if (!basic.valid) return basic; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { valid: false, reason: 'Invalid URL' }; + } + + const hostname = parsed.hostname; + if (hostname === 'localhost') { + return { valid: true }; + } + + if (isIP(hostname)) { + return validateResolvedAddress(hostname); + } + + let addresses: Array<{ address: string }>; + try { + addresses = await lookup(hostname, { all: true, verbatim: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { valid: false, reason: `Failed to resolve hostname "${hostname}": ${message}` }; + } + + if (addresses.length === 0) { + return { valid: false, reason: `Hostname "${hostname}" did not resolve to an IP address` }; + } + + for (const { address } of addresses) { + const resolved = validateResolvedAddress(address); + if (!resolved.valid) { + return { valid: false, reason: `Blocked resolved address ${address}: ${resolved.reason}` }; + } + } + + return { valid: true }; +} diff --git a/src/mcp/playwright/PlaywrightEngine.ts b/src/mcp/playwright/PlaywrightEngine.ts index 1b17dae..affece6 100644 --- a/src/mcp/playwright/PlaywrightEngine.ts +++ b/src/mcp/playwright/PlaywrightEngine.ts @@ -3,9 +3,7 @@ import { sendRpc } from '../wmux-client'; interface CdpTargetInfo { surfaceId: string; - webContentsId: number; targetId: string; - wsUrl: string; } interface CdpInfoResponse { diff --git a/src/mcp/playwright/tools/__tests__/utility.test.ts b/src/mcp/playwright/tools/__tests__/utility.test.ts new file mode 100644 index 0000000..876d47f --- /dev/null +++ b/src/mcp/playwright/tools/__tests__/utility.test.ts @@ -0,0 +1,32 @@ +import * as os from 'os'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +import { resolveBrowserExportPath } from '../utility'; + +describe('resolveBrowserExportPath', () => { + const exportRoot = path.join(os.homedir(), '.wmux', 'exports'); + + it('resolves default exports under the wmux export root', () => { + expect(resolveBrowserExportPath(undefined, 'output.pdf')).toBe( + path.join(exportRoot, 'output.pdf'), + ); + }); + + it('rejects absolute output paths', () => { + expect(() => resolveBrowserExportPath(path.join(exportRoot, 'secret.pdf'), 'output.pdf')).toThrow( + 'Absolute output paths are not allowed', + ); + }); + + it('rejects traversal outside the export root', () => { + expect(() => resolveBrowserExportPath('../outside.zip', 'trace.zip')).toThrow( + 'Output path escapes the export root', + ); + }); + + it('allows nested relative paths inside the export root', () => { + expect(resolveBrowserExportPath('reports/run-1/trace.zip', 'trace.zip')).toBe( + path.join(exportRoot, 'reports', 'run-1', 'trace.zip'), + ); + }); +}); diff --git a/src/mcp/playwright/tools/navigation.ts b/src/mcp/playwright/tools/navigation.ts index 565bd75..fdf91db 100644 --- a/src/mcp/playwright/tools/navigation.ts +++ b/src/mcp/playwright/tools/navigation.ts @@ -67,19 +67,12 @@ export function registerNavigationTools(server: McpServer): void { }, async ({ surfaceId }) => { try { - // Use CDP via RPC for reliability - await sendRpc('browser.cdp.send', { - method: 'Page.navigateToHistoryEntry', - params: {}, + await sendRpc('browser.goBack', { ...(surfaceId && { surfaceId }), - }).catch(() => { - // Fallback: use history navigation via JS evaluation - return sendRpc('browser.evaluate', { - expression: 'history.back()', - ...(surfaceId && { surfaceId }), - }); }); + await new Promise((resolve) => setTimeout(resolve, 300)); + // Get current URL const urlResult = await sendRpc('browser.evaluate', { expression: 'location.href', diff --git a/src/mcp/playwright/tools/utility.ts b/src/mcp/playwright/tools/utility.ts index b084804..eaa270a 100644 --- a/src/mcp/playwright/tools/utility.ts +++ b/src/mcp/playwright/tools/utility.ts @@ -1,6 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { PlaywrightEngine } from '../PlaywrightEngine'; // Optional surfaceId schema reused across tools @@ -9,6 +11,30 @@ const optionalSurfaceId = z .optional() .describe('Target a specific surface by ID. Omit to use the active surface.'); +function getExportRoot(): string { + return path.join(os.homedir(), '.wmux', 'exports'); +} + +export function resolveBrowserExportPath(requestedPath: string | undefined, defaultFileName: string): string { + const exportRoot = getExportRoot(); + const candidate = requestedPath?.trim() || defaultFileName; + if (path.isAbsolute(candidate)) { + throw new Error(`Absolute output paths are not allowed. Use a relative path under ${exportRoot}`); + } + + const resolved = path.resolve(exportRoot, candidate); + const relative = path.relative(exportRoot, resolved); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Output path escapes the export root. Use a relative path under ${exportRoot}`); + } + + return resolved; +} + +async function ensureExportDir(filePath: string): Promise { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); +} + /** * Register utility MCP tools on the given server. * @@ -29,13 +55,13 @@ export function registerUtilityTools(server: McpServer): void { path: z .string() .optional() - .describe('Output file path for the PDF. Defaults to "output.pdf".'), + .describe('Relative output path under ~/.wmux/exports. Defaults to "output.pdf".'), surfaceId: optionalSurfaceId, }, async ({ path: outputPath, surfaceId }) => { - const resolvedPath = outputPath ?? 'output.pdf'; - try { + const resolvedPath = resolveBrowserExportPath(outputPath, 'output.pdf'); + await ensureExportDir(resolvedPath); const page = await engine.getPage(surfaceId); if (!page) { throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).'); @@ -103,7 +129,7 @@ export function registerUtilityTools(server: McpServer): void { path: z .string() .optional() - .describe('Output file path for the trace (used with "stop"). Defaults to "trace.zip".'), + .describe('Relative output path under ~/.wmux/exports (used with "stop"). Defaults to "trace.zip".'), surfaceId: optionalSurfaceId, }, async ({ action, path: outputPath, surfaceId }) => { @@ -128,7 +154,8 @@ export function registerUtilityTools(server: McpServer): void { } // action === 'stop' - const resolvedPath = outputPath ?? 'trace.zip'; + const resolvedPath = resolveBrowserExportPath(outputPath, 'trace.zip'); + await ensureExportDir(resolvedPath); await context.tracing.stop({ path: resolvedPath }); return { content: [ diff --git a/src/renderer/components/Browser/BrowserPanel.tsx b/src/renderer/components/Browser/BrowserPanel.tsx index 7667e0f..6d428fb 100644 --- a/src/renderer/components/Browser/BrowserPanel.tsx +++ b/src/renderer/components/Browser/BrowserPanel.tsx @@ -47,6 +47,7 @@ declare global { interface BrowserPanelProps { surfaceId: string; initialUrl: string; + partition: string; isActive: boolean; onClose: () => void; } @@ -55,7 +56,7 @@ interface BrowserPanelProps { // Component // --------------------------------------------------------------------------- -export default function BrowserPanel({ surfaceId, initialUrl, isActive, onClose }: BrowserPanelProps) { +export default function BrowserPanel({ surfaceId, initialUrl, partition, isActive, onClose }: BrowserPanelProps) { const t = useT(); const webviewRef = useRef(null); const [currentUrl, setCurrentUrl] = useState(initialUrl); @@ -399,7 +400,7 @@ export default function BrowserPanel({ surfaceId, initialUrl, isActive, onClose } src={initialUrl} - partition="persist:browser" + partition={partition} data-surface-id={surfaceId} style={{ width: '100%', diff --git a/src/renderer/components/Pane/Pane.tsx b/src/renderer/components/Pane/Pane.tsx index 7cef0c2..bf5f3b2 100644 --- a/src/renderer/components/Pane/Pane.tsx +++ b/src/renderer/components/Pane/Pane.tsx @@ -156,9 +156,10 @@ function SplitSurfaceView({ /> ) : surface.surfaceType === 'browser' ? ( onCloseSurface(surface.id)} /> @@ -204,9 +205,10 @@ function SplitSurfaceView({
{browsers.map((surface) => ( onCloseSurface(surface.id)} /> diff --git a/src/renderer/hooks/useRpcBridge.ts b/src/renderer/hooks/useRpcBridge.ts index 0984491..44efbf5 100644 --- a/src/renderer/hooks/useRpcBridge.ts +++ b/src/renderer/hooks/useRpcBridge.ts @@ -321,6 +321,7 @@ async function handleRpcMethod(method: string, params: RpcParams): Promise w.id === store.activeWorkspaceId); if (!ws) return { error: 'no active workspace' }; const url = typeof params.url === 'string' ? params.url : undefined; + const partition = typeof params.partition === 'string' ? params.partition : 'persist:wmux-default'; // Check if a browser surface already exists anywhere — reuse it const leaves = findLeafPanes(ws.rootPane); @@ -336,8 +337,11 @@ async function handleRpcMethod(method: string, params: RpcParams): Promise s.id === surfaceId); - if (surf && url) { - surf.browserUrl = url; + if (surf) { + if (url) { + surf.browserUrl = url; + } + surf.browserPartition = partition; } p.activeSurfaceId = surfaceId; }); @@ -358,7 +362,7 @@ async function handleRpcMethod(method: string, params: RpcParams): Promise w.id === store.activeWorkspaceId); if (!ws) return { error: 'no active workspace' }; diff --git a/src/renderer/stores/slices/__tests__/surfaceSlice.test.ts b/src/renderer/stores/slices/__tests__/surfaceSlice.test.ts new file mode 100644 index 0000000..8052c84 --- /dev/null +++ b/src/renderer/stores/slices/__tests__/surfaceSlice.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { createWorkspace, type Workspace } from '../../../../shared/types'; +import { createSurfaceSlice } from '../surfaceSlice'; + +type TestState = { + workspaces: Workspace[]; + activeWorkspaceId: string; +}; + +function createHarness() { + const workspace = createWorkspace('Test'); + const state: TestState = { + workspaces: [workspace], + activeWorkspaceId: workspace.id, + }; + + const set = (updater: (state: TestState) => void) => { + updater(state); + }; + + const slice = createSurfaceSlice(set as never, (() => state) as never, {} as never); + return { state, slice }; +} + +describe('surfaceSlice browser partition state', () => { + it('stores the provided partition on new browser surfaces', () => { + const { state, slice } = createHarness(); + const paneId = state.workspaces[0].rootPane.id; + + slice.addBrowserSurface(paneId, 'https://example.com', 'persist:wmux-login'); + + const pane = state.workspaces[0].rootPane; + if (pane.type !== 'leaf') throw new Error('expected leaf pane'); + expect(pane.surfaces[0].browserPartition).toBe('persist:wmux-login'); + }); + + it('updates browser partitions across surfaces when a new profile is applied', () => { + const { state, slice } = createHarness(); + const paneId = state.workspaces[0].rootPane.id; + + slice.addBrowserSurface(paneId, 'https://one.example', 'persist:wmux-default'); + slice.addBrowserSurface(paneId, 'https://two.example', 'persist:wmux-default'); + slice.updateBrowserPartition('persist:wmux-login'); + + const pane = state.workspaces[0].rootPane; + if (pane.type !== 'leaf') throw new Error('expected leaf pane'); + expect(pane.surfaces.every((surface) => surface.browserPartition === 'persist:wmux-login')).toBe(true); + }); +}); diff --git a/src/renderer/stores/slices/surfaceSlice.ts b/src/renderer/stores/slices/surfaceSlice.ts index 2d439eb..155e3fb 100644 --- a/src/renderer/stores/slices/surfaceSlice.ts +++ b/src/renderer/stores/slices/surfaceSlice.ts @@ -5,7 +5,7 @@ import { createSurface, generateId } from '../../../shared/types'; export interface SurfaceSlice { addSurface: (paneId: string, ptyId: string, shell: string, cwd: string) => void; - addBrowserSurface: (paneId: string, url?: string) => void; + addBrowserSurface: (paneId: string, url?: string, partition?: string) => void; addEditorSurface: (paneId: string, filePath: string) => void; closeSurface: (paneId: string, surfaceId: string) => void; setActiveSurface: (paneId: string, surfaceId: string) => void; @@ -13,6 +13,7 @@ export interface SurfaceSlice { prevSurface: (paneId: string) => void; updateSurfacePtyId: (paneId: string, surfaceId: string, ptyId: string) => void; updateSurfaceTitle: (surfaceId: string, title: string) => void; + updateBrowserPartition: (partition: string, surfaceId?: string) => void; } function findLeafPane(root: Pane, id: string): PaneLeaf | null { @@ -37,7 +38,7 @@ export const createSurfaceSlice: StateCreator set((state: StoreState) => { + addBrowserSurface: (paneId, url, partition) => set((state: StoreState) => { const ws = state.workspaces.find((w: Workspace) => w.id === state.activeWorkspaceId); if (!ws) return; const pane = findLeafPane(ws.rootPane, paneId); @@ -50,6 +51,7 @@ export const createSurfaceSlice: StateCreator set((state: StoreState) => { + for (const ws of state.workspaces) { + const updateInPane = (pane: Pane): boolean => { + if (pane.type === 'leaf') { + let updated = false; + for (const surface of pane.surfaces) { + if (surface.surfaceType !== 'browser') continue; + if (surfaceId && surface.id !== surfaceId) continue; + surface.browserPartition = partition; + updated = true; + } + return updated; + } + return pane.children.some(updateInPane); + }; + if (updateInPane(ws.rootPane) && surfaceId) return; + } + }), }); diff --git a/src/shared/__tests__/security.test.ts b/src/shared/__tests__/security.test.ts new file mode 100644 index 0000000..ad90762 --- /dev/null +++ b/src/shared/__tests__/security.test.ts @@ -0,0 +1,79 @@ +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fsMock = vi.hoisted(() => ({ + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const execFileSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock('fs', () => ({ + existsSync: fsMock.existsSync, + mkdirSync: fsMock.mkdirSync, + writeFileSync: fsMock.writeFileSync, + unlinkSync: fsMock.unlinkSync, +})); + +vi.mock('child_process', () => ({ + execFileSync: execFileSyncMock, +})); + +describe('secureWriteTokenFile', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + fsMock.existsSync.mockReturnValue(true); + }); + + it('creates the parent directory and writes the token file', async () => { + fsMock.existsSync.mockReturnValue(false); + + const { secureWriteTokenFile } = await import('../security'); + const tokenPath = path.join('C:', 'Users', 'tester', '.wmux', 'daemon-auth-token'); + + secureWriteTokenFile(tokenPath, 'secret-token'); + + expect(fsMock.existsSync).toHaveBeenCalledWith(path.dirname(tokenPath)); + expect(fsMock.mkdirSync).toHaveBeenCalledWith(path.dirname(tokenPath), { recursive: true }); + expect(fsMock.writeFileSync).toHaveBeenCalledWith(tokenPath, 'secret-token', { + encoding: 'utf8', + mode: 0o600, + }); + }); + + it('applies Windows ACL hardening when running on Windows', async () => { + vi.stubEnv('USERNAME', 'tester'); + vi.stubEnv('SystemRoot', 'C:\\Windows'); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + + const { secureWriteTokenFile } = await import('../security'); + const tokenPath = path.join('C:', 'Users', 'tester', '.wmux-auth-token'); + secureWriteTokenFile(tokenPath, 'secret-token'); + + expect(execFileSyncMock).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\icacls.exe', + [tokenPath, '/inheritance:r', '/grant:r', 'tester:F'], + { windowsHide: true }, + ); + }); + + it('deletes the token file and throws when Windows ACL hardening fails', async () => { + vi.stubEnv('USERNAME', 'tester'); + vi.stubEnv('SystemRoot', 'C:\\Windows'); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + execFileSyncMock.mockImplementation(() => { + throw new Error('icacls failed'); + }); + + const { secureWriteTokenFile } = await import('../security'); + const tokenPath = path.join('C:', 'Users', 'tester', '.wmux-auth-token'); + + expect(() => secureWriteTokenFile(tokenPath, 'secret-token')).toThrow( + `Failed to set secure ACL on ${tokenPath}: icacls failed`, + ); + expect(fsMock.unlinkSync).toHaveBeenCalledWith(tokenPath); + }); +}); diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 199d1b6..8d18dd6 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -35,6 +35,7 @@ export type RpcMethod = | 'system.capabilities' | 'browser.open' | 'browser.navigate' + | 'browser.goBack' | 'browser.close' | 'browser.session.start' | 'browser.session.stop' @@ -43,7 +44,6 @@ export type RpcMethod = | 'browser.type.humanlike' | 'browser.cdp.target' | 'browser.cdp.info' - | 'browser.cdp.send' | 'browser.screenshot' | 'browser.evaluate' | 'browser.type.cdp' @@ -89,6 +89,7 @@ export const ALL_RPC_METHODS = [ 'system.capabilities', 'browser.open', 'browser.navigate', + 'browser.goBack', 'browser.close', 'browser.session.start', 'browser.session.stop', @@ -97,7 +98,6 @@ export const ALL_RPC_METHODS = [ 'browser.type.humanlike', 'browser.cdp.target', 'browser.cdp.info', - 'browser.cdp.send', 'browser.screenshot', 'browser.evaluate', 'browser.type.cdp', diff --git a/src/shared/security.ts b/src/shared/security.ts new file mode 100644 index 0000000..1c68b2d --- /dev/null +++ b/src/shared/security.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; + +export function secureWriteTokenFile(filePath: string, token: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(filePath, token, { encoding: 'utf8', mode: 0o600 }); + + if (process.platform === 'win32') { + try { + const icacls = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\icacls.exe`; + execFileSync(icacls, [ + filePath, + '/inheritance:r', + '/grant:r', + `${process.env.USERNAME}:F`, + ], { windowsHide: true }); + } catch (aclErr) { + console.warn('[secureWriteTokenFile] Could not set file ACL:', aclErr); + try { + fs.unlinkSync(filePath); + } catch { + // Best effort cleanup of an insecure token file. + } + const message = aclErr instanceof Error ? aclErr.message : String(aclErr); + throw new Error(`Failed to set secure ACL on ${filePath}: ${message}`); + } + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 9261dd3..90604c9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -7,6 +7,7 @@ export interface Surface { cwd: string; surfaceType?: 'terminal' | 'browser' | 'editor'; browserUrl?: string; + browserPartition?: string; editorFilePath?: string; scrollbackFile?: string; // surfaceId used as filename for scrollback dump } @@ -295,14 +296,12 @@ export function createWorkspace(name: string): Workspace { // === Security: URL validation for SSRF prevention === /** - * Validates a URL for safe navigation. Blocks dangerous schemes and private - * network addresses to prevent SSRF attacks from AI agent-driven browsing. + * Fast preflight validation for browser navigation URLs. * - * Allows localhost/127.0.0.1/[::1] for local development servers. - * - * NOTE (v1 limitation): This is string-based validation only. DNS-resolved IPs - * are not checked, so DNS rebinding attacks are not mitigated. A future version - * should resolve hostnames and re-validate the resolved IP. + * This blocks dangerous schemes and obvious private/null/link-local literal + * addresses before navigation requests leave the caller. Hostname resolution + * checks are enforced separately in the main process at the actual navigation + * boundary. */ export function validateNavigationUrl(url: string): { valid: boolean; reason?: string } { let parsed: URL;