fix(python): give NewContext identities the browser's Firefox version

NewContext() and AsyncNewContext() passed ff_version=None through to
generate_context_fingerprint(), so a context's user agent kept the version
fpgen drew (e.g. Firefox/146) while the browser underneath was 152. They
now default ff_version to the major version of Playwright's
Browser.version, which Juggler reports from MOZ_APP_VERSION_DISPLAY, so the
UA always names the browser the page is actually talking to. An explicit
ff_version still wins.

The docstrings said each context gets "its own real fingerprint preset";
the default has been an fpgen draw, with a preset only when one is passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-25 20:34:17 -06:00
co-authored by Claude Opus 5.5
parent 165e68f831
commit 46e5c8d1a4
5 changed files with 57 additions and 11 deletions
+9 -5
View File
@@ -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)
+9 -5
View File
@@ -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)
@@ -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
+2
View File
@@ -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