UNPKG

camoufox-mcp-server

Version:

MCP server for browser automation using Camoufox - a privacy-focused Firefox fork with advanced anti-detection features

274 lines (273 loc) 10.6 kB
import { Camoufox } from "camoufox-js"; import chalk from "chalk"; import { parseAndValidateBrowserRequestUrl, validateBrowserRequestUrl, validateTargetUrl } from "./policy.js"; import { GUARD_SETTLE_MS, LAUNCH_TIMEOUT_MS, MAX_CONCURRENCY, MAX_GUARDED_REQUESTS, MAX_QUEUE, QUEUE_TIMEOUT_MS } from "./config.js"; import { createDiagnosticsCollector } from "./diagnostics.js"; import { browserContextOptions, buildCamoufoxOptions, validateCommonBrowserInput } from "./browser-options.js"; import { applyStealthProfile, defaultHeadlessMode, describeError, getProxySecrets, getProxyServer, redactUrl, selectOperatingSystem, withTimeout } from "./utils.js"; export { browserContextOptions, buildCamoufoxOptions, validateBrowserOptionsInput } from "./browser-options.js"; let shuttingDown = false; let activeBrowses = 0; const pendingBrowses = []; const activeBrowsers = new Set(); export function setBrowserShuttingDown(value) { shuttingDown = value; } export function activeBrowserCount() { return activeBrowsers.size; } export function queuedBrowserRequestCount() { return pendingBrowses.length; } export function trackBrowser(browser) { activeBrowsers.add(browser); } export function releaseBrowserSlot() { activeBrowses = Math.max(0, activeBrowses - 1); const next = pendingBrowses.shift(); if (next) { next.start(); } } export async function acquireBrowserSlot() { if (shuttingDown) { throw new Error("Server is shutting down."); } if (activeBrowses < MAX_CONCURRENCY) { activeBrowses += 1; return releaseBrowserSlot; } if (pendingBrowses.length >= MAX_QUEUE) { throw new Error("Too many concurrent browse requests. Try again later."); } return new Promise((resolve, reject) => { const entry = { reject, timer: setTimeout(() => { const index = pendingBrowses.indexOf(entry); if (index >= 0) { pendingBrowses.splice(index, 1); } reject(new Error("Timed out waiting for a browse slot.")); }, QUEUE_TIMEOUT_MS), start: () => { clearTimeout(entry.timer); activeBrowses += 1; resolve(releaseBrowserSlot); }, }; pendingBrowses.push(entry); }); } export async function withBrowserSlot(fn) { const release = await acquireBrowserSlot(); try { return await fn(); } finally { release(); } } export async function launchCamoufoxBrowser(options) { let timedOut = false; const launchPromise = Camoufox(options); launchPromise.then((browser) => { if (timedOut) { void closeBrowser(browser); } }, () => undefined); try { return await withTimeout(launchPromise, LAUNCH_TIMEOUT_MS, "Browser launch"); } catch (error) { timedOut = true; throw error; } } export async function installRequestGuard(context) { let inspectedRequests = 0; let blockedRequestError; function blockRequest(rawUrl, reason) { if (!blockedRequestError) { blockedRequestError = new Error(`Blocked unsafe browser request to ${redactUrl(rawUrl)}. ${reason}`); } } function hasRequestBudget(rawUrl) { if (inspectedRequests >= MAX_GUARDED_REQUESTS) { blockRequest(rawUrl, "Too many browser requests."); return false; } inspectedRequests += 1; return true; } context.on("request", (request) => { const requestUrl = request.url(); try { parseAndValidateBrowserRequestUrl(requestUrl); } catch (requestError) { blockRequest(requestUrl, describeError(requestError)); } }); await context.route("**/*", async (route) => { const requestUrl = route.request().url(); if (!hasRequestBudget(requestUrl)) { await route.abort("blockedbyclient").catch(() => undefined); return; } try { await validateBrowserRequestUrl(requestUrl); } catch (requestError) { blockRequest(requestUrl, describeError(requestError)); await route.abort("blockedbyclient").catch(() => undefined); return; } await route.continue().catch((continueError) => { console.error(chalk.yellow(`[Camoufox] Request continue failed: ${describeError(continueError)}`)); }); }); await context.routeWebSocket(/.*/, async (webSocket) => { const requestUrl = webSocket.url(); if (!hasRequestBudget(requestUrl)) { await webSocket.close({ code: 1008, reason: "Blocked by server policy" }).catch(() => undefined); return; } try { await validateBrowserRequestUrl(requestUrl); } catch (requestError) { blockRequest(requestUrl, describeError(requestError)); await webSocket.close({ code: 1008, reason: "Blocked by server policy" }).catch(() => undefined); return; } webSocket.connectToServer(); }); return { assertAllowed() { if (blockedRequestError) { throw blockedRequestError; } }, watchPage(page) { page.on("websocket", (webSocket) => { const requestUrl = webSocket.url(); if (!hasRequestBudget(requestUrl)) { return; } try { parseAndValidateBrowserRequestUrl(requestUrl); } catch (requestError) { blockRequest(requestUrl, describeError(requestError)); } }); }, }; } export async function runBrowserOperation(label, input, callback) { const effectiveInput = applyStealthProfile(input); const safeUrl = redactUrl(effectiveInput.url); const targetUrl = await validateCommonBrowserInput(effectiveInput); return withBrowserSlot(async () => { const selectedOS = selectOperatingSystem(effectiveInput.os); const waitStrategy = effectiveInput.waitStrategy ?? "load"; const headlessMode = defaultHeadlessMode(effectiveInput.headless); console.error(chalk.blue(`[Camoufox] Launching browser to ${label}: ${safeUrl}`)); const browser = await launchCamoufoxBrowser(buildCamoufoxOptions(effectiveInput, selectedOS, headlessMode)); activeBrowsers.add(browser); try { const context = await browser.newContext(browserContextOptions(effectiveInput)); const requestGuard = await installRequestGuard(context); const page = await context.newPage(); requestGuard.watchPage(page); const rawUrls = [effectiveInput.url, getProxyServer(effectiveInput.proxy)].filter((rawUrl) => Boolean(rawUrl)); const secrets = getProxySecrets(effectiveInput.proxy); const diagnostics = createDiagnosticsCollector(page, effectiveInput, rawUrls, secrets); let lastNavigationResponse = null; page.on("response", (response) => { const request = response.request(); if (request.isNavigationRequest() && request.frame() === page.mainFrame()) { lastNavigationResponse = response; } }); let response; try { response = await page.goto(targetUrl.toString(), { waitUntil: waitStrategy, timeout: effectiveInput.timeout, }); lastNavigationResponse = response; } catch (navigationError) { const navigationErrorMessage = describeError(navigationError).toLowerCase(); if (/\b(?:127\.0\.0\.1|localhost|ip6-localhost|ip6-loopback|::1)\b/.test(navigationErrorMessage)) { throw new Error(`Blocked unsafe browser request to ${safeUrl}.`, { cause: navigationError }); } requestGuard.assertAllowed(); throw navigationError; } await page.waitForTimeout(GUARD_SETTLE_MS); requestGuard.assertAllowed(); await validateTargetUrl(page.url()); requestGuard.assertAllowed(); return await callback({ page, response, requestGuard, diagnostics, selectedOS, waitStrategy, getLastNavigationResponse: () => lastNavigationResponse, }); } finally { console.error(chalk.blue("[Camoufox] Closing browser.")); await closeBrowser(browser); } }); } export async function assertPageLocationSafe(page) { if (page.url() === "about:blank") { return; } await validateTargetUrl(page.url()); } export async function settleAndAssertSafe(page, requestGuard) { await page.waitForTimeout(GUARD_SETTLE_MS); requestGuard.assertAllowed(); await assertPageLocationSafe(page); requestGuard.assertAllowed(); } export async function runGuardedPageRead(page, requestGuard, read) { try { requestGuard.assertAllowed(); await assertPageLocationSafe(page); requestGuard.assertAllowed(); const result = await read(); await page.waitForTimeout(GUARD_SETTLE_MS).catch(() => undefined); requestGuard.assertAllowed(); await assertPageLocationSafe(page); requestGuard.assertAllowed(); return result; } catch (readError) { await page.waitForTimeout(GUARD_SETTLE_MS).catch(() => undefined); requestGuard.assertAllowed(); await assertPageLocationSafe(page); requestGuard.assertAllowed(); throw readError; } } export async function closeBrowser(browser) { activeBrowsers.delete(browser); try { await browser.close(); } catch (closeError) { console.error(chalk.yellow(`[Camoufox] Browser close failed: ${describeError(closeError)}`)); } } export async function closeActiveBrowsers() { const browsers = Array.from(activeBrowsers); await Promise.all(browsers.map((browser) => closeBrowser(browser))); } export function rejectPendingBrowses(reason) { const pending = pendingBrowses.splice(0); for (const entry of pending) { clearTimeout(entry.timer); entry.reject(new Error(reason)); } }