mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-09 16:02:28 +00:00
Fixes the new_page() hang from #666, and restores the pythonlib/ + settings/ halves of #637-#647 that were dropped when those PRs were consolidated into #666 (that PR only carried patches/ + additions/, so these never actually landed). ## new_page() hangs when window.outer* is spoofed (#666) The outer-size hijack in browser-init.patch pinned the chrome documentElement to the spoofed size. That caps .browserStack, which caps the content viewport, so the content window can never reach the size Juggler asks for in updateViewportSize() -- and awaitViewportDimensions awaits exact equality with no timeout, so it deadlocks rather than erroring. The second new_page() hung forever and took the context with it. The pin was never load-bearing: GetOuterWidth/GetOuterHeight already consult MaskConfig unconditionally (fingerprint-injection.patch), so window.outerWidth is spoofed in C++ regardless of the real chrome window size. Resizing is enough. Measured on the official v152.0.4-beta.26 build (headless): config before after none pass pass inner pass pass outer HANG pass both HANG pass (iw:360 ih:740 ow:360 oh:800 -- exact) This corrects the diagnosis in #666, which blamed the inner+outer combination and the `!(outerWidth || outerHeight)` guard. outer* ALONE is sufficient to hang, and dropping inner* does not help, so that guard is not the culprit. Also fixed driver-side: Playwright's implicit 1280x720 viewport is what asks for the impossible size, so the driver now defaults to no_viewport when the config spoofs any window dimension. That fixes the hang on already-released builds without a rebuild. An explicit viewport=/no_viewport= from the caller wins. ## WebRTC ICE prefs (#538) #666 merged the C++ half of the WebRTC fix but not the prefs, so the shipped build still has no_host=true and none of the proxy_only prefs. proxy_only_if_behind_proxy is the pref that actually stops the real-IP leak: it prevents a UDP STUN request routing around a TCP proxy. no_host=false keeps the stock two-candidate shape, which obfuscate_host_addresses makes leak-free. ## Also restored from the consolidation - fix(proxy): dom.security.https_first rewrote http:// before the launch-arg proxy filter saw it, breaking CONNECT-only proxies (#638). - fix(stealth): speech-voice spoofing + stop leaking host voices (#646). - fix(stealth): clamp inner <= outer <= avail <= screen; BrowserForge can emit impossible geometries that leak as tells (#647). Refs: https://github.com/daijro/camoufox/pull/666 Refs: https://github.com/daijro/camoufox/issues/538
867 lines
32 KiB
Python
867 lines
32 KiB
Python
import os
|
|
import sys
|
|
from functools import wraps
|
|
from os import environ
|
|
from os.path import abspath
|
|
from pathlib import Path
|
|
from pprint import pprint
|
|
from random import randint, randrange
|
|
from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast
|
|
|
|
import numpy as np
|
|
import orjson
|
|
from browserforge.fingerprints import Fingerprint, Screen
|
|
from screeninfo import get_monitors
|
|
from typing_extensions import TypeAlias
|
|
from ua_parser import user_agent_parser
|
|
|
|
from .addons import DefaultAddons, add_default_addons, confirm_paths
|
|
from .exceptions import (
|
|
InvalidOS,
|
|
InvalidPropertyType,
|
|
NonFirefoxFingerprint,
|
|
)
|
|
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_window_dimensions, set_media_devices_defaults
|
|
from .geolocation import geoip_allowed, get_geolocation
|
|
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
|
|
from .locales import handle_locales
|
|
from .pkgman import OS_NAME, get_path, installed_verstr, launch_path
|
|
from .virtdisplay import VirtualDisplay
|
|
from ._warnings import LeakWarning
|
|
from .webgl import sample_webgl
|
|
|
|
ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str]
|
|
|
|
# Camoufox preferences to cache previous pages and requests
|
|
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,
|
|
}
|
|
|
|
|
|
def _generate_fontconfig(fontconfig_path: str) -> str:
|
|
"""
|
|
Generates a runtime fontconfig that resolves bundled font paths absolutely.
|
|
The bundled fonts.conf uses prefix="cwd" relative paths which break when
|
|
Playwright's working directory differs from the browser install directory.
|
|
Writes a patched copy to ~/.cache/camoufox/fontconfig/ (deterministic,
|
|
only regenerated when content changes).
|
|
"""
|
|
import hashlib
|
|
|
|
fonts_dir = get_path("fonts")
|
|
fonts_conf_src = os.path.join(fontconfig_path, "fonts.conf")
|
|
|
|
with open(fonts_conf_src, 'r') as f:
|
|
conf_content = f.read()
|
|
|
|
conf_content = conf_content.replace(
|
|
'<dir prefix="cwd">fonts</dir>',
|
|
f'<dir>{fonts_dir}</dir>',
|
|
)
|
|
|
|
cache_dir = os.path.join(os.path.expanduser('~'), '.cache', 'camoufox', 'fontconfig')
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
|
|
content_hash = hashlib.sha256(conf_content.encode()).hexdigest()[:12]
|
|
runtime_conf = os.path.join(cache_dir, f'fonts-{content_hash}.conf')
|
|
if not os.path.exists(runtime_conf):
|
|
with open(runtime_conf, 'w') as f:
|
|
f.write(conf_content)
|
|
|
|
return runtime_conf
|
|
|
|
|
|
def get_env_vars(
|
|
config_map: Dict[str, str], user_agent_os: str
|
|
) -> Dict[str, Union[str, float, bool]]:
|
|
"""
|
|
Gets a dictionary of environment variables for Camoufox.
|
|
"""
|
|
env_vars: Dict[str, Union[str, float, bool]] = {}
|
|
try:
|
|
updated_config_data = orjson.dumps(config_map)
|
|
except orjson.JSONEncodeError as e:
|
|
print(f"Error updating config: {e}")
|
|
sys.exit(1)
|
|
|
|
# Split the config into chunks
|
|
chunk_size = 2047 if OS_NAME == 'win' else 32767
|
|
config_str = updated_config_data.decode('utf-8')
|
|
|
|
for i in range(0, len(config_str), chunk_size):
|
|
chunk = config_str[i : i + chunk_size]
|
|
env_name = f"CAMOU_CONFIG_{(i // chunk_size) + 1}"
|
|
try:
|
|
env_vars[env_name] = chunk
|
|
except Exception as e:
|
|
print(f"Error setting {env_name}: {e}")
|
|
sys.exit(1)
|
|
|
|
if OS_NAME == 'lin':
|
|
# https://github.com/coryking/camoufox/commit/f21eeb2850a74cc104fb57e17e0a2fa27b7a2a28
|
|
# Thanks @coryking
|
|
# the user_agent_os is either 'lin', 'mac', or 'win' but our fontconfig directory is 'linux', 'macos', or 'windows'
|
|
directory_map = {
|
|
'lin': 'linux',
|
|
'mac': 'macos',
|
|
'win': 'windows',
|
|
}
|
|
os_dir = directory_map.get(user_agent_os, user_agent_os)
|
|
|
|
# v150+ uses "fontconfig/" (matching the Go launcher); older bundles shipped "fontconfigs/".
|
|
fontconfig_path = get_path(os.path.join("fontconfig", os_dir))
|
|
if not os.path.exists(os.path.join(fontconfig_path, "fonts.conf")):
|
|
fontconfig_path = get_path(os.path.join("fontconfigs", os_dir))
|
|
|
|
# assert that fonts.conf exists in the directory
|
|
if not os.path.exists(os.path.join(fontconfig_path, "fonts.conf")):
|
|
# puke violently if fonts.conf doesn't exist!!
|
|
raise FileNotFoundError(
|
|
f"fonts.conf not found in {fontconfig_path}! Something ain't right with your camoufox bundle."
|
|
)
|
|
|
|
env_vars['FONTCONFIG_FILE'] = _generate_fontconfig(fontconfig_path)
|
|
|
|
return env_vars
|
|
|
|
|
|
def _load_properties(path: Optional[Path] = None) -> Dict[str, str]:
|
|
"""
|
|
Loads the properties.json file.
|
|
"""
|
|
if path:
|
|
prop_file = str(path.parent / "properties.json")
|
|
else:
|
|
prop_file = get_path("properties.json")
|
|
with open(prop_file, "rb") as f:
|
|
prop_dict = orjson.loads(f.read())
|
|
|
|
return {prop['property']: prop['type'] for prop in prop_dict}
|
|
|
|
|
|
def validate_config(config_map: Dict[str, str], path: Optional[Path] = None) -> None:
|
|
"""
|
|
Validates the config map.
|
|
"""
|
|
property_types = _load_properties(path=path)
|
|
|
|
for key, value in config_map.items():
|
|
expected_type = property_types.get(key)
|
|
if not expected_type:
|
|
print(f'Skipping unknown patch {key} : {value}')
|
|
continue # Property not supported by this browser version; skip silently
|
|
|
|
if not validate_type(value, expected_type):
|
|
raise InvalidPropertyType(
|
|
f"Invalid type for property {key}. Expected {expected_type}, got {type(value).__name__}"
|
|
)
|
|
|
|
|
|
def validate_type(value: Any, expected_type: str) -> bool:
|
|
"""
|
|
Validates the type of the value.
|
|
"""
|
|
if expected_type == "str":
|
|
return isinstance(value, str)
|
|
elif expected_type == "int":
|
|
return isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
|
elif expected_type == "uint":
|
|
return (
|
|
isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
|
) and value >= 0
|
|
elif expected_type == "double":
|
|
return isinstance(value, (float, int))
|
|
elif expected_type == "bool":
|
|
return isinstance(value, bool)
|
|
elif expected_type == "array":
|
|
return isinstance(value, list)
|
|
elif expected_type == "dict":
|
|
return isinstance(value, dict)
|
|
else:
|
|
return False
|
|
|
|
|
|
def get_target_os(config: Dict[str, Any]) -> Literal['mac', 'win', 'lin']:
|
|
"""
|
|
Gets the OS from the config if the user agent is set,
|
|
otherwise returns the OS of the current system.
|
|
"""
|
|
if config.get("navigator.userAgent"):
|
|
return determine_ua_os(config["navigator.userAgent"])
|
|
return OS_NAME
|
|
|
|
|
|
def determine_ua_os(user_agent: str) -> Literal['mac', 'win', 'lin']:
|
|
"""
|
|
Determines the OS from the user agent string.
|
|
"""
|
|
parsed_ua = user_agent_parser.ParseOS(user_agent).get('family')
|
|
if not parsed_ua:
|
|
raise ValueError("Could not determine OS from user agent")
|
|
if parsed_ua.startswith("Mac"):
|
|
return "mac"
|
|
if parsed_ua.startswith("Windows"):
|
|
return "win"
|
|
return "lin"
|
|
|
|
|
|
def get_screen_cons(headless: Optional[bool] = None) -> Optional[Screen]:
|
|
"""
|
|
Determines a sane viewport size for Camoufox if being ran in headful mode.
|
|
"""
|
|
if headless is False:
|
|
return None # Skip if headless
|
|
try:
|
|
monitors = get_monitors()
|
|
except Exception:
|
|
return None # Skip if there's an error getting the monitors
|
|
if not monitors:
|
|
return None # Skip if there are no monitors
|
|
|
|
# Use the dimensions from the monitor with greatest screen real estate
|
|
monitor = max(monitors, key=lambda m: m.width * m.height)
|
|
return Screen(max_width=monitor.width, max_height=monitor.height)
|
|
|
|
|
|
def update_fonts(config: Dict[str, Any], target_os: str) -> None:
|
|
"""
|
|
Updates the fonts for the target OS.
|
|
"""
|
|
with open(os.path.join(os.path.dirname(__file__), "fonts.json"), "rb") as f:
|
|
fonts = orjson.loads(f.read())[target_os]
|
|
|
|
# Merge with existing fonts
|
|
if 'fonts' in config:
|
|
config['fonts'] = np.unique(fonts + config['fonts']).tolist()
|
|
else:
|
|
config['fonts'] = fonts
|
|
|
|
|
|
def check_custom_fingerprint(fingerprint: Fingerprint) -> None:
|
|
"""
|
|
Asserts that the passed BrowserForge fingerprint is a valid Firefox fingerprint.
|
|
and warns the user that passing their own fingerprint is not recommended.
|
|
"""
|
|
# Check what the browser is
|
|
browser_name = user_agent_parser.ParseUserAgent(fingerprint.navigator.userAgent).get(
|
|
'family', 'Non-Firefox'
|
|
)
|
|
if browser_name != 'Firefox':
|
|
raise NonFirefoxFingerprint(
|
|
f'"{browser_name}" 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)
|
|
|
|
|
|
def check_valid_os(os: ListOrString) -> None:
|
|
"""
|
|
Checks if the target OS is valid.
|
|
"""
|
|
if not isinstance(os, str):
|
|
for os_name in os:
|
|
check_valid_os(os_name)
|
|
return
|
|
# Assert that the OS is lowercase
|
|
if not os.islower():
|
|
raise InvalidOS(f"OS values must be lowercase: '{os}'")
|
|
# Assert that the OS is supported by Camoufox
|
|
if os not in ('windows', 'macos', 'linux'):
|
|
raise InvalidOS(f"Camoufox does not support the OS: '{os}'")
|
|
|
|
|
|
def _clean_locals(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Gets the launch options from the locals of the function.
|
|
"""
|
|
del data['playwright']
|
|
del data['persistent_context']
|
|
return data
|
|
|
|
|
|
def merge_into(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
|
"""
|
|
Merges new keys/values from the source dictionary into the target dictionary.
|
|
Given that the key does not exist in the target dictionary.
|
|
"""
|
|
for key, value in source.items():
|
|
if key not in target:
|
|
target[key] = value
|
|
|
|
|
|
def set_into(target: Dict[str, Any], key: str, value: Any) -> None:
|
|
"""
|
|
Sets a new key/value into the target dictionary.
|
|
Given that the key does not exist in the target dictionary.
|
|
"""
|
|
if key not in target:
|
|
target[key] = value
|
|
|
|
|
|
def is_domain_set(
|
|
config: Dict[str, Any],
|
|
*properties: str,
|
|
) -> bool:
|
|
"""
|
|
Checks if a domain is set in the config.
|
|
"""
|
|
for prop in properties:
|
|
# If the . prefix exists, check if the domain is a prefix of any key in the config
|
|
if prop[-1] in ('.', ':'):
|
|
if any(key.startswith(prop) for key in config):
|
|
return True
|
|
# Otherwise, check if the domain is a direct key in the config
|
|
else:
|
|
if prop in config:
|
|
return True
|
|
return False
|
|
|
|
|
|
def warn_manual_config(config: Dict[str, Any]) -> None:
|
|
"""
|
|
Warns the user if they are manually setting properties that Camoufox already sets internally.
|
|
"""
|
|
# Manual locale setting
|
|
if is_domain_set(
|
|
config, 'navigator.language', 'navigator.languages', 'headers.Accept-Language', 'locale:'
|
|
):
|
|
LeakWarning.warn('locale', False)
|
|
# Manual geolocation and timezone setting
|
|
if is_domain_set(config, 'geolocation:', 'timezone'):
|
|
LeakWarning.warn('geolocation', False)
|
|
# Manual User-Agent setting
|
|
if is_domain_set(config, 'headers.User-Agent'):
|
|
LeakWarning.warn('header-ua', False)
|
|
# Manual navigator setting
|
|
if is_domain_set(config, 'navigator.'):
|
|
LeakWarning.warn('navigator', False)
|
|
# Manual screen/window setting
|
|
if is_domain_set(config, 'screen.', 'window.', 'document.body.'):
|
|
LeakWarning.warn('viewport', False)
|
|
|
|
|
|
_WINDOW_DIM_KEYS = (
|
|
'window.outerWidth',
|
|
'window.outerHeight',
|
|
'window.innerWidth',
|
|
'window.innerHeight',
|
|
'document.body.clientWidth',
|
|
'document.body.clientHeight',
|
|
)
|
|
|
|
|
|
def spoofs_window_dimensions(from_options: Dict[str, Any]) -> bool:
|
|
"""
|
|
Whether the CAMOU_CONFIG in a set of launch options spoofs any window
|
|
dimension. The config is chunked across CAMOU_CONFIG_<n> env vars, so
|
|
reassemble it in index order before looking.
|
|
"""
|
|
env = from_options.get('env') or {}
|
|
chunks = [(int(k.rsplit('_', 1)[1]), v) for k, v in env.items() if k.startswith('CAMOU_CONFIG_')]
|
|
if not chunks:
|
|
return False
|
|
blob = ''.join(v for _, v in sorted(chunks))
|
|
return any(key in blob for key in _WINDOW_DIM_KEYS)
|
|
|
|
|
|
def attach_no_viewport_default(target: Any) -> Any:
|
|
"""
|
|
Default new_page()/new_context() to no_viewport=True.
|
|
|
|
Playwright applies a 1280x720 viewport by default, which makes Juggler ask
|
|
the content window to become 1280x720 (TargetRegistry.updateViewportSize).
|
|
When Camoufox is pinning the window to a spoofed size, that request can
|
|
never be satisfied, and awaitViewportDimensions has no timeout -- so the
|
|
second new_page() hangs forever (daijro/camoufox#666).
|
|
|
|
With no_viewport, Juggler measures the window instead of resizing it, so the
|
|
handshake resolves immediately and the page reports the spoofed dimensions
|
|
exactly. Explicit viewport=/no_viewport= from the caller always wins.
|
|
"""
|
|
for name in ('new_page', 'new_context'):
|
|
original = getattr(target, name, None)
|
|
if original is None:
|
|
continue
|
|
|
|
def wrap(original: Any) -> Any:
|
|
@wraps(original)
|
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
if 'viewport' not in kwargs and 'no_viewport' not in kwargs:
|
|
kwargs['no_viewport'] = True
|
|
# Works for both sync and async: async returns the coroutine
|
|
# unawaited, and the caller awaits it as usual.
|
|
return original(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
setattr(target, name, wrap(original))
|
|
return target
|
|
|
|
|
|
async def async_attach_vd(
|
|
browser: Any, virtual_display: Optional[VirtualDisplay] = None
|
|
) -> Any: # type: ignore
|
|
"""
|
|
Attaches the virtual display to the async browser cleanup
|
|
"""
|
|
if not virtual_display: # Skip if no virtual display is provided
|
|
return browser
|
|
|
|
_close = browser.close
|
|
|
|
async def new_close(*args: Any, **kwargs: Any):
|
|
try:
|
|
await _close(*args, **kwargs)
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
if virtual_display:
|
|
virtual_display.kill()
|
|
|
|
browser.close = new_close
|
|
browser._virtual_display = virtual_display
|
|
|
|
return browser
|
|
|
|
|
|
def sync_attach_vd(
|
|
browser: Any, virtual_display: Optional[VirtualDisplay] = None
|
|
) -> Any: # type: ignore
|
|
"""
|
|
Attaches the virtual display to the sync browser cleanup
|
|
"""
|
|
if not virtual_display: # Skip if no virtual display is provided
|
|
return browser
|
|
|
|
_close = browser.close
|
|
|
|
def new_close(*args: Any, **kwargs: Any):
|
|
try:
|
|
_close(*args, **kwargs)
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
if virtual_display:
|
|
virtual_display.kill()
|
|
|
|
browser.close = new_close
|
|
browser._virtual_display = virtual_display
|
|
|
|
return browser
|
|
|
|
|
|
def launch_options(
|
|
*,
|
|
config: Optional[Dict[str, Any]] = None,
|
|
os: Optional[ListOrString] = None,
|
|
block_images: Optional[bool] = None,
|
|
block_webrtc: Optional[bool] = None,
|
|
block_webgl: Optional[bool] = None,
|
|
disable_coop: Optional[bool] = None,
|
|
webgl_config: Optional[Tuple[str, str]] = None,
|
|
geoip: Optional[Union[str, bool]] = None,
|
|
geoip_db: Optional[str] = None,
|
|
humanize: Optional[Union[bool, float]] = None,
|
|
locale: Optional[Union[str, List[str]]] = None,
|
|
addons: Optional[List[str]] = None,
|
|
fonts: Optional[List[str]] = None,
|
|
custom_fonts_only: Optional[bool] = None,
|
|
exclude_addons: Optional[List[DefaultAddons]] = None,
|
|
screen: Optional[Screen] = None,
|
|
window: Optional[Tuple[int, int]] = None,
|
|
fingerprint: Optional[Fingerprint] = None,
|
|
fingerprint_preset: Optional[Union[bool, Dict[str, Any]]] = None,
|
|
ff_version: Optional[int] = None,
|
|
headless: Optional[bool] = None,
|
|
main_world_eval: Optional[bool] = None,
|
|
executable_path: Optional[Union[str, Path]] = None,
|
|
browser: Optional[str] = None,
|
|
firefox_user_prefs: Optional[Dict[str, Any]] = None,
|
|
proxy: Optional[Dict[str, str]] = None,
|
|
enable_cache: Optional[bool] = None,
|
|
args: Optional[List[str]] = None,
|
|
env: Optional[Dict[str, Union[str, float, bool]]] = None,
|
|
i_know_what_im_doing: Optional[bool] = None,
|
|
debug: Optional[bool] = None,
|
|
virtual_display: Optional[str] = None,
|
|
**launch_options: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Launches a new browser instance for Camoufox.
|
|
Accepts all Playwright Firefox launch options, along with the following:
|
|
|
|
Parameters:
|
|
config (Optional[Dict[str, Any]]):
|
|
Camoufox properties to use. (read https://github.com/daijro/camoufox/blob/main/README.md)
|
|
os (Optional[ListOrString]):
|
|
Operating system to use for the fingerprint generation.
|
|
Can be "windows", "macos", "linux", or a list to randomly choose from.
|
|
Default: ["windows", "macos", "linux"]
|
|
block_images (Optional[bool]):
|
|
Whether to block all images.
|
|
block_webrtc (Optional[bool]):
|
|
Whether to block WebRTC entirely.
|
|
block_webgl (Optional[bool]):
|
|
Whether to block WebGL. To prevent leaks, only use this for special cases.
|
|
disable_coop (Optional[bool]):
|
|
Disables the Cross-Origin-Opener-Policy, allowing elements in cross-origin iframes,
|
|
such as the Turnstile checkbox, to be clicked.
|
|
geoip (Optional[Union[str, bool]]):
|
|
Calculate longitude, latitude, timezone, country, & locale based on the IP address.
|
|
Pass the target IP address to use, or `True` to find the IP address automatically.
|
|
geoip_db (Optional[str]):
|
|
Name of the GeoIP database to use (e.g., "MaxMind").
|
|
If not specified, uses the configured default.
|
|
humanize (Optional[Union[bool, float]]):
|
|
Humanize the cursor movement.
|
|
Takes either `True`, or the MAX duration in seconds of the cursor movement.
|
|
The cursor typically takes up to 1.5 seconds to move across the window.
|
|
locale (Optional[Union[str, List[str]]]):
|
|
Locale(s) to use in Camoufox. The first listed locale will be used for the Intl API.
|
|
addons (Optional[List[str]]):
|
|
List of Firefox addons to use.
|
|
fonts (Optional[List[str]]):
|
|
Fonts to load into Camoufox (in addition to the default fonts for the target `os`).
|
|
Takes a list of font family names that are installed on the system.
|
|
custom_fonts_only (Optional[bool]):
|
|
If enabled, OS-specific system fonts will be not be passed to Camoufox.
|
|
exclude_addons (Optional[List[DefaultAddons]]):
|
|
Default addons to exclude. Passed as a list of camoufox.DefaultAddons enums.
|
|
screen (Optional[Screen]):
|
|
Constrains the screen dimensions of the generated fingerprint.
|
|
Takes a browserforge.fingerprints.Screen instance.
|
|
window (Optional[Tuple[int, int]]):
|
|
Set a fixed window size instead of generating a random one
|
|
fingerprint (Optional[Fingerprint]):
|
|
Use a custom BrowserForge fingerprint. Note: Not all values will be implemented.
|
|
If not provided, a random fingerprint will be generated based on the provided
|
|
`os` & `screen` constraints.
|
|
fingerprint_preset (Optional[Union[bool, Dict[str, Any]]]):
|
|
Opt into using real fingerprint presets instead of BrowserForge.
|
|
Pass `True` to use a random bundled preset, or pass a preset dict directly.
|
|
By default (None), BrowserForge is used for infinite unique fingerprints.
|
|
ff_version (Optional[int]):
|
|
Firefox version to use. Defaults to the current Camoufox version.
|
|
To prevent leaks, only use this for special cases.
|
|
headless (Optional[bool]):
|
|
Whether to run the browser in headless mode. Defaults to False.
|
|
Note: If you are running linux, passing headless='virtual' to Camoufox & AsyncCamoufox
|
|
will use Xvfb.
|
|
main_world_eval (Optional[bool]):
|
|
Whether to enable running scripts in the main world.
|
|
To use this, prepend "mw:" to the script: page.evaluate("mw:" + script).
|
|
executable_path (Optional[Union[str, Path]]):
|
|
Custom Camoufox browser executable path.
|
|
browser (Optional[str]):
|
|
Select a specific installed browser version. Can be:
|
|
- Repo/build like "official/beta.20"
|
|
- Build alone like "beta.20"
|
|
- Full version like "134.0.2-beta.20"
|
|
If not specified, uses the active version.
|
|
firefox_user_prefs (Optional[Dict[str, Any]]):
|
|
Firefox user preferences to set.
|
|
proxy (Optional[Dict[str, str]]):
|
|
Proxy to use for the browser.
|
|
Note: If geoip is True, a request will be sent through this proxy to find the target IP.
|
|
enable_cache (Optional[bool]):
|
|
Cache previous pages, requests, etc (uses more memory).
|
|
args (Optional[List[str]]):
|
|
Arguments to pass to the browser.
|
|
env (Optional[Dict[str, Union[str, float, bool]]]):
|
|
Environment variables to set.
|
|
debug (Optional[bool]):
|
|
Prints the config being sent to Camoufox.
|
|
virtual_display (Optional[str]):
|
|
Virtual display number. Ex: ':99'. This is handled by Camoufox & AsyncCamoufox.
|
|
webgl_config (Optional[Tuple[str, str]]):
|
|
Use a specific WebGL vendor/renderer pair. Passed as a tuple of (vendor, renderer).
|
|
**launch_options (Dict[str, Any]):
|
|
Additional Firefox launch options.
|
|
"""
|
|
# Build the config
|
|
if config is None:
|
|
config = {}
|
|
|
|
# Set default values for optional arguments
|
|
if headless is None:
|
|
headless = False
|
|
if addons is None:
|
|
addons = []
|
|
if args is None:
|
|
args = []
|
|
if firefox_user_prefs is None:
|
|
firefox_user_prefs = {}
|
|
if custom_fonts_only is None:
|
|
custom_fonts_only = False
|
|
if i_know_what_im_doing is None:
|
|
i_know_what_im_doing = False
|
|
if env is None:
|
|
env = cast(Dict[str, Union[str, float, bool]], environ)
|
|
if isinstance(executable_path, str):
|
|
# Convert executable path to a Path object
|
|
executable_path = Path(abspath(executable_path))
|
|
|
|
# Handle virtual display
|
|
if virtual_display:
|
|
env['DISPLAY'] = virtual_display
|
|
# Virtual display uses Xvfb (X11). If the host session forces Wayland via env vars,
|
|
# GTK/Firefox may try Wayland and ignore DISPLAY, breaking Xvfb usage.
|
|
env['GDK_BACKEND'] = 'x11'
|
|
env.pop('WAYLAND_DISPLAY', None)
|
|
env["MOZ_ENABLE_WAYLAND"] = "0"
|
|
|
|
# Warn the user for manual config settings
|
|
if not i_know_what_im_doing:
|
|
warn_manual_config(config)
|
|
|
|
# Snapshot which domains the USER set before fingerprint generation fills in
|
|
# the rest. The post-generation BrowserForge-correction fixes below must
|
|
# only touch generated values, never override what the user passed.
|
|
_user_set_navigator = is_domain_set(config, 'navigator.')
|
|
_user_set_screen_window = is_domain_set(config, 'screen.', 'window.')
|
|
_user_set_media_devices = is_domain_set(config, 'mediaDevices:')
|
|
|
|
# Assert the target OS is valid
|
|
if os:
|
|
check_valid_os(os)
|
|
|
|
# webgl_config requires OS to be set
|
|
elif webgl_config:
|
|
raise ValueError('OS must be set when using webgl_config')
|
|
|
|
# Add the default addons
|
|
add_default_addons(addons, exclude_addons)
|
|
|
|
# Confirm all addon paths are valid
|
|
if addons:
|
|
confirm_paths(addons)
|
|
config['addons'] = addons
|
|
|
|
# Get the Firefox version
|
|
if ff_version:
|
|
ff_version_str = str(ff_version)
|
|
LeakWarning.warn('ff_version', i_know_what_im_doing)
|
|
else:
|
|
ff_version_str = installed_verstr().split('.', 1)[0]
|
|
|
|
# Generate a fingerprint
|
|
_used_preset = False
|
|
if fingerprint is not None:
|
|
# User passed a custom BrowserForge fingerprint
|
|
if not i_know_what_im_doing:
|
|
check_custom_fingerprint(fingerprint)
|
|
elif fingerprint_preset is not None:
|
|
# User opted into real fingerprint presets
|
|
if isinstance(fingerprint_preset, dict):
|
|
preset = fingerprint_preset
|
|
else:
|
|
preset = get_random_preset(os=os, ff_version=ff_version_str)
|
|
if preset:
|
|
merge_into(config, from_preset(preset, ff_version_str))
|
|
_used_preset = True
|
|
|
|
if not _used_preset and fingerprint is None:
|
|
# Default: BrowserForge synthetic generation (infinite unique fingerprints)
|
|
fingerprint = generate_fingerprint(
|
|
screen=screen or get_screen_cons(headless or 'DISPLAY' in env),
|
|
window=window,
|
|
os=os,
|
|
)
|
|
|
|
if not _used_preset and fingerprint is not None:
|
|
# Inject the BrowserForge fingerprint into the config
|
|
merge_into(
|
|
config,
|
|
from_browserforge(fingerprint, ff_version_str),
|
|
)
|
|
|
|
target_os = get_target_os(config)
|
|
|
|
# Correct BrowserForge fingerprint inconsistencies that leak as headless /
|
|
# impossible-geometry tells, unless the user is driving these themselves.
|
|
if not _user_set_navigator:
|
|
fix_navigator_arch(config, target_os)
|
|
if not _user_set_screen_window:
|
|
fix_screen_no_taskbar(config, target_os)
|
|
clamp_window_dimensions(config)
|
|
|
|
# Set a random window.history.length
|
|
set_into(config, 'window.history.length', randrange(1, 6)) # nosec
|
|
|
|
# Update fonts list
|
|
if fonts:
|
|
config['fonts'] = fonts
|
|
|
|
if custom_fonts_only:
|
|
firefox_user_prefs['gfx.bundled-fonts.activate'] = 0
|
|
if fonts:
|
|
LeakWarning.warn('custom_fonts_only')
|
|
else:
|
|
raise ValueError('No custom fonts were passed, but `custom_fonts_only` is enabled.')
|
|
elif 'fonts' not in config or not config.get('fonts'):
|
|
# Generate a unique random font subset from the OS font list
|
|
os_name = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
|
|
try:
|
|
config['fonts'] = _generate_random_font_subset(os_name)
|
|
except Exception:
|
|
update_fonts(config, target_os)
|
|
|
|
# Generate a unique random voice subset
|
|
if 'voices' not in config:
|
|
os_name_v = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
|
|
try:
|
|
config['voices'] = _generate_random_voice_subset(os_name_v)
|
|
except Exception:
|
|
pass
|
|
|
|
# Default mediaDevices to one mic + one camera so headless contexts don't
|
|
# expose an empty enumerateDevices() list (a headless tell).
|
|
if not _user_set_media_devices:
|
|
set_media_devices_defaults(config)
|
|
|
|
# Set random seeds for fingerprint noise (per launch)
|
|
set_into(config, 'fonts:spacing_seed', randint(1, 4_294_967_295)) # nosec
|
|
set_into(config, 'audio:seed', randint(1, 4_294_967_295)) # nosec
|
|
set_into(config, 'canvas:seed', randint(1, 4_294_967_295)) # nosec
|
|
|
|
# Set geolocation
|
|
if geoip:
|
|
geoip_allowed() # Assert that geoip is allowed
|
|
|
|
if geoip is True:
|
|
# Find the user's IP address
|
|
if proxy:
|
|
geoip = public_ip(Proxy(**proxy).as_string())
|
|
else:
|
|
geoip = public_ip()
|
|
|
|
# Spoof WebRTC if not blocked
|
|
if not block_webrtc:
|
|
if valid_ipv4(geoip):
|
|
set_into(config, 'webrtc:ipv4', geoip)
|
|
firefox_user_prefs['network.dns.disableIPv6'] = True
|
|
elif valid_ipv6(geoip):
|
|
set_into(config, 'webrtc:ipv6', geoip)
|
|
|
|
geolocation = get_geolocation(geoip, geoip_db=geoip_db)
|
|
geo_config = geolocation.as_config()
|
|
for key, value in geo_config.items():
|
|
if key in ('timezone', 'locale:language', 'locale:region', 'locale:script'):
|
|
config.setdefault(key, value)
|
|
else:
|
|
config[key] = value
|
|
|
|
# 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.
|
|
elif (
|
|
proxy
|
|
and 'localhost' not in proxy.get('server', '')
|
|
and not is_domain_set(config, 'geolocation')
|
|
):
|
|
LeakWarning.warn('proxy_without_geoip')
|
|
|
|
# Set locale
|
|
if locale:
|
|
handle_locales(locale, config)
|
|
|
|
# Pass the humanize option
|
|
if humanize:
|
|
set_into(config, 'humanize', True)
|
|
if isinstance(humanize, (int, float)):
|
|
set_into(config, 'humanize:maxTime', humanize)
|
|
|
|
# Enable the main world context creation
|
|
if main_world_eval:
|
|
set_into(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 or launch_options.pop('allow_webgl', True) is 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
|
|
if webgl_config:
|
|
webgl_fp = sample_webgl(target_os, *webgl_config)
|
|
elif config.get('webGl:vendor') and config.get('webGl:renderer'):
|
|
# Preset already set vendor/renderer — sample matching WebGL params
|
|
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'])
|
|
else:
|
|
webgl_fp = sample_webgl(target_os)
|
|
enable_webgl2 = webgl_fp.pop('webGl2Enabled')
|
|
|
|
# Merge the WebGL fingerprint into the config
|
|
merge_into(config, webgl_fp)
|
|
# Set the WebGL preferences
|
|
merge_into(
|
|
firefox_user_prefs,
|
|
{
|
|
'webgl.enable-webgl2': enable_webgl2,
|
|
'webgl.force-enabled': True,
|
|
},
|
|
)
|
|
|
|
# Cache previous pages, requests, etc (uses more memory)
|
|
if enable_cache:
|
|
merge_into(firefox_user_prefs, CACHE_PREFS)
|
|
|
|
# Print the config if debug is enabled
|
|
if debug:
|
|
print('[DEBUG] Config:')
|
|
pprint(config)
|
|
|
|
# Validate the config
|
|
validate_config(config, path=executable_path)
|
|
|
|
# Prepare environment variables to pass to Camoufox
|
|
env_vars = {
|
|
**get_env_vars(config, target_os),
|
|
**env,
|
|
}
|
|
# Prepare the executable path
|
|
if executable_path:
|
|
executable_path = str(executable_path)
|
|
elif browser:
|
|
# Select a specific installed browser version
|
|
from .multiversion import find_installed_version
|
|
|
|
browser_path = find_installed_version(browser)
|
|
if not browser_path:
|
|
raise ValueError(
|
|
f"Browser version '{browser}' not found. Run `camoufox list` to see installed versions."
|
|
)
|
|
executable_path = launch_path(browser_path)
|
|
else:
|
|
executable_path = launch_path()
|
|
|
|
result = {
|
|
"executable_path": executable_path,
|
|
"args": args,
|
|
"env": env_vars,
|
|
"firefox_user_prefs": firefox_user_prefs,
|
|
"headless": headless,
|
|
**(launch_options if launch_options is not None else {}),
|
|
}
|
|
# Only include proxy if it's not None (Playwright 1.55+ validates this)
|
|
# https://github.com/coryking/camoufox/commit/1336e8e509e8c12a896a09d9ee51f131f739f106
|
|
# Thanks @coryking
|
|
if proxy is not None:
|
|
result["proxy"] = proxy
|
|
|
|
return result
|