mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-07 08:00:33 +00:00
Launcher-side fixes found by comparing camoufox against stock Firefox 152.0.4 on Linux, Windows 11 and macOS hosts: - DNT / GPC: BrowserForge draws doNotTrack "1" on most Firefox samples, but a stock Firefox 152 reports "unspecified" and globalPrivacyControl false; the stock defaults are used unless the caller sets them, and both are applied as prefs so the API, the worker and the DNT / Sec-GPC headers agree (#760). - Timezone and geolocation: the timezone is passed to the browser, and a configured position sets permissions.default.geo so permissions.query agrees with the auto-grant (#769, #773). - hardwareConcurrency: the reported count is the fingerprint's and the browser is pinned to that many cores (cpu_affinity.py, Linux/Windows), so worker timing agrees with it; otherwise the host count snapped into the core counts real machines ship with (never 2, Firefox's resistFingerprinting value). - Fonts: the OS base is always present in full, OS-version variants are drawn all-or-nothing, co-shipped groups stay together, Cascadia is never claimed off Windows, a native macOS/Windows identity claims only the real OS base, and gfx.font_rendering.fallback.async is off on Linux so per-character fallback does not depend on cmap-load timing. - Speech voices: a per-OS installed-voice model (voice-manifests.json) with the voiceURI formats each backend really produces (voice-uris.json); no default voice where stock has none. - WebGL: extensions a release Firefox never exposes are filtered, but OVR_multiview2 stays for Windows D3D11 renderers, which expose it. - Media devices: a seeded draw of common per-OS devices with OS-style labels. - Windows scrollbars follow the drawn Windows version (overlay on 11). - Glyph-advance perturbation (fonts:spacing_seed) defaults to off: it moved every measureText width off the value the same font gives on a real machine. - Launcher prefs are also exported as CAMOU_PREFS_1..N so camoufox.cfg applies them at startup, and the browser UI locale follows the spoofed locale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
14 KiB
Python
360 lines
14 KiB
Python
"""
|
|
Tests for the BrowserForge fingerprint-correction helpers in
|
|
camoufox.fingerprints, ported to parity with the camoufox-js launcher.
|
|
|
|
Run with:
|
|
cd pythonlib && python -m pytest tests/test_fingerprint_fixes.py -v
|
|
|
|
These guard the headless / impossible-geometry tells that BrowserForge
|
|
occasionally ships and that the camoufox-js wrapper already corrected but the
|
|
pythonlib did not.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from camoufox.fingerprints import ( # noqa: E402
|
|
clamp_screen_to_display,
|
|
clamp_window_dimensions,
|
|
clamp_window_position,
|
|
fix_navigator_arch,
|
|
fix_screen_no_taskbar,
|
|
set_media_devices_defaults,
|
|
)
|
|
|
|
|
|
class TestFixNavigatorArch:
|
|
def test_corrects_armv81_to_ua_arch(self):
|
|
c = {
|
|
"navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) ...",
|
|
"navigator.platform": "Linux armv81",
|
|
"navigator.oscpu": "Linux armv81",
|
|
}
|
|
fix_navigator_arch(c, "lin")
|
|
assert c["navigator.platform"] == "Linux x86_64"
|
|
assert c["navigator.oscpu"] == "Linux x86_64"
|
|
|
|
def test_noop_when_already_consistent(self):
|
|
c = {
|
|
"navigator.userAgent": "... Linux x86_64 ...",
|
|
"navigator.platform": "Linux x86_64",
|
|
"navigator.oscpu": "Linux x86_64",
|
|
}
|
|
fix_navigator_arch(c, "lin")
|
|
assert c["navigator.platform"] == "Linux x86_64"
|
|
|
|
def test_only_runs_on_linux(self):
|
|
c = {"navigator.userAgent": "... Macintosh ...", "navigator.platform": "MacIntel"}
|
|
fix_navigator_arch(c, "mac")
|
|
assert c["navigator.platform"] == "MacIntel"
|
|
|
|
def test_noop_without_ua(self):
|
|
c = {"navigator.platform": "Linux armv81"}
|
|
fix_navigator_arch(c, "lin")
|
|
assert c["navigator.platform"] == "Linux armv81"
|
|
|
|
|
|
class TestFixScreenNoTaskbar:
|
|
def test_subtracts_taskbar_when_avail_equals_screen(self):
|
|
c = {
|
|
"screen.width": 1920,
|
|
"screen.height": 1080,
|
|
"screen.availWidth": 1920,
|
|
"screen.availHeight": 1080,
|
|
"window.outerHeight": 1080,
|
|
"window.innerHeight": 1040,
|
|
}
|
|
fix_screen_no_taskbar(c, "lin")
|
|
assert c["screen.availHeight"] == 1080 - 27 # linux panel
|
|
assert c["window.outerHeight"] == 1053
|
|
# chrome delta (1080-1040=40) preserved
|
|
assert c["window.innerHeight"] == 1053 - 40
|
|
|
|
def test_per_os_taskbar_height(self):
|
|
for os_name, px in (("win", 40), ("mac", 25), ("lin", 27)):
|
|
c = {
|
|
"screen.width": 1920,
|
|
"screen.height": 1080,
|
|
"screen.availWidth": 1920,
|
|
"screen.availHeight": 1080,
|
|
}
|
|
fix_screen_no_taskbar(c, os_name)
|
|
assert c["screen.availHeight"] == 1080 - px
|
|
|
|
def test_noop_when_avail_already_less_than_screen(self):
|
|
c = {
|
|
"screen.width": 1920,
|
|
"screen.height": 1080,
|
|
"screen.availWidth": 1920,
|
|
"screen.availHeight": 1040,
|
|
}
|
|
fix_screen_no_taskbar(c, "lin")
|
|
assert c["screen.availHeight"] == 1040
|
|
|
|
|
|
class TestClampWindowDimensions:
|
|
def test_clamps_impossible_geometry_both_axes(self):
|
|
c = {
|
|
"screen.width": 1920,
|
|
"screen.height": 1080,
|
|
"screen.availWidth": 2000, # > screen
|
|
"window.outerWidth": 2200, # > avail
|
|
"window.innerWidth": 2100, # > outer
|
|
}
|
|
clamp_window_dimensions(c)
|
|
assert c["screen.availWidth"] == 1920
|
|
assert c["window.outerWidth"] == 1920
|
|
assert c["window.innerWidth"] <= c["window.outerWidth"]
|
|
|
|
def test_preserves_chrome_delta(self):
|
|
c = {
|
|
"screen.width": 1000,
|
|
"window.outerWidth": 1200, # 200 over screen
|
|
"window.innerWidth": 1180, # 20px chrome
|
|
}
|
|
clamp_window_dimensions(c)
|
|
assert c["window.outerWidth"] == 1000
|
|
assert c["window.innerWidth"] == 1000 - 20
|
|
|
|
def test_noop_when_hierarchy_already_valid(self):
|
|
c = {
|
|
"screen.width": 1920,
|
|
"screen.availWidth": 1920,
|
|
"window.outerWidth": 1280,
|
|
"window.innerWidth": 1264,
|
|
}
|
|
clamp_window_dimensions(c)
|
|
assert c["window.outerWidth"] == 1280
|
|
assert c["window.innerWidth"] == 1264
|
|
|
|
|
|
class TestClampScreenToDisplay:
|
|
def test_shrinks_screen_to_display(self):
|
|
# BrowserForge routinely ships a 2560x1440 fingerprint to a 1366x768
|
|
# laptop; browser-init then resizes the real window past the monitor.
|
|
c = {
|
|
"screen.width": 2560,
|
|
"screen.height": 1440,
|
|
"screen.availWidth": 2560,
|
|
"screen.availHeight": 1400,
|
|
}
|
|
clamp_screen_to_display(c, 1366, 768)
|
|
assert c["screen.width"] == 1366
|
|
assert c["screen.height"] == 768
|
|
# taskbar delta (1440-1400=40) preserved, so fix_screen_no_taskbar's
|
|
# avail < height invariant still holds
|
|
assert c["screen.availWidth"] == 1366
|
|
assert c["screen.availHeight"] == 768 - 40
|
|
|
|
def test_noop_when_already_within_display(self):
|
|
c = {"screen.width": 1280, "screen.height": 720, "screen.availHeight": 700}
|
|
clamp_screen_to_display(c, 1366, 768)
|
|
assert c["screen.width"] == 1280
|
|
assert c["screen.height"] == 720
|
|
assert c["screen.availHeight"] == 700
|
|
|
|
def test_ignores_unset_bounds(self):
|
|
c = {"screen.width": 2560, "screen.height": 1440}
|
|
clamp_screen_to_display(c, None, None)
|
|
assert c["screen.width"] == 2560
|
|
assert c["screen.height"] == 1440
|
|
|
|
def test_avail_never_drops_below_one(self):
|
|
# taskbar delta larger than the display must not produce a <= 0 avail
|
|
c = {"screen.height": 2000, "screen.availHeight": 100}
|
|
clamp_screen_to_display(c, None, 768)
|
|
assert c["screen.availHeight"] >= 1
|
|
|
|
def test_clamped_result_survives_cascade(self):
|
|
c = {
|
|
"screen.width": 2560,
|
|
"screen.height": 1440,
|
|
"screen.availWidth": 2560,
|
|
"screen.availHeight": 1400,
|
|
"window.outerWidth": 1920,
|
|
"window.outerHeight": 1055,
|
|
"window.innerWidth": 1920,
|
|
"window.innerHeight": 1000,
|
|
}
|
|
clamp_screen_to_display(c, 1366, 768)
|
|
clamp_window_dimensions(c)
|
|
assert c["window.outerWidth"] <= c["screen.availWidth"] <= c["screen.width"] == 1366
|
|
assert c["window.outerHeight"] <= c["screen.availHeight"] <= c["screen.height"] == 768
|
|
assert c["window.innerWidth"] <= c["window.outerWidth"]
|
|
assert c["window.innerHeight"] <= c["window.outerHeight"]
|
|
|
|
|
|
class TestClampWindowPosition:
|
|
def test_pulls_window_back_inside_screen(self):
|
|
c = {
|
|
"screen.width": 1366,
|
|
"screen.height": 768,
|
|
"window.outerWidth": 1366,
|
|
"window.outerHeight": 728,
|
|
"window.screenX": 250,
|
|
"window.screenY": 281,
|
|
}
|
|
clamp_window_position(c)
|
|
assert c["window.screenX"] == 0
|
|
assert c["window.screenY"] == 40
|
|
|
|
def test_noop_when_window_already_inside(self):
|
|
c = {
|
|
"screen.width": 1920,
|
|
"screen.height": 1080,
|
|
"window.outerWidth": 1280,
|
|
"window.outerHeight": 720,
|
|
"window.screenX": 100,
|
|
"window.screenY": 50,
|
|
}
|
|
clamp_window_position(c)
|
|
assert c["window.screenX"] == 100
|
|
assert c["window.screenY"] == 50
|
|
|
|
def test_never_negative(self):
|
|
c = {
|
|
"screen.width": 800,
|
|
"window.outerWidth": 1000, # wider than screen
|
|
"window.screenX": 50,
|
|
}
|
|
clamp_window_position(c)
|
|
assert c["window.screenX"] == 0
|
|
|
|
|
|
class TestSetMediaDevicesDefaults:
|
|
def test_draws_common_desktop_devices(self):
|
|
# A pre-permission page only sees "has a mic" / "has a camera"; the draw
|
|
# is seeded by the identity and follows the common desktop population.
|
|
c = {"navigator.userAgent": "ua", "navigator.platform": "Win32"}
|
|
set_media_devices_defaults(c)
|
|
assert c["mediaDevices:enabled"] is True
|
|
# counts and the post-grant label/group lists agree
|
|
for kind, key in (("micros", "microphone"), ("webcams", "webcam"), ("speakers", "speaker")):
|
|
n = c[f"mediaDevices:{kind}"]
|
|
assert n >= 0
|
|
assert len(c[f"mediaDevices:{key}Labels"]) == n
|
|
assert len(c[f"mediaDevices:{key}Groups"]) == n
|
|
# a Windows machine always has an output, in WASAPI label form
|
|
assert c["mediaDevices:speakers"] >= 1
|
|
assert all("(" in s for s in c["mediaDevices:speakerLabels"])
|
|
again = {"navigator.userAgent": "ua", "navigator.platform": "Win32"}
|
|
set_media_devices_defaults(again)
|
|
assert again == c
|
|
# over many identities laptops dominate Windows: most have both
|
|
mics = cams = 0
|
|
for i in range(400):
|
|
d = {"navigator.userAgent": f"ua{i}", "navigator.platform": "Win32"}
|
|
set_media_devices_defaults(d)
|
|
mics += d["mediaDevices:micros"] > 0
|
|
cams += d["mediaDevices:webcams"] > 0
|
|
assert 0.8 < mics / 400 < 1.0
|
|
assert 0.55 < cams / 400 < 0.95
|
|
|
|
def test_per_os_label_style(self):
|
|
from camoufox.fingerprints import draw_media_devices
|
|
|
|
mac = draw_media_devices("mac", 7)
|
|
assert all(not s.startswith("Microphone (") for s in mac["mediaDevices:microphoneLabels"])
|
|
lin = draw_media_devices("lin", 7)
|
|
# PulseAudio lists a monitor source per output as a capture device
|
|
outs = lin["mediaDevices:speakerLabels"]
|
|
assert all(f"Monitor of {o}" in lin["mediaDevices:microphoneLabels"] for o in outs)
|
|
# a sound card's mic and speakers share a group, like a real groupId
|
|
win = draw_media_devices("win", 11)
|
|
if win["mediaDevices:micros"] and win["mediaDevices:speakers"]:
|
|
assert win["mediaDevices:microphoneGroups"][0] == win["mediaDevices:speakerGroups"][0]
|
|
# never the fake engine's names
|
|
for os_key in ("win", "mac", "lin"):
|
|
for seed in range(50):
|
|
d = draw_media_devices(os_key, seed)
|
|
mics = d["mediaDevices:microphoneLabels"]
|
|
cams = d["mediaDevices:webcamLabels"]
|
|
assert "Default Audio Device" not in mics
|
|
assert "Default Video Device" not in cams
|
|
# distinct within a kind (macOS names a webcam and its mic alike)
|
|
assert len(set(mics)) == len(mics)
|
|
assert len(set(cams)) == len(cams)
|
|
|
|
def test_respects_user_set_media_devices(self):
|
|
c = {"mediaDevices:webcams": 5}
|
|
set_media_devices_defaults(c)
|
|
assert c == {"mediaDevices:webcams": 5}
|
|
|
|
|
|
class TestFixHardwareConcurrency:
|
|
def test_keeps_a_plausible_draw_the_host_can_be_pinned_to(self, monkeypatch):
|
|
# The fingerprint's value survives when it is a count a real desktop
|
|
# ships with AND the browser can be pinned to it (reported == measurable
|
|
# by construction).
|
|
from camoufox import cpu_affinity, fingerprints as fp
|
|
|
|
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
|
|
for drawn in (4, 6, 8, 10, 12, 14, 16):
|
|
c = {"navigator.hardwareConcurrency": drawn}
|
|
fp.fix_hardware_concurrency(c)
|
|
assert c["navigator.hardwareConcurrency"] == drawn
|
|
|
|
def test_snaps_implausible_draws_down_into_the_table(self, monkeypatch):
|
|
# browserforge draws counts no desktop ships with: over 400 linux draws
|
|
# 8.0% were < 4 cores and 4.2% were exactly 2. hardwareConcurrency == 2
|
|
# is what Firefox reports under resistFingerprinting, so CreepJS-style
|
|
# heuristics label the browser "Firefox resistFingerprinting"; odd counts
|
|
# (5, 7, 9, ...) are equally synthetic. They snap DOWN into
|
|
# PLAUSIBLE_CORE_COUNTS, with the table floor for anything below it.
|
|
from camoufox import cpu_affinity, fingerprints as fp
|
|
|
|
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
|
|
for drawn, expected in ((1, 4), (2, 4), (3, 4), (5, 4), (7, 6), (9, 8),
|
|
(11, 10), (13, 12), (15, 14), (32, 16)):
|
|
c = {"navigator.hardwareConcurrency": drawn}
|
|
fp.fix_hardware_concurrency(c)
|
|
assert c["navigator.hardwareConcurrency"] == expected, drawn
|
|
# never above what the host can be pinned to
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda: 4)
|
|
c = {"navigator.hardwareConcurrency": 2}
|
|
fp.fix_hardware_concurrency(c)
|
|
assert c["navigator.hardwareConcurrency"] == 4
|
|
|
|
def test_snaps_host_parallelism_when_it_cannot_pin(self, monkeypatch):
|
|
# The host count, snapped DOWN into the
|
|
# counts real machines ship with; the tails report 24 / 4. Used when the
|
|
# draw exceeds the host or the host cannot pin (macOS).
|
|
# 24 is in the table (recorded on real Windows/Linux devices); 2 is NOT,
|
|
# although it is recorded, because 2 is the resistFingerprinting value.
|
|
from camoufox import cpu_affinity, fingerprints as fp
|
|
|
|
monkeypatch.setattr(cpu_affinity, "supported", lambda: False)
|
|
for host, expected in (
|
|
(16, 16),
|
|
(10, 10),
|
|
(24, 24),
|
|
(32, 24),
|
|
(64, 24),
|
|
(7, 6),
|
|
(5, 4),
|
|
(2, 4),
|
|
(9, 8),
|
|
):
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda host=host: host)
|
|
c = {"navigator.hardwareConcurrency": 2}
|
|
fp.fix_hardware_concurrency(c)
|
|
assert c["navigator.hardwareConcurrency"] == expected, host
|
|
# a draw above the host count cannot be honoured even where pinning works
|
|
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda: 8)
|
|
c = {"navigator.hardwareConcurrency": 32}
|
|
fp.fix_hardware_concurrency(c)
|
|
assert c["navigator.hardwareConcurrency"] == 8
|
|
|
|
def test_no_host_count_leaves_the_draw(self, monkeypatch):
|
|
from camoufox import fingerprints as fp
|
|
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda: None)
|
|
c = {"navigator.hardwareConcurrency": 8}
|
|
fp.fix_hardware_concurrency(c)
|
|
assert c["navigator.hardwareConcurrency"] == 8
|