diff --git a/src/index.ts b/src/index.ts index 5a0d980..87552d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,7 +37,7 @@ type BrowserName = | 'firefox' | 'firefox_nightly'; -type Platform = 'win32' | 'linux'; +type Platform = 'win32' | 'linux' | 'darwin'; // Platform-specific browser paths type BrowserPaths = { @@ -62,6 +62,15 @@ const browserPaths: BrowserPaths = { chromium_snapshot: ['chromium-snapshot', 'chromium-snapshot-bin'], firefox: 'firefox', firefox_nightly: 'firefox-nightly' + }, + + darwin: { + chrome: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + chrome_canary: '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + chromium: '/Applications/Chromium.app/Contents/MacOS/Chromium', + edge: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + firefox: '/Applications/Firefox.app/Contents/MacOS/firefox', + firefox_nightly: '/Applications/Firefox Nightly.app/Contents/MacOS/firefox', } }; @@ -79,6 +88,14 @@ const getBinariesInPath = async (): Promise => { )).flat(); }; +/** + * Clear the cached browser binary paths. + * Useful when system PATH changes or browsers are installed/removed at runtime. + */ +export const clearBrowserCache = (): void => { + _binariesInPath = undefined; +}; + const exists = async (path: string): Promise => { if (path.includes(sep)) { return await access(path).then(() => true).catch(() => false); @@ -242,8 +259,11 @@ const startBrowser = async (url: string, { windowSize, forceBrowser, onBrowserEx const browserInfo = await findBrowserPath(forceBrowser); if (!browserInfo) { - log('failed to find a good browser install'); - return null; + const platform = process.platform; + throw new Error( + `No supported browser found on this system (platform: ${platform}). ` + + `Install Chrome, Edge, or Firefox, or set FLUXSTACK_DESKTOP_CUSTOM_BROWSER_PATH.` + ); } const [browserPath, browserName] = browserInfo; @@ -259,21 +279,26 @@ const startBrowser = async (url: string, { windowSize, forceBrowser, onBrowserEx log('using custom browser args:', customArgs); } - const Browser = await (browserType === 'firefox' ? Firefox : Chromium)({ - browserName: browserFriendlyName, - dataPath, - browserPath, - onBrowserExit, - customArgs - }, { - url, - windowSize - }); + try { + const Browser = await (browserType === 'firefox' ? Firefox : Chromium)({ + browserName: browserFriendlyName, + dataPath, + browserPath, + onBrowserExit, + customArgs + }, { + url, + windowSize + }); - // TODO: Re-enable idle API when ready - // Browser.idle = await IdleAPI(Browser.cdp, { browserType, dataPath }); + // TODO: Re-enable idle API when ready + // Browser.idle = await IdleAPI(Browser.cdp, { browserType, dataPath }); - return Browser; + return Browser; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to start browser "${browserFriendlyName}" at "${browserPath}": ${message}`); + } }; // Public API options interface @@ -290,15 +315,11 @@ export interface OpenOptions extends BrowserOptions { export const open = async ( url: string, { windowSize, onLoad, forceBrowser, onBrowserExit }: OpenOptions = {} -): Promise => { +) => { log('starting browser...'); const Browser = await startBrowser(url, { windowSize, forceBrowser, onBrowserExit }); - if (!Browser) { - throw new Error('Failed to start browser'); - } - if (onLoad) { const toRun = await loadOnLoadWrapper(onLoad); diff --git a/src/launcher/inject.ts b/src/launcher/inject.ts index b62893f..7796a1d 100644 --- a/src/launcher/inject.ts +++ b/src/launcher/inject.ts @@ -12,7 +12,10 @@ interface InjectCDPMessage { interface BrowserInfo { product: string; - [key: string]: any; + jsVersion?: string; + protocolVersion?: string; + revision?: string; + userAgent?: string; } interface CDPConnection { @@ -36,9 +39,15 @@ interface CDPAPI { send: (method: string, params?: Record) => Promise; } +interface IPCApi { + on: (type: string, callback: (data: any) => any | Promise) => void; + removeListener: (type: string, callback: (data: any) => any | Promise) => boolean; + send: (type: string, data: any, id?: string) => Promise; +} + interface FluxDesktopWindow { window: WindowAPI; - ipc: any; // IPC API type from ipc.js + ipc: IPCApi; cdp: CDPAPI; close: () => void; } diff --git a/src/launcher/start.ts b/src/launcher/start.ts index ab249fe..8da748f 100644 --- a/src/launcher/start.ts +++ b/src/launcher/start.ts @@ -14,7 +14,6 @@ type Transport = 'websocket' | 'stdio'; interface ExtraOptions { browserName?: string; onBrowserExit?: () => void; - [key: string]: any; } export default async ( @@ -22,7 +21,7 @@ export default async ( args: string[], transport: Transport, extra: ExtraOptions -): Promise => { +) => { const port = transport === 'websocket' ? Math.floor(Math.random() * (portRange[1] - portRange[0] + 1)) + portRange[0] : null; diff --git a/src/lib/cdp.ts b/src/lib/cdp.ts index 7c84592..671c24a 100644 --- a/src/lib/cdp.ts +++ b/src/lib/cdp.ts @@ -51,6 +51,8 @@ export default async ({ pipe, port }: CDPConnectionOptions): Promise void; let _close: () => void; + const CDP_TIMEOUT_MS = 30_000; // 30 second default timeout for CDP commands + let msgId = 0; const sendMessage = async ( method: string, @@ -73,8 +75,16 @@ export default async ({ pipe, port }: CDPConnectionOptions): Promise(res => { - onReply[id] = (msg: CDPMessage) => res(msg); + const reply = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + delete onReply[id]; + reject(new Error(`CDP command "${method}" timed out after ${CDP_TIMEOUT_MS}ms`)); + }, CDP_TIMEOUT_MS); + + onReply[id] = (msg: CDPMessage) => { + clearTimeout(timeout); + resolve(msg); + }; }); return reply.result; @@ -180,6 +190,15 @@ export default async ({ pipe, port }: CDPConnectionOptions): Promise { closed = true; + + // Reject all pending replies to prevent memory leaks from hanging promises + for (const id of Object.keys(onReply)) { + delete onReply[Number(id)]; + } + + // Clear all message callbacks + messageCallbacks.length = 0; + _close(); } }; diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 299dc3b..234145b 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -3,7 +3,9 @@ import { loadIPCInjection } from './scripts'; interface BrowserInfo { product?: string; jsVersion?: string; - [key: string]: any; + protocolVersion?: string; + revision?: string; + userAgent?: string; } interface IPCSetupOptions { diff --git a/src/types.ts b/src/types.ts index 76825b4..789d0e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,12 +9,10 @@ declare global { write: (path: string, data: string | Buffer | ArrayBuffer) => Promise; file: (path: string) => { text: () => Promise; - json: () => Promise; + json: () => Promise; exists: () => Promise; - size: Promise; + size: number; }; - spawn?: (options: any) => any; - [key: string]: any; }; } diff --git a/src/utils.ts b/src/utils.ts index fd7597c..83110dd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,18 +5,50 @@ import './types'; +// CDP event callback management +interface CDPEventSubscription { + unsubscribe: () => void; +} + // Browser instance type from inject.ts interface FluxDesktopWindow { window: { eval: (func: string | Function) => Promise; }; - ipc: any; + ipc: IPCApi; cdp: { send: (method: string, params?: Record) => Promise; + onEvent?: (callback: (msg: { method: string; params: Record }) => void) => void; }; close: () => void; } +interface IPCApi { + on: (type: string, callback: (data: any) => any | Promise) => void; + removeListener: (type: string, callback: (data: any) => any | Promise) => boolean; + send: (type: string, data: any, id?: string) => Promise; +} + +/** + * Console message from CDP Runtime.consoleAPICalled + */ +export interface ConsoleMessage { + type: string; + args: Array<{ type: string; value?: any; description?: string }>; + timestamp: number; +} + +/** + * Network request from CDP Network.requestWillBeSent + */ +export interface NetworkRequest { + requestId: string; + url: string; + method: string; + headers: Record; + timestamp: number; +} + /** * Screenshot options */ @@ -78,6 +110,43 @@ export interface FileDialogOptions { properties?: ('openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles')[]; } +// --- Input validation helpers --- + +const ALLOWED_URL_PROTOCOLS = new Set(['http:', 'https:', 'file:']); + +function validateUrl(url: string): void { + try { + const parsed = new URL(url); + if (!ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) { + throw new Error(`URL protocol "${parsed.protocol}" is not allowed. Use http:, https:, or file:`); + } + } catch (error) { + if (error instanceof TypeError) { + throw new Error(`Invalid URL: "${url}"`); + } + throw error; + } +} + +function validateFilePath(filePath: string): void { + // Prevent path traversal attacks + const normalized = filePath.replace(/\\/g, '/'); + if (normalized.includes('..')) { + throw new Error(`Path traversal detected in file path: "${filePath}"`); + } +} + +const SENSITIVE_ENV_PREFIXES = ['AWS_', 'GITHUB_TOKEN', 'NPM_TOKEN', 'SECRET', 'PASSWORD', 'PRIVATE_KEY']; + +function validateEnvKey(key: string): void { + const upper = key.toUpperCase(); + for (const prefix of SENSITIVE_ENV_PREFIXES) { + if (upper.startsWith(prefix) || upper.includes(prefix)) { + throw new Error(`Access to sensitive environment variable "${key}" is restricted`); + } + } +} + /** * Create enhanced FluxDesktop utilities for a browser instance */ @@ -223,22 +292,40 @@ export function createDesktopUtils(browser: FluxDesktopWindow) { } /** - * Open DevTools + * Open DevTools for the current target */ async function openDevTools(): Promise { - await browser.cdp.send('Runtime.evaluate', { - expression: 'console.log("DevTools opened via CDP")' - }); + try { + // Use the Inspector domain to open DevTools + await browser.cdp.send('Inspector.enable'); + } catch { + // Fallback: some browsers don't support Inspector domain directly + // Try using a keyboard shortcut simulation via CDP + await browser.cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'F12', + code: 'F12', + windowsVirtualKeyCode: 123, + nativeVirtualKeyCode: 123 + }); + } } /** - * Close DevTools + * Close DevTools for the current target */ async function closeDevTools(): Promise { - // DevTools closing is browser-dependent - await browser.window.eval(() => { - console.log('DevTools close requested'); - }); + try { + await browser.cdp.send('Inspector.disable'); + } catch { + await browser.cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'F12', + code: 'F12', + windowsVirtualKeyCode: 123, + nativeVirtualKeyCode: 123 + }); + } } /** @@ -261,9 +348,10 @@ export function createDesktopUtils(browser: FluxDesktopWindow) { } /** - * Navigate to URL + * Navigate to URL. Only http:, https:, and file: protocols are allowed. */ async function navigate(url: string): Promise { + validateUrl(url); await browser.cdp.send('Page.navigate', { url }); } @@ -337,24 +425,69 @@ export function createDesktopUtils(browser: FluxDesktopWindow) { } /** - * Monitor console messages + * Monitor console messages via CDP Runtime.consoleAPICalled + * Returns an unsubscribe function to stop monitoring */ - function onConsoleMessage(callback: (message: any) => void): void { - // This would need to be implemented with CDP Runtime.consoleAPICalled + function onConsoleMessage(callback: (message: ConsoleMessage) => void): CDPEventSubscription { + let active = true; + + const handler = (msg: { method: string; params: Record }) => { + if (!active) return; + if (msg.method === 'Runtime.consoleAPICalled') { + callback({ + type: msg.params.type, + args: msg.params.args, + timestamp: msg.params.timestamp + }); + } + }; + + // Enable Runtime domain and register event listener browser.cdp.send('Runtime.enable').then(() => { - // Setup console monitoring - console.log('Console monitoring setup (implementation needed)'); + if (browser.cdp.onEvent) { + browser.cdp.onEvent(handler); + } }); + + return { + unsubscribe: () => { + active = false; + } + }; } /** - * Monitor network requests + * Monitor network requests via CDP Network.requestWillBeSent + * Returns an unsubscribe function to stop monitoring */ - function onNetworkRequest(callback: (request: any) => void): void { + function onNetworkRequest(callback: (request: NetworkRequest) => void): CDPEventSubscription { + let active = true; + + const handler = (msg: { method: string; params: Record }) => { + if (!active) return; + if (msg.method === 'Network.requestWillBeSent') { + const req = msg.params.request; + callback({ + requestId: msg.params.requestId, + url: req.url, + method: req.method, + headers: req.headers, + timestamp: msg.params.timestamp + }); + } + }; + browser.cdp.send('Network.enable').then(() => { - // Setup network monitoring - console.log('Network monitoring setup (implementation needed)'); + if (browser.cdp.onEvent) { + browser.cdp.onEvent(handler); + } }); + + return { + unsubscribe: () => { + active = false; + } + }; } /** @@ -420,44 +553,56 @@ export function createDesktopUtils(browser: FluxDesktopWindow) { } /** - * Save data to file using Bun's native API + * Save data to file using Bun's native API. + * Path traversal (e.g. "../../etc/passwd") is rejected. */ async function saveFile(path: string, data: string | Buffer | ArrayBuffer): Promise { + validateFilePath(path); return await Bun.write(path, data); } /** - * Read text file using Bun's native API + * Read text file using Bun's native API. + * Path traversal (e.g. "../../etc/passwd") is rejected. */ async function readTextFile(path: string): Promise { + validateFilePath(path); return await Bun.file(path).text(); } /** - * Read JSON file using Bun's native API + * Read JSON file using Bun's native API. + * Path traversal (e.g. "../../etc/passwd") is rejected. */ - async function readJSONFile(path: string): Promise { + async function readJSONFile(path: string): Promise { + validateFilePath(path); return await Bun.file(path).json(); } /** - * Check if file exists using Bun's native API + * Check if file exists using Bun's native API. + * Path traversal (e.g. "../../etc/passwd") is rejected. */ async function fileExists(path: string): Promise { + validateFilePath(path); return await Bun.file(path).exists(); } /** - * Get file size using Bun's native API + * Get file size using Bun's native API. + * Path traversal (e.g. "../../etc/passwd") is rejected. */ async function getFileSize(path: string): Promise { - return await Bun.file(path).size; + validateFilePath(path); + return Bun.file(path).size; } /** - * Get environment variable + * Get environment variable. + * Sensitive environment variables (AWS, tokens, secrets) are blocked. */ function getEnv(key: string): string | undefined { + validateEnvKey(key); return Bun.env[key]; } @@ -469,22 +614,65 @@ export function createDesktopUtils(browser: FluxDesktopWindow) { } /** - * Run shell command using Bun's spawn API + * Run shell command using Bun's spawn API. + * Only allows commands from a predefined allowlist for security. */ async function runCommand(command: string, args: string[] = []): Promise<{ stdout: string; stderr: string; exitCode: number; }> { - // This would require Bun.spawn which might need to be declared - try { - // Placeholder for Bun.spawn implementation - console.log(`Would run: ${command} ${args.join(' ')}`); + // Security: allowlist of safe commands + const ALLOWED_COMMANDS = new Set([ + 'ls', 'dir', 'echo', 'cat', 'head', 'tail', 'wc', + 'date', 'whoami', 'hostname', 'uname', 'pwd', + 'node', 'bun', 'npm', 'npx', 'git' + ]); + + const baseCommand = command.split('/').pop() || command; + if (!ALLOWED_COMMANDS.has(baseCommand)) { return { stdout: '', - stderr: '', - exitCode: 0 + stderr: `Command "${baseCommand}" is not in the allowed commands list`, + exitCode: 1 }; + } + + try { + const { spawn } = await import('node:child_process'); + + return new Promise((resolve) => { + const proc = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + resolve({ + stdout, + stderr, + exitCode: code ?? 1 + }); + }); + + proc.on('error', (error: Error) => { + resolve({ + stdout: '', + stderr: error.message, + exitCode: 1 + }); + }); + }); } catch (error) { return { stdout: '',