From af937cce5503533e6705d879951447ab826e9486 Mon Sep 17 00:00:00 2001 From: daijro Date: Thu, 21 Nov 2024 21:03:25 -0600 Subject: [PATCH] pythonlib: WebGL rotation & leak fixes 0.4.0 --- pythonlib/camoufox/__version__.py | 2 +- pythonlib/camoufox/addons.py | 204 +++++++------------------ pythonlib/camoufox/utils.py | 64 ++++---- pythonlib/camoufox/warnings.yml | 4 - pythonlib/camoufox/webgl/__init__.py | 3 + pythonlib/camoufox/webgl/sample.py | 82 ++++++++++ pythonlib/camoufox/webgl/webgl_data.db | Bin 0 -> 274432 bytes pythonlib/camoufox/xpi_dl.py | 67 -------- pythonlib/pyproject.toml | 2 +- 9 files changed, 176 insertions(+), 252 deletions(-) create mode 100644 pythonlib/camoufox/webgl/__init__.py create mode 100644 pythonlib/camoufox/webgl/sample.py create mode 100644 pythonlib/camoufox/webgl/webgl_data.db delete mode 100644 pythonlib/camoufox/xpi_dl.py diff --git a/pythonlib/camoufox/__version__.py b/pythonlib/camoufox/__version__.py index 9f4915f..ff41fb9 100644 --- a/pythonlib/camoufox/__version__.py +++ b/pythonlib/camoufox/__version__.py @@ -8,7 +8,7 @@ class CONSTRAINTS: The minimum and maximum supported versions of the Camoufox browser. """ - MIN_VERSION = 'beta.15' + MIN_VERSION = 'beta.17' MAX_VERSION = '1' @staticmethod diff --git a/pythonlib/camoufox/addons.py b/pythonlib/camoufox/addons.py index 7345cbd..dfff0b6 100644 --- a/pythonlib/camoufox/addons.py +++ b/pythonlib/camoufox/addons.py @@ -1,14 +1,10 @@ -import asyncio import os -import socket -import threading -import time from enum import Enum -from typing import List +from multiprocessing import Lock +from typing import List, Optional -import orjson - -from .exceptions import InvalidAddonPath, InvalidDebugPort, MissingDebugPort +from .exceptions import InvalidAddonPath +from .pkgman import get_path, unzip, webdl class DefaultAddons(Enum): @@ -17,35 +13,6 @@ class DefaultAddons(Enum): """ UBO = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi" - # Disable by default. Not always necessary, and increases the memory footprint of Camoufox. - # BPC = "https://gitflic.ru/project/magnolia1234/bpc_uploads/blob/raw?file=bypass_paywalls_clean-latest.xpi" - - -def get_debug_port(args: List[str]) -> int: - """ - Gets the debug port from the args, or creates a new one if not provided - """ - for i, arg in enumerate(args): - # Search for debugger server port - if arg == "-start-debugger-server": - # If arg is found but no port is provided, raise an error - if i + 1 >= len(args): - raise MissingDebugPort(f"No debug port provided: {args}") - debug_port = args[i + 1] - # Try to parse the debug port as an integer - try: - return int(debug_port) - except ValueError as e: - raise InvalidDebugPort( - f"Error parsing debug port. Must be an integer: {debug_port}" - ) from e - - # Create new debugger server port - debug_port_int = get_open_port() - # Add -start-debugger-server {debug_port} to args - args.extend(["-start-debugger-server", str(debug_port_int)]) - - return debug_port_int def confirm_paths(paths: List[str]) -> None: @@ -61,129 +28,62 @@ def confirm_paths(paths: List[str]) -> None: ) -def get_open_port() -> int: +def add_default_addons( + addons_list: List[str], exclude_list: Optional[List[DefaultAddons]] = None +) -> None: """ - Gets an open port + Adds default addons, minus any specified in exclude_list, to addons_list """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(('localhost', 0)) - return s.getsockname()[1] + # Build a dictionary from DefaultAddons, excluding keys found in exclude_list + if exclude_list is None: + exclude_list = [] + + addons = [addon for addon in DefaultAddons if addon not in exclude_list] + + with Lock(): + maybe_download_addons(addons, addons_list) -def threaded_try_load_addons(debug_port_int: int, addons_list: List[str]) -> None: +def download_and_extract(url: str, extract_path: str, name: str) -> None: """ - Tries to load addons (in a separate thread) + Downloads and extracts an addon from a given URL to a specified path """ - thread = threading.Thread( - target=try_load_addons, args=(debug_port_int, addons_list), daemon=True - ) - thread.start() + # Create a temporary file to store the downloaded zip + buffer = webdl(url, desc=f"Downloading addon ({name})", bar=False) + unzip(buffer, extract_path, f"Extracting addon ({name})", bar=False) -def try_load_addons(debug_port_int: int, addons_list: List[str]) -> None: +def get_addon_path(addon_name: str) -> str: """ - Tries to load addons + Returns a path to the addon """ - # Wait for the server to be open - while True: + return get_path(os.path.join("addons", addon_name)) + + +def maybe_download_addons( + addons: List[DefaultAddons], addons_list: Optional[List[str]] = None +) -> None: + """ + Downloads and extracts addons from a given dictionary to a specified list + Skips downloading if the addon is already downloaded + """ + for addon in addons: + # Get the addon path + addon_path = get_addon_path(addon.name) + + # Check if the addon is already extracted + if os.path.exists(addon_path): + # Add the existing addon path to addons_list + if addons_list is not None: + addons_list.append(addon_path) + continue + + # Addon doesn't exist, create directory and download try: - with socket.create_connection(("localhost", debug_port_int)): - break - except ConnectionRefusedError: - time.sleep(0.05) - - # Load addons - asyncio.run(load_all_addons(debug_port_int, addons_list)) - - -async def load_all_addons(debug_port_int: int, addons_list: List[str]) -> None: - """ - Loads all addons - """ - addon_loaders = [LoadFirefoxAddon(debug_port_int, addon) for addon in addons_list] - await asyncio.gather(*[addon_loader.load() for addon_loader in addon_loaders]) - - -class LoadFirefoxAddon: - ''' - Firefox addon loader - https://github.com/daijro/hrequests/blob/main/hrequests/extensions.py#L95 - ''' - - def __init__(self, port, addon_path): - self.port: int = port - self.addon_path: str = addon_path - self.success: bool = False - self.buffers: list = [] - self.remaining_bytes: int = 0 - - async def load(self): - reader, writer = await asyncio.open_connection('localhost', self.port) - writer.write(self._format_message({"to": "root", "type": "getRoot"})) - await writer.drain() - - while True: - data = await reader.read(100) # Adjust buffer size as needed - if not data: - break - await self._process_data(writer, data) - - writer.close() - await writer.wait_closed() - return self.success - - async def _process_data(self, writer, data): - while data: - if self.remaining_bytes == 0: - index = data.find(b':') - if index == -1: - self.buffers.append(data) - return - - total_data = b''.join(self.buffers) + data - size, _, remainder = total_data.partition(b':') - - try: - self.remaining_bytes = int(size) - except ValueError as e: - raise ValueError("Invalid state") from e - - data = remainder - - if len(data) < self.remaining_bytes: - self.remaining_bytes -= len(data) - self.buffers.append(data) - return - else: - self.buffers.append(data[: self.remaining_bytes]) - message = orjson.loads(b''.join(self.buffers)) - self.buffers.clear() - - await self._on_message(writer, message) - - data = data[self.remaining_bytes :] - self.remaining_bytes = 0 - - async def _on_message(self, writer, message): - if "addonsActor" in message: - writer.write( - self._format_message( - { - "to": message["addonsActor"], - "type": "installTemporaryAddon", - "addonPath": self.addon_path, - } - ) - ) - await writer.drain() - - if "addon" in message: - self.success = True - writer.write_eof() - - if "error" in message: - writer.write_eof() - - def _format_message(self, data): - raw = orjson.dumps(data) - return f"{len(raw)}:".encode() + raw + os.makedirs(addon_path, exist_ok=True) + download_and_extract(addon.value, addon_path, addon.name) + # Add the new addon directory path to addons_list + if addons_list is not None: + addons_list.append(addon_path) + except Exception as e: + print(f"Failed to download and extract {addon.name}: {e}") diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index e4e3936..517a3ac 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -14,12 +14,7 @@ from screeninfo import get_monitors from typing_extensions import TypeAlias from ua_parser import user_agent_parser -from .addons import ( - DefaultAddons, - confirm_paths, - get_debug_port, - threaded_try_load_addons, -) +from .addons import DefaultAddons, add_default_addons, confirm_paths from .exceptions import ( InvalidOS, InvalidPropertyType, @@ -32,7 +27,7 @@ from .locale import geoip_allowed, get_geolocation, handle_locales from .pkgman import OS_NAME, get_path, installed_verstr, launch_path from .virtdisplay import VirtualDisplay from .warnings import LeakWarning -from .xpi_dl import add_default_addons +from .webgl import sample_webgl ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str] @@ -345,7 +340,8 @@ def launch_options( os: Optional[ListOrString] = None, block_images: Optional[bool] = None, block_webrtc: Optional[bool] = None, - allow_webgl: Optional[bool] = None, + block_webgl: Optional[bool] = None, + webgl_config: Optional[Tuple[str, str]] = None, geoip: Optional[Union[str, bool]] = None, humanize: Optional[Union[bool, float]] = None, locale: Optional[Union[str, List[str]]] = None, @@ -383,7 +379,7 @@ def launch_options( Whether to block all images. block_webrtc (Optional[bool]): Whether to block WebRTC entirely. - allow_webgl (Optional[bool]): + block_webgl (Optional[bool]): Whether to allow WebGL. To prevent leaks, only use this for special cases. geoip (Optional[Union[str, bool]]): Calculate longitude, latitude, timezone, country, & locale based on the IP address. @@ -434,6 +430,8 @@ def launch_options( 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. """ @@ -476,6 +474,7 @@ def launch_options( # Confirm all addon paths are valid if addons: confirm_paths(addons) + config['addons'] = addons # Get the Firefox version if ff_version: @@ -512,7 +511,7 @@ def launch_options( config['fonts'] = fonts update_fonts(config, target_os) # Set a fixed font spacing seed - set_into(config, 'fonts:spacing_seed', randint(0, 2147483647)) # nosec + set_into(config, 'fonts:spacing_seed', randint(0, 1_073_741_823)) # nosec # Set geolocation if geoip: @@ -555,29 +554,40 @@ def launch_options( if isinstance(humanize, (int, float)): set_into(config, 'humanize:maxTime', humanize) - # Validate the config - validate_config(config, path=executable_path) + # Set Firefox user preferences + if block_images: + firefox_user_prefs['permissions.default.image'] = 2 + if block_webrtc: + firefox_user_prefs['media.peerconnection.enabled'] = False + if block_webgl: + firefox_user_prefs['webgl.disabled'] = True + else: + # Select a random webgl pair + if webgl_config: + merge_into(config, sample_webgl(target_os, *webgl_config)) + else: + merge_into(config, sample_webgl(target_os)) + # Use software rendering to be less unique + merge_into( + firefox_user_prefs, + { + 'webgl.forbid-software': False, + 'webgl.forbid-hardware': True, + '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) - # Set Firefox user preferences - if block_images: - firefox_user_prefs['permissions.default.image'] = 2 - if block_webrtc: - firefox_user_prefs['media.peerconnection.enabled'] = False - if allow_webgl: - LeakWarning.warn('allow_webgl', i_know_what_im_doing) - firefox_user_prefs['webgl.disabled'] = False - - # Cache previous pages, requests, etc (uses more memory) - if enable_cache: - firefox_user_prefs.update(CACHE_PREFS) - - # Load the addons - threaded_try_load_addons(get_debug_port(args), addons) + # Validate the config + validate_config(config, path=executable_path) # Prepare environment variables to pass to Camoufox env_vars = { diff --git a/pythonlib/camoufox/warnings.yml b/pythonlib/camoufox/warnings.yml index 4c64f7e..10a87db 100644 --- a/pythonlib/camoufox/warnings.yml +++ b/pythonlib/camoufox/warnings.yml @@ -28,10 +28,6 @@ custom_fingerprint: >- proxy_without_geoip: >- When using a proxy, it is heavily recommended that you pass `geoip=True`. -allow_webgl: >- - Enabling WebGL can lead to Canvas fingerprinting and detection. - Camoufox will automatically spoof your vendor and renderer, but it cannot spoof your WebGL fingerprint. - ff_version: >- Spoofing the Firefox version will likely lead to detection. If rotating the Firefox version is absolutely necessary, it would be more advisable to diff --git a/pythonlib/camoufox/webgl/__init__.py b/pythonlib/camoufox/webgl/__init__.py new file mode 100644 index 0000000..ca911a3 --- /dev/null +++ b/pythonlib/camoufox/webgl/__init__.py @@ -0,0 +1,3 @@ +from .sample import sample_webgl + +__all__ = ['sample_webgl'] diff --git a/pythonlib/camoufox/webgl/sample.py b/pythonlib/camoufox/webgl/sample.py new file mode 100644 index 0000000..9d34e64 --- /dev/null +++ b/pythonlib/camoufox/webgl/sample.py @@ -0,0 +1,82 @@ +import sqlite3 +from pathlib import Path +from typing import Dict, Optional + +import numpy as np +import orjson + + +def sample_webgl( + os: str, vendor: Optional[str] = None, renderer: Optional[str] = None +) -> Dict[str, str]: + """ + Sample a random WebGL vendor/renderer combination and its data based on OS probabilities. + Optionally use a specific vendor/renderer pair. + + Args: + os: Operating system ('win', 'mac', or 'lin') + vendor: Optional specific vendor to use + renderer: Optional specific renderer to use (requires vendor to be set) + + Returns: + Dict containing WebGL data including vendor, renderer and additional parameters + + Raises: + ValueError: If invalid OS provided or no data found for OS/vendor/renderer + """ + # Map OS to probability column + os_map = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'} + if os not in os_map: + raise ValueError(f'Invalid OS: {os}. Must be one of: {", ".join(os_map)}') + os = os_map[os] + + # Get database path relative to this file + db_path = Path(__file__).parent / 'webgl_data.db' + + # Connect to database + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + if vendor and renderer: + # Get specific vendor/renderer pair and verify it exists for this OS + cursor.execute( + f'SELECT vendor, renderer, data, {os} FROM webgl_fingerprints WHERE vendor = ? AND renderer = ?', + (vendor, renderer), + ) + result = cursor.fetchone() + conn.close() + + if not result: + raise ValueError(f'No WebGL data found for vendor "{vendor}" and renderer "{renderer}"') + + if result[3] <= 0: # Check OS-specific probability + raise ValueError( + f'Vendor "{vendor}" and renderer "{renderer}" combination not valid for {os}' + ) + + return { + 'vendor': result[0], + 'renderer': result[1], + **orjson.loads(result[2]), + } + + # Get all vendor/renderer pairs and their probabilities for this OS + cursor.execute(f'SELECT vendor, renderer, data, {os} FROM webgl_fingerprints WHERE {os} > 0') + results = cursor.fetchall() + conn.close() + + if not results: + raise ValueError(f'No WebGL data found for OS: {os}') + + # Split into separate arrays + _, _, data_strs, probs = map(list, zip(*results)) + + # Convert probabilities to numpy array and normalize + probs_array = np.array(probs, dtype=np.float64) + probs_array = probs_array / probs_array.sum() + + # Sample based on probabilities + idx = np.random.choice(len(probs_array), p=probs_array) + + # Parse the JSON data string + return orjson.loads(data_strs[idx]) diff --git a/pythonlib/camoufox/webgl/webgl_data.db b/pythonlib/camoufox/webgl/webgl_data.db new file mode 100644 index 0000000000000000000000000000000000000000..e0b06d635bc50b4e7998d03d9c75f133e45e7428 GIT binary patch literal 274432 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 Vkwg4{qLHh_|0h4;`Ncjz{Qp|O^Z@_> literal 0 HcmV?d00001 diff --git a/pythonlib/camoufox/xpi_dl.py b/pythonlib/camoufox/xpi_dl.py deleted file mode 100644 index b05822b..0000000 --- a/pythonlib/camoufox/xpi_dl.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -from multiprocessing import Lock -from typing import List, Optional - -from .addons import DefaultAddons -from .pkgman import get_path, unzip, webdl - - -def add_default_addons( - addons_list: List[str], exclude_list: Optional[List[DefaultAddons]] = None -) -> None: - """ - Adds default addons, minus any specified in exclude_list, to addons_list - """ - # Build a dictionary from DefaultAddons, excluding keys found in exclude_list - if exclude_list is None: - exclude_list = [] - - addons = [addon for addon in DefaultAddons if addon not in exclude_list] - - with Lock(): - maybe_download_addons(addons, addons_list) - - -def download_and_extract(url: str, extract_path: str, name: str) -> None: - """ - Downloads and extracts an addon from a given URL to a specified path - """ - # Create a temporary file to store the downloaded zip - buffer = webdl(url, desc=f"Downloading addon ({name})", bar=False) - unzip(buffer, extract_path, f"Extracting addon ({name})", bar=False) - - -def get_addon_path(addon_name: str) -> str: - """ - Returns a path to the addon - """ - return get_path(os.path.join("addons", addon_name)) - - -def maybe_download_addons( - addons: List[DefaultAddons], addons_list: Optional[List[str]] = None -) -> None: - """ - Downloads and extracts addons from a given dictionary to a specified list - Skips downloading if the addon is already downloaded - """ - for addon in addons: - # Get the addon path - addon_path = get_addon_path(addon.name) - - # Check if the addon is already extracted - if os.path.exists(addon_path): - # Add the existing addon path to addons_list - if addons_list is not None: - addons_list.append(addon_path) - continue - - # Addon doesn't exist, create directory and download - try: - os.makedirs(addon_path, exist_ok=True) - download_and_extract(addon.value, addon_path, addon.name) - # Add the new addon directory path to addons_list - if addons_list is not None: - addons_list.append(addon_path) - except Exception as e: - print(f"Failed to download and extract {addon.name}: {e}") diff --git a/pythonlib/pyproject.toml b/pythonlib/pyproject.toml index d24a95b..af7d6b0 100644 --- a/pythonlib/pyproject.toml +++ b/pythonlib/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "camoufox" -version = "0.3.10" +version = "0.4.0" description = "Wrapper around Playwright to help launch Camoufox" authors = ["daijro "] license = "MIT"