UNPKG

camoufox

Version:

JavaScript port of Camoufox - a tool for Firefox anti-fingerprinting and browser automation.

499 lines (498 loc) 17 kB
// from browserforge.fingerprints import Fingerprint, Screen // from screeninfo import get_monitors // from ua_parser import user_agent_parser import path from 'path'; import { addDefaultAddons, confirmPaths } from './addons.js'; import { InvalidOS, InvalidPropertyType, NonFirefoxFingerprint, UnknownProperty } from './exceptions.js'; import { fromBrowserforge, generateFingerprint } from './fingerprints.js'; import { publicIP, validIPv4, validIPv6 } from './ip.js'; import { geoipAllowed, getGeolocation, handleLocales } from './locale.js'; import { LOCAL_DATA, OS_NAME, getPath, installedVerStr, launchPath } from './pkgman.js'; import { LeakWarning } from './warnings.js'; import { sampleWebGL } from './webgl/sample.js'; import { readFileSync } from 'fs'; import { join } from 'path'; import { UAParser } from 'ua-parser-js'; // Camoufox preferences to cache previous pages and requests const CACHE_PREFS = { 'browser.sessionhistory.max_entries': 10, 'browser.sessionhistory.max_total_viewers': -1, 'browser.cache.memory.enable': true, 'browser.cache.disk_cache_ssl': true, 'browser.cache.disk.smart_size.enabled': true, }; function getEnvVars(configMap, userAgentOS) { const envVars = {}; let updatedConfigData; try { updatedConfigData = new TextEncoder().encode(JSON.stringify(configMap)); } catch (e) { console.error(`Error updating config: ${e}`); process.exit(1); } const chunkSize = OS_NAME === 'win' ? 2047 : 32767; const configStr = new TextDecoder().decode(updatedConfigData); for (let i = 0; i < configStr.length; i += chunkSize) { const chunk = configStr.slice(i, i + chunkSize); const envName = `CAMOU_CONFIG_${Math.floor(i / chunkSize) + 1}`; try { envVars[envName] = chunk; } catch (e) { console.error(`Error setting ${envName}: ${e}`); process.exit(1); } } if (OS_NAME === 'lin') { const fontconfigPath = getPath(path.join('fontconfig', userAgentOS)); envVars['FONTCONFIG_PATH'] = fontconfigPath; } return envVars; } export function getAsBooleanFromENV(name, defaultValue) { const value = process.env[name]; if (value === 'false' || value === '0') return false; if (value) return true; return !!defaultValue; } function loadProperties(filePath) { let propFile; filePath = filePath?.toString(); if (filePath) { propFile = path.join(path.dirname(filePath), 'properties.json'); } else { propFile = getPath('properties.json'); } const propData = readFileSync(propFile).toString(); const propDict = JSON.parse(propData); return propDict.reduce((acc, prop) => { acc[prop.property] = prop.type; return acc; }, {}); } function validateConfig(configMap, path) { const propertyTypes = loadProperties(path); for (const [key, value] of Object.entries(configMap)) { const expectedType = propertyTypes[key]; if (!expectedType) { throw new UnknownProperty(`Unknown property ${key} in config`); } if (!validateType(value, expectedType)) { throw new InvalidPropertyType(`Invalid type for property ${key}. Expected ${expectedType}, got ${typeof value}`); } } } function validateType(value, expectedType) { switch (expectedType) { case 'str': return typeof value === 'string'; case 'int': return Number.isInteger(value); case 'uint': return Number.isInteger(value) && value >= 0; case 'double': return typeof value === 'number'; case 'bool': return typeof value === 'boolean'; case 'array': return Array.isArray(value); case 'dict': return typeof value === 'object' && value !== null && !Array.isArray(value); default: return false; } } function getTargetOS(config) { if (config['navigator.userAgent']) { return determineUAOS(config['navigator.userAgent']); } return OS_NAME; } function determineUAOS(userAgent) { const parser = new UAParser(userAgent); const parsedUA = parser.getOS().name; if (!parsedUA) { throw new Error("Could not determine OS from user agent"); } if (parsedUA.startsWith("Mac")) { return 'mac'; } if (parsedUA.startsWith("Windows")) { return 'win'; } return 'lin'; } function getScreenCons(headless) { if (headless === false) { return null; } // TODO - Implement getMonitors // try { // const monitors = getMonitors(); // if (!monitors.length) { // return null; // } // const monitor = monitors.reduce((prev, curr) => (prev.width * prev.height > curr.width * curr.height ? prev : curr)); // return { maxWidth: monitor.width, maxHeight: monitor.height }; // } catch { // return null; // } return null; } function updateFonts(config, targetOS) { const fontsPath = join(LOCAL_DATA.toString(), 'fonts.json'); const fonts = JSON.parse(readFileSync(fontsPath, 'utf-8'))[targetOS]; if (config.fonts) { config.fonts = Array.from(new Set([...fonts, ...config.fonts])); } else { config.fonts = fonts; } } function checkCustomFingerprint(fingerprint) { const parser = new UAParser(fingerprint.navigator.userAgent); const browserName = parser.getBrowser().name || 'Non-Firefox'; if (browserName !== 'Firefox') { throw new NonFirefoxFingerprint(`"${browserName}" fingerprints are not supported in Camoufox. Using fingerprints from a browser other than Firefox WILL lead to detection. If this is intentional, pass i_know_what_im_doing=True.`); } LeakWarning.warn('custom_fingerprint', false); } function checkValidOS(os) { if (Array.isArray(os)) { os.forEach(checkValidOS); return; } if (!os.toLowerCase()) { throw new InvalidOS(`OS values must be lowercase: '${os}'`); } if (!['windows', 'macos', 'linux'].includes(os)) { throw new InvalidOS(`Camoufox does not support the OS: '${os}'`); } } function cleanLocals(data) { delete data.playwright; delete data.persistentContext; return data; } function mergeInto(target, source) { Object.entries(source).forEach(([key, value]) => { if (!(key in target)) { target[key] = value; } }); } function setInto(target, key, value) { if (!(key in target)) { target[key] = value; } } function isDomainSet(config, ...properties) { return properties.some(prop => { if (prop.endsWith('.') || prop.endsWith(':')) { return Object.keys(config).some(key => key.startsWith(prop)); } return prop in config; }); } function warnManualConfig(config) { if (isDomainSet(config, 'navigator.language', 'navigator.languages', 'headers.Accept-Language', 'locale:')) { LeakWarning.warn('locale', false); } if (isDomainSet(config, 'geolocation:', 'timezone')) { LeakWarning.warn('geolocation', false); } if (isDomainSet(config, 'headers.User-Agent')) { LeakWarning.warn('header-ua', false); } if (isDomainSet(config, 'navigator.')) { LeakWarning.warn('navigator', false); } if (isDomainSet(config, 'screen.', 'window.', 'document.body.')) { LeakWarning.warn('viewport', false); } } async function asyncAttachVD(browser, virtualDisplay) { if (!virtualDisplay) { return browser; } const originalClose = browser.close; browser.close = async (...args) => { await originalClose.apply(browser, ...args); if (virtualDisplay) { virtualDisplay.kill(); } }; browser._virtualDisplay = virtualDisplay; return browser; } export function syncAttachVD(browser, virtualDisplay) { /** * Attaches the virtual display to the sync browser cleanup */ if (!virtualDisplay) { // Skip if no virtual display is provided return browser; } const originalClose = browser.close; browser.close = (...args) => { originalClose.apply(browser, ...args); if (virtualDisplay) { virtualDisplay.kill(); } }; browser._virtualDisplay = virtualDisplay; return browser; } /** * Convert a Playwright proxy string to a URL object. * * Implementation from https://github.com/microsoft/playwright/blob/3873b72ac1441ca691f7594f0ed705bd84518f93/packages/playwright-core/src/server/browserContext.ts#L737-L747 */ function getProxyUrl(proxy) { if (typeof proxy === 'string') { return new URL(proxy); } const { server, username, password } = proxy; let url; try { // new URL('127.0.0.1:8080') throws // new URL('localhost:8080') fails to parse host or protocol // In both of these cases, we need to try re-parse URL with `http://` prefix. url = new URL(server); if (!url.host || !url.protocol) url = new URL('http://' + server); } catch (e) { url = new URL('http://' + server); } if (username) url.username = username; if (password) url.password = password; return url; } export async function launchOptions({ config, os, block_images, block_webrtc, block_webgl, disable_coop, webgl_config, geoip, geoip_file, humanize, locale, addons, fonts, custom_fonts_only, exclude_addons, screen, window, fingerprint, ff_version, headless, main_world_eval, executable_path, firefox_user_prefs, proxy, enable_cache, args, env, i_know_what_im_doing, debug, virtual_display, ...launch_options }) { // Build the config if (!config) { config = {}; } // Set default values for optional arguments if (headless === undefined) { headless = false; } if (!addons) { addons = []; } if (!args) { args = []; } if (!firefox_user_prefs) { firefox_user_prefs = {}; } if (custom_fonts_only === undefined) { custom_fonts_only = false; } if (i_know_what_im_doing === undefined) { i_know_what_im_doing = false; } if (!env) { env = process.env; } if (typeof executable_path === 'string') { // Convert executable path to a Path object executable_path = path.resolve(executable_path); } // Handle virtual display if (virtual_display) { env['DISPLAY'] = virtual_display; } // Warn the user for manual config settings if (!i_know_what_im_doing) { warnManualConfig(config); } // Assert the target OS is valid if (os) { checkValidOS(os); } // webgl_config requires OS to be set else if (webgl_config) { throw new Error('OS must be set when using webgl_config'); } // Add the default addons addDefaultAddons(addons, exclude_addons); // Confirm all addon paths are valid if (addons.length > 0) { confirmPaths(addons); config['addons'] = addons; } // Get the Firefox version let ff_version_str; if (ff_version) { ff_version_str = ff_version.toString(); LeakWarning.warn('ff_version', i_know_what_im_doing); } else { ff_version_str = installedVerStr().split('.', 1)[0]; } // Generate a fingerprint if (!fingerprint) { fingerprint = generateFingerprint(window, { screen: screen || getScreenCons(headless || 'DISPLAY' in env), os, }); } else { // Or use the one passed by the user if (!i_know_what_im_doing) { checkCustomFingerprint(fingerprint); } } // Inject the fingerprint into the config mergeInto(config, fromBrowserforge(fingerprint, ff_version_str)); const targetOS = getTargetOS(config); // Set a random window.history.length setInto(config, 'window.history.length', Math.floor(Math.random() * 5) + 1); // Update fonts list if (fonts) { config['fonts'] = fonts; } if (custom_fonts_only) { firefox_user_prefs['gfx.bundled-fonts.activate'] = 0; if (fonts) { // The user has passed their own fonts, and OS fonts are disabled. LeakWarning.warn('custom_fonts_only'); } else { // OS fonts are disabled, and the user has not passed their own fonts either. throw new Error('No custom fonts were passed, but `custom_fonts_only` is enabled.'); } } else { updateFonts(config, targetOS); } // Set a fixed font spacing seed setInto(config, 'fonts:spacing_seed', Math.floor(Math.random() * 1_073_741_824)); // Handle proxy const proxyUrl = proxy ? getProxyUrl(proxy) : undefined; // Set geolocation if (geoip) { geoipAllowed(); // Find the user's IP address geoip = await publicIP(proxyUrl?.href); // Spoof WebRTC if not blocked if (!block_webrtc) { if (validIPv4(geoip)) { setInto(config, 'webrtc:ipv4', geoip); firefox_user_prefs['network.dns.disableIPv6'] = true; } else if (validIPv6(geoip)) { setInto(config, 'webrtc:ipv6', geoip); } } const geolocation = await getGeolocation(geoip, geoip_file); config = { ...config, ...geolocation.asConfig() }; } // Raise a warning when a proxy is being used without spoofing geolocation. // This is a very bad idea; the warning cannot be ignored with i_know_what_im_doing. if (proxyUrl && !proxyUrl.hostname.includes('localhost') && !isDomainSet(config, 'geolocation:')) { LeakWarning.warn('proxy_without_geoip'); } // Set locale if (locale) { handleLocales(locale, config); } // Pass the humanize option if (humanize) { setInto(config, 'humanize', true); if (typeof humanize === 'number') { setInto(config, 'humanize:maxTime', humanize); } } // Enable the main world context creation if (main_world_eval) { setInto(config, 'allowMainWorld', true); } // Set Firefox user preferences if (block_images) { LeakWarning.warn('block_images', i_know_what_im_doing); firefox_user_prefs['permissions.default.image'] = 2; } if (block_webrtc) { firefox_user_prefs['media.peerconnection.enabled'] = false; } if (disable_coop) { LeakWarning.warn('disable_coop', i_know_what_im_doing); firefox_user_prefs['browser.tabs.remote.useCrossOriginOpenerPolicy'] = false; } // Allow allow_webgl parameter for backwards compatibility if (block_webgl || launch_options.allow_webgl === false) { firefox_user_prefs['webgl.disabled'] = true; LeakWarning.warn('block_webgl', i_know_what_im_doing); } else { // If the user has provided a specific WebGL vendor/renderer pair, use it let webgl_fp; if (webgl_config) { webgl_fp = sampleWebGL(targetOS, ...webgl_config); } else { webgl_fp = sampleWebGL(targetOS); } const enable_webgl2 = webgl_fp.webGl2Enabled ?? false; // Merge the WebGL fingerprint into the config mergeInto(config, webgl_fp); // Set the WebGL preferences mergeInto(firefox_user_prefs, { 'webgl.enable-webgl2': enable_webgl2, 'webgl.force-enabled': true, }); } // Canvas anti-fingerprinting mergeInto(config, { 'canvas:aaOffset': Math.floor(Math.random() * 101) - 50, // nosec 'canvas:aaCapOffset': true, }); // Cache previous pages, requests, etc (uses more memory) if (enable_cache) { mergeInto(firefox_user_prefs, CACHE_PREFS); } // Print the config if debug is enabled if (debug) { console.debug('[DEBUG] Config:'); console.debug(config); } // Validate the config validateConfig(config, executable_path); //Prepare environment variables to pass to Camoufox const env_vars = { ...getEnvVars(config, targetOS), ...process.env, }; // Prepare the executable path if (executable_path) { executable_path = executable_path.toString(); } else { executable_path = launchPath(); } const out = { "executablePath": executable_path, "args": args, "env": env_vars, "firefoxUserPrefs": firefox_user_prefs, "proxy": proxyUrl ? { server: proxyUrl.origin, username: proxyUrl.username, password: proxyUrl.password, bypass: typeof proxy === 'string' ? undefined : proxy.bypass, } : undefined, "headless": headless, ...launch_options, }; return out; }