UNPKG

claude-computer-use-mcp

Version:

MCP server providing browser automation capabilities to Claude Code

555 lines 22.9 kB
import { chromium } from 'playwright'; import crypto from 'crypto'; import { SECURITY_CONFIG } from './config.js'; import { CookieManager } from './cookie-manager.js'; import { monitoring } from './monitoring.js'; import { AdvancedBrowserController } from './advanced-browser.js'; import { validateUrl, validateSelector, validateText, validateScript, validateSessionId, validateTimeout, validateAttribute, ValidationError, SecurityError } from './validation.js'; export class BrowserController { sessions = new Map(); securityConfig; sessionCreationTimes = new Map(); cookieManager; cleanupInterval; // Rate limiting tracking sessionCreationHistory = []; // Session creation lock to prevent race conditions sessionCreationLock = false; // Advanced browser features advancedController; // Request interception interceptedSessions = new Set(); constructor(securityConfig = SECURITY_CONFIG) { this.securityConfig = securityConfig; this.advancedController = new AdvancedBrowserController(this.sessions); try { this.cookieManager = new CookieManager(); // Initialize monitoring if enabled if (securityConfig.enableMetrics || securityConfig.enableAuditLogging) { monitoring.enableMetrics = securityConfig.enableMetrics; monitoring.enableAuditLogging = securityConfig.enableAuditLogging; } // Initialize cookie manager asynchronously this.cookieManager.initialize().catch(error => { console.error('Failed to initialize cookie manager:', error); }); } catch (error) { throw new Error(`Failed to create browser controller: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Cleanup old sessions periodically this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), 60000); // Every minute } generateSecureSessionId() { return `session-${crypto.randomBytes(16).toString('hex')}`; } checkRateLimit() { const now = Date.now(); const oneMinuteAgo = now - 60 * 1000; // 1 minute ago const oneHourAgo = now - 60 * 60 * 1000; // 1 hour ago // Clean up old entries this.sessionCreationHistory = this.sessionCreationHistory.filter(timestamp => timestamp > oneHourAgo); // Count recent sessions const sessionsInLastMinute = this.sessionCreationHistory.filter(timestamp => timestamp > oneMinuteAgo).length; const sessionsInLastHour = this.sessionCreationHistory.length; // Check rate limits if (sessionsInLastMinute >= this.securityConfig.maxSessionsPerMinute) { throw new SecurityError(`Rate limit exceeded: too many sessions created in the last minute (max: ${this.securityConfig.maxSessionsPerMinute})`); } if (sessionsInLastHour >= this.securityConfig.maxSessionsPerHour) { throw new SecurityError(`Rate limit exceeded: too many sessions created in the last hour (max: ${this.securityConfig.maxSessionsPerHour})`); } } recordSessionCreation() { this.sessionCreationHistory.push(Date.now()); } cleanupPromise = null; async cleanupExpiredSessions() { // Prevent concurrent cleanup runs using a promise-based lock if (this.cleanupPromise) { return this.cleanupPromise; } this.cleanupPromise = this.performCleanup(); try { await this.cleanupPromise; } finally { this.cleanupPromise = null; } } async performCleanup() { const now = Date.now(); const expiredSessions = []; // Create a snapshot of session times to avoid race conditions const sessionTimes = Array.from(this.sessionCreationTimes.entries()); for (const [id, createdTime] of sessionTimes) { if (now - createdTime > this.securityConfig.sessionTimeout) { expiredSessions.push(id); } } // Close expired sessions in parallel but with error handling const cleanupPromises = expiredSessions.map(async (id) => { try { await this.closeSession(id); } catch (err) { console.error(`Failed to cleanup expired session ${id}:`, err); } }); await Promise.allSettled(cleanupPromises); } async createSession(headless = true) { // Prevent concurrent session creation to avoid race conditions if (this.sessionCreationLock) { throw new SecurityError('Session creation in progress, please try again'); } this.sessionCreationLock = true; try { // Check rate limits first this.checkRateLimit(); // Check session limit (with lock held to prevent race conditions) if (this.sessions.size >= this.securityConfig.maxSessions) { throw new SecurityError(`Maximum number of sessions (${this.securityConfig.maxSessions}) reached`); } const id = this.generateSecureSessionId(); const browser = await chromium.launch({ headless, args: [ '--disable-gpu', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding' ] }); const contextOptions = { viewport: { width: 1280, height: 720 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', // Security headers extraHTTPHeaders: { 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin' }, // Disable permissions permissions: [], // Enhanced security settings ignoreHTTPSErrors: false, bypassCSP: false }; // Add Content Security Policy if enabled if (this.securityConfig.enableContentSecurityPolicy) { contextOptions.extraHTTPHeaders['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;"; } const context = await browser.newContext(contextOptions); const page = await context.newPage(); // Enable request interception if configured if (this.securityConfig.enableRequestInterception) { await this.enableRequestInterception(id, page); } // Set up performance monitoring if (this.securityConfig.enableMetrics) { page.on('load', () => { monitoring.auditLog({ level: 'info', sessionId: id, action: 'page_loaded', details: { url: page.url() } }); }); } this.sessions.set(id, { id, browser, context, page, createdAt: new Date() }); this.sessionCreationTimes.set(id, Date.now()); this.recordSessionCreation(); // Record for rate limiting monitoring.auditLog({ level: 'info', sessionId: id, action: 'session_created', details: { headless, timestamp: Date.now() } }); return id; } catch (error) { throw new Error(`Failed to create browser session: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { this.sessionCreationLock = false; } } getSession(sessionId) { validateSessionId(sessionId); return this.sessions.get(sessionId); } async closeSession(sessionId) { validateSessionId(sessionId); const session = this.sessions.get(sessionId); if (session) { try { await session.browser.close(); } catch (error) { console.error(`Error closing browser for session ${sessionId}:`, error); } finally { this.sessions.delete(sessionId); this.sessionCreationTimes.delete(sessionId); } } } async closeAllSessions() { const closePromises = Array.from(this.sessions.values()).map(async (session) => { try { await session.browser.close(); } catch (error) { console.error(`Error closing browser for session ${session.id}:`, error); } }); await Promise.allSettled(closePromises); this.sessions.clear(); this.sessionCreationTimes.clear(); } async navigate(sessionId, url) { validateUrl(url, this.securityConfig); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { await session.page.goto(url, { waitUntil: 'networkidle', timeout: 30000 // 30 second timeout }); } catch (error) { throw new Error(`Failed to navigate to ${url}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async screenshot(sessionId, fullPage = false) { const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { return await session.page.screenshot({ fullPage }); } catch (error) { throw new Error(`Failed to take screenshot: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async click(sessionId, selector) { validateSelector(selector, this.securityConfig); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { await session.page.click(selector, { timeout: 10000 }); } catch (error) { throw new Error(`Failed to click on selector '${selector}': ${error instanceof Error ? error.message : 'Unknown error'}`); } } async type(sessionId, selector, text) { validateSelector(selector, this.securityConfig); validateText(text, this.securityConfig); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { await session.page.fill(selector, text); } catch (error) { throw new Error(`Failed to type into selector '${selector}': ${error instanceof Error ? error.message : 'Unknown error'}`); } } async waitForSelector(sessionId, selector, timeout) { validateSelector(selector, this.securityConfig); const validatedTimeout = validateTimeout(timeout); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { await session.page.waitForSelector(selector, { timeout: validatedTimeout }); } catch (error) { throw new Error(`Failed to wait for selector '${selector}': ${error instanceof Error ? error.message : 'Unknown error'}`); } } async evaluate(sessionId, script) { validateScript(script, this.securityConfig); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { // Use Function constructor for safer execution with isolated scope const wrappedScript = ` (function() { 'use strict'; try { return (function() { ${script} })(); } catch (error) { return { error: error.message || 'Script execution failed', stack: error.stack || 'No stack trace available' }; } })() `; // Validate the wrapped script can be parsed before execution try { new Function(wrappedScript); } catch (syntaxError) { throw new ValidationError(`Script syntax error: ${syntaxError instanceof Error ? syntaxError.message : 'Unknown error'}`); } return await session.page.evaluate(wrappedScript); } catch (error) { throw new Error(`Failed to execute script: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getText(sessionId, selector) { validateSelector(selector, this.securityConfig); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { const text = await session.page.textContent(selector); return text !== null ? text : ''; } catch (error) { throw new Error(`Failed to get text from selector '${selector}': ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getAttribute(sessionId, selector, attribute) { validateSelector(selector, this.securityConfig); validateAttribute(attribute); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { return await session.page.getAttribute(selector, attribute); } catch (error) { throw new Error(`Failed to get attribute '${attribute}' from selector '${selector}': ${error instanceof Error ? error.message : 'Unknown error'}`); } } async selectOption(sessionId, selector, value) { validateSelector(selector, this.securityConfig); validateText(value, this.securityConfig); const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { await session.page.selectOption(selector, value); } catch (error) { throw new Error(`Failed to select option '${value}' in selector '${selector}': ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getUrl(sessionId) { const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); return session.page.url(); } async getTitle(sessionId) { const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { return await session.page.title(); } catch (error) { throw new Error(`Failed to get page title: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async listSessions() { const sessionList = []; for (const session of this.sessions.values()) { try { const title = await session.page.title(); sessionList.push({ id: session.id, url: session.page.url(), title, createdAt: session.createdAt }); } catch (error) { // If we can't get the title, still include the session sessionList.push({ id: session.id, url: session.page.url(), title: '<error retrieving title>', createdAt: session.createdAt }); } } return sessionList; } async saveCookies(sessionId) { const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { const cookies = await session.context.cookies(); await this.cookieManager.saveCookies(sessionId, cookies); } catch (error) { throw new Error(`Failed to save cookies: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async loadCookies(sessionId, targetDomain) { const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { const cookies = await this.cookieManager.loadCookies(sessionId, targetDomain); if (cookies.length > 0) { await session.context.addCookies(cookies); } } catch (error) { throw new Error(`Failed to load cookies: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async clearCookies(sessionId) { const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { // Clear cookies from browser context await session.context.clearCookies(); // Clear stored cookies await this.cookieManager.clearCookies(sessionId); } catch (error) { throw new Error(`Failed to clear cookies: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getCookies(sessionId, urls) { const session = this.getSession(sessionId); if (!session) throw new Error(`Session ${sessionId} not found`); try { const cookies = await session.context.cookies(urls); // Return cookies without sensitive values for security return cookies.map(cookie => ({ name: cookie.name, domain: cookie.domain, path: cookie.path, expires: cookie.expires, httpOnly: cookie.httpOnly, secure: cookie.secure, sameSite: cookie.sameSite })); } catch (error) { throw new Error(`Failed to get cookies: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Advanced browser features delegation async createNewTab(sessionId, url) { return this.advancedController.createNewTab(sessionId, url); } async switchToTab(sessionId, tabIndex) { return this.advancedController.switchToTab(sessionId, tabIndex); } async closeTab(sessionId, tabIndex) { return this.advancedController.closeTab(sessionId, tabIndex); } async listTabs(sessionId) { return this.advancedController.listTabs(sessionId); } async fillForm(sessionId, formData, submitSelector) { return this.advancedController.fillForm(sessionId, formData, submitSelector); } async uploadFile(sessionId, fileSelector, filePath) { return this.advancedController.uploadFile(sessionId, fileSelector, filePath); } async downloadFile(sessionId, downloadSelector, downloadPath) { return this.advancedController.downloadFile(sessionId, downloadSelector, downloadPath); } async takeAdvancedScreenshot(sessionId, options = {}) { return this.advancedController.takeAdvancedScreenshot(sessionId, options); } async scroll(sessionId, direction, distance) { return this.advancedController.scroll(sessionId, direction, distance); } async dragAndDrop(sessionId, sourceSelector, targetSelector) { return this.advancedController.dragAndDrop(sessionId, sourceSelector, targetSelector); } async waitForNavigation(sessionId, timeout, waitUntil) { return this.advancedController.waitForNavigation(sessionId, timeout, waitUntil); } async enableNetworkLogging(sessionId) { return this.advancedController.enableNetworkLogging(sessionId); } async getNetworkLogs(sessionId, includeHeaders) { return this.advancedController.getNetworkLogs(sessionId, includeHeaders); } async getPerformanceMetrics(sessionId) { return this.advancedController.getPerformanceMetrics(sessionId); } async runAccessibilityAudit(sessionId, selector) { return this.advancedController.runAccessibilityAudit(sessionId, selector); } // Request interception async enableRequestInterception(sessionId, page) { if (this.interceptedSessions.has(sessionId)) return; await page.route('**/*', (route) => { const url = route.request().url(); const domain = new URL(url).hostname; // Block requests to blacklisted domains if (this.securityConfig.blockedDomains.some(blocked => domain.includes(blocked))) { monitoring.auditLog({ level: 'warn', sessionId, action: 'request_blocked', details: { url, domain, reason: 'blocked_domain' } }); route.abort(); return; } // Log all requests if metrics enabled if (this.securityConfig.enableMetrics) { monitoring.auditLog({ level: 'debug', sessionId, action: 'request_intercepted', details: { url, method: route.request().method() } }); } route.continue(); }); this.interceptedSessions.add(sessionId); } async cleanup() { // Clear the cleanup interval if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } // Clean up advanced controller for (const sessionId of this.sessions.keys()) { await this.advancedController.cleanup(sessionId); } // Close all sessions await this.closeAllSessions(); // Cleanup monitoring await monitoring.cleanup(); } } //# sourceMappingURL=browser-controller.js.map