diff --git a/docs/per-context-patches.md b/docs/per-context-patches.md index 8f59613..5f71c08 100644 --- a/docs/per-context-patches.md +++ b/docs/per-context-patches.md @@ -610,7 +610,7 @@ bundles are shipped in the wheel. | Property | Source | Notes | |----------|--------|-------| -| UA, platform, HWC, oscpu | fpgen or preset | UA version patched to match Camoufox Firefox version | +| UA, platform, HWC, oscpu | fpgen or preset | UA version patched to the browser's Firefox version (NewContext reads it from Playwright's `browser.version` unless `ff_version` is given) | | Screen dims, colorDepth | fpgen or preset | Viewport adjusted by -28px for browser chrome | | WebGL vendor/renderer | `sample_webgl()` from `webgl_data.db` | OS-weighted probability sampling. fpgen's own WebGL fields are not mapped in `fpgen.yml` yet, so both paths call `sample_webgl()`. | | Font list | `_generate_random_font_subset()` | One weighted OS-version base in full, plus each addition unit at its measured probability; marker fonts always included. See [FONTS.md](FONTS.md). NOT from presets. | diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py index ee4357d..e05c813 100644 --- a/pythonlib/camoufox/async_api.py +++ b/pythonlib/camoufox/async_api.py @@ -195,15 +195,15 @@ async def AsyncNewContext( """ Creates a new browser context with a unique fingerprint identity. - Each context gets its own real fingerprint preset (navigator, screen, WebGL, fonts, etc.) - with its own audio noise seed. All values are applied + Each context gets its own identity (navigator, screen, WebGL, fonts, voices), + drawn by fpgen unless a preset is given, with its own audio noise seed. All values are applied via addInitScript so they self-destruct before page scripts can detect them. Parameters: browser: A Browser instance from AsyncNewBrowser or AsyncCamoufox. - preset: A specific fingerprint preset dict to use. If None, picks randomly. - os: Target OS for preset selection ("windows", "macos", "linux"). - ff_version: Firefox version string for UA patching. + preset: A fingerprint preset dict to use. If None, fpgen draws a new identity. + os: Target OS for the drawn identity ("windows", "macos", "linux"). + ff_version: Firefox major version to claim in the UA. Defaults to the browser's own. webrtc_ip: IPv4 address to spoof for WebRTC ICE candidates. proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}). Unless webrtc_ip and timezone_id are both given, they are looked up from the @@ -211,6 +211,10 @@ async def AsyncNewContext( geolocation: Per-context geolocation ({"latitude": float, "longitude": float}). **context_kwargs: Additional Playwright new_context() options. """ + # The drawn UA carries fpgen's Firefox version, which must not disagree with + # the browser the page is actually talking to. + ff_version = ff_version or browser.version.split('.', 1)[0] + # Auto-derive WebRTC IP and timezone from proxy's exit IP when not explicitly provided if proxy and (not webrtc_ip or "timezone_id" not in context_kwargs): exit_ip, timezone = await _resolve_proxy_geo(proxy) diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py index 4c7a897..be07644 100644 --- a/pythonlib/camoufox/sync_api.py +++ b/pythonlib/camoufox/sync_api.py @@ -173,15 +173,15 @@ def NewContext( """ Creates a new browser context with a unique fingerprint identity. - Each context gets its own real fingerprint preset - with its own audio noise seed. All values are applied + Each context gets its own identity (navigator, screen, WebGL, fonts, voices), + drawn by fpgen unless a preset is given, with its own audio noise seed. All values are applied via addInitScript so they self-destruct before page scripts can detect them. Parameters: browser: A Browser instance from NewBrowser or Camoufox. - preset: A specific fingerprint preset dict to use. If None, picks randomly. - os: Target OS for preset selection ("windows", "macos", "linux"). - ff_version: Firefox version string for UA patching. + preset: A fingerprint preset dict to use. If None, fpgen draws a new identity. + os: Target OS for the drawn identity ("windows", "macos", "linux"). + ff_version: Firefox major version to claim in the UA. Defaults to the browser's own. webrtc_ip: IPv4 address to spoof for WebRTC ICE candidates. proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}). Unless webrtc_ip and timezone_id are both given, they are looked up from the @@ -189,6 +189,10 @@ def NewContext( geolocation: Per-context geolocation ({"latitude": float, "longitude": float}). **context_kwargs: Additional Playwright new_context() options. """ + # The drawn UA carries fpgen's Firefox version, which must not disagree with + # the browser the page is actually talking to. + ff_version = ff_version or browser.version.split('.', 1)[0] + # Auto-derive WebRTC IP and timezone from proxy's exit IP when not explicitly provided if proxy and (not webrtc_ip or "timezone_id" not in context_kwargs): exit_ip, timezone = _resolve_proxy_geo(proxy) diff --git a/pythonlib/tests/test_new_context_version.py b/pythonlib/tests/test_new_context_version.py new file mode 100644 index 0000000..031d60b --- /dev/null +++ b/pythonlib/tests/test_new_context_version.py @@ -0,0 +1,36 @@ +"""A context's user agent carries the Firefox version of the browser it runs in. + +Without an explicit ff_version, NewContext kept whatever version fpgen drew +(e.g. Firefox/146) on a 160 browser, so the UA disagreed with every +version-dependent API the page could probe. +""" + +import asyncio +import re +from unittest import mock + +import pytest + +from camoufox import async_api, sync_api + + +def _user_agent(init_script): + return re.search(r'setNavigatorUserAgent\("([^"]+)"\)', init_script).group(1) + + +@pytest.mark.parametrize("api", ["sync", "async"]) +def test_user_agent_matches_the_browser_version(api): + context = mock.MagicMock() + browser = mock.MagicMock() + # Playwright's Browser.version for Firefox: MOZ_APP_VERSION_DISPLAY. + browser.version = "160.0.1" + if api == "sync": + browser.new_context.return_value = context + sync_api.NewContext(browser, os="linux") + else: + context.add_init_script = mock.AsyncMock() + browser.new_context = mock.AsyncMock(return_value=context) + asyncio.run(async_api.AsyncNewContext(browser, os="linux")) + + user_agent = _user_agent(context.add_init_script.call_args.args[0]) + assert "Firefox/160.0" in user_agent and "rv:160.0" in user_agent, user_agent diff --git a/pythonlib/tests/test_proxy_geo.py b/pythonlib/tests/test_proxy_geo.py index c719bfa..0b8dbfb 100644 --- a/pythonlib/tests/test_proxy_geo.py +++ b/pythonlib/tests/test_proxy_geo.py @@ -34,6 +34,7 @@ EXIT = {"status": "success", "query": "203.0.113.7", "timezone": "Europe/Paris"} def _sync_browser(): browser = mock.MagicMock() + browser.version = "152.0.4" return browser @@ -41,6 +42,7 @@ def _async_browser(): context = mock.MagicMock() context.add_init_script = mock.AsyncMock() browser = mock.MagicMock() + browser.version = "152.0.4" browser.new_context = mock.AsyncMock(return_value=context) return browser