Who's asking
I maintain camoufox-profile-manager,
an open-source, self-hosted profile manager built on Camoufox — roughly what
AdsPower or Dolphin do commercially, for people who would rather run it
themselves. Camoufox does the hard part; we manage profiles, proxies, groups and
the browser lifecycle around it.
Thanks for the browser. Pinning a resolved config and replaying it across
launches works beautifully — user agent, screen, GPU, cores, audio and fonts all
hold steady, which is exactly what a long-lived profile needs.
This is not a request to change any default. We have a working setup and are
not blocked. It is a report that one advertised property appears to be inert, in
case that is news to you.
Describe the bug:
The finding
canvas:seed is advertised as a config property but nothing seems to consume it:
- Declared in
settings/properties.json and settings/camoucfg.jvv
- Generated and passed by
pythonlib/camoufox/utils.py
(set_into(config, 'canvas:seed', randint(...)))
- Referenced by
tests/patches/config-overrides.py
- Not read anywhere in
additions/camoucfg/MaskConfig.hpp, and no patch in
patches/ mentions canvas
audio:seed and fonts:spacing_seed sit beside it in the same code paths and do
take effect, which is what made this surprising. Setting canvas:seed to any
value produces no change in the exported canvas.
Relatedly, docs/per-context-patches.md documents window.setCanvasSeed(), but
it is undefined in the shipped build while its siblings are defined:
window.setCanvasSeed undefined
window.setNavigatorUserAgent function
window.setWebGLVendor function
If that work simply is not released yet, this may already be handled on your
side — a pointer is all we need.
To Reproduce:
Camoufox 152.0.4-beta.28, Python lib 0.5.x, macOS arm64.
canvas_seed_repro.py
"""Reproduction: canvas:seed does not affect the 2D canvas fingerprint.
Camoufox 152.0.4-beta.28, Python lib 0.5.x.
pip install camoufox[geoip] && camoufox fetch
python canvas_seed_repro.py
Every check below is printed with what was expected, so the output stands on
its own.
"""
import asyncio
import hashlib
import tempfile
from pathlib import Path
from camoufox.async_api import AsyncCamoufox
# A page reads its canvas the way a fingerprinting script would.
CANVAS = """() => {
const c = document.createElement('canvas');
c.width = 300; c.height = 70;
const x = c.getContext('2d');
x.textBaseline = 'top';
x.font = '16px Arial';
x.fillStyle = '#f60'; x.fillRect(0, 0, 120, 25);
x.fillStyle = '#069'; x.fillText('canvas probe', 2, 18);
return c.toDataURL();
}"""
SITE = "https://example.com"
OTHER_SITE = "https://iana.org"
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()[:16]
async def read_canvas(context, url=SITE):
page = await context.new_page()
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
value = digest(await page.evaluate(CANVAS))
await page.close()
return value
async def main():
root = Path(tempfile.mkdtemp(prefix="canvas-seed-"))
seed_config = {"canvas:seed": 424242}
def options(profile_dir):
return dict(
headless=True,
os="windows",
i_know_what_im_doing=True,
persistent_context=True,
user_data_dir=str(root / profile_dir),
config=dict(seed_config),
)
print("Camoufox canvas:seed reproduction")
print("=" * 66)
# 1. Within one session the value is stable per site. This is correct.
async with AsyncCamoufox(**options("a")) as context:
first_tab = await read_canvas(context)
second_tab = await read_canvas(context)
other_site = await read_canvas(context, OTHER_SITE)
print("\nWithin one session, canvas:seed = 424242")
print(f" same site, tab 1 {first_tab}")
print(f" same site, tab 2 {second_tab} equal: {first_tab == second_tab} (expected: equal)")
print(f" a different site {other_site} equal: {first_tab == other_site} (expected: not equal)")
# 2. Across launches of the SAME profile with the SAME seed, it changes.
# This is the bug: the seed should make it reproducible.
async with AsyncCamoufox(**options("a")) as context:
second_launch = await read_canvas(context)
async with AsyncCamoufox(**options("a")) as context:
third_launch = await read_canvas(context)
print("\nSame profile, same seed, relaunched")
print(f" launch 1 {first_tab}")
print(f" launch 2 {second_launch}")
print(f" launch 3 {third_launch}")
stable = first_tab == second_launch == third_launch
print(f" all equal: {stable} (expected: equal, since the seed is fixed)")
# 3. Two different seeds, fresh profiles: no relationship either way.
async with AsyncCamoufox(**{**options("b"), "config": {"canvas:seed": 111}}) as context:
seed_111 = await read_canvas(context)
async with AsyncCamoufox(**{**options("c"), "config": {"canvas:seed": 999}}) as context:
seed_999 = await read_canvas(context)
print("\nDifferent seeds, fresh profiles")
print(f" canvas:seed = 111 {seed_111}")
print(f" canvas:seed = 999 {seed_999}")
# 4. Is the per-context API from docs/per-context-patches.md present?
async with AsyncCamoufox(headless=True, os="windows", i_know_what_im_doing=True) as browser:
page = await browser.new_page()
await page.goto("about:blank")
present = await page.evaluate(
"""() => ({
setCanvasSeed: typeof window.setCanvasSeed,
setNavigatorUserAgent: typeof window.setNavigatorUserAgent,
setWebGLVendor: typeof window.setWebGLVendor,
})"""
)
print("\nPer-context API in this build")
for name, kind in present.items():
print(f" window.{name:24} {kind}")
print("\n" + "=" * 66)
print("Result:", "seed is honoured" if stable else "the seed has no effect on the canvas fingerprint")
asyncio.run(main())
Within one session, canvas:seed = 424242
same site, tab 1 cc9159d5dd44dc0e
same site, tab 2 cc9159d5dd44dc0e equal: True (expected: equal)
a different site 425ea3469ee3f27c equal: False (expected: not equal)
Same profile, same seed, relaunched
launch 1 cc9159d5dd44dc0e
launch 2 57f921e26a94bcdc
launch 3 abcf3e69880a54fe
all equal: False (expected: equal, since the seed is fixed)
Per-context API in this build
window.setCanvasSeed undefined
The first block is correct behaviour: stable per site within a session, different
across sites. Only the seed is the issue.
Scope, for the record
Since I had to measure this precisely, in case it saves you time:
| Surface |
Behaviour |
toDataURL() / toBlob(), 2D context |
randomised per site, per session |
toDataURL(), WebGL context |
randomised the same way |
getImageData() |
stable |
readPixels() |
stable |
measureText() |
stable |
So it is image export that is randomised, from either context type.
What we do instead
For completeness, so this does not read as a blocker: launching with
privacy.baselineFingerprintingProtection = false disables the randomisation,
and combined with a pinned fonts:spacing_seed the canvas becomes reproducible
across launches. Text-bearing canvases need that second part — that is what
misled me at first, since the font seed masked the pref's effect.
That workaround costs cross-site unlinkability: the canvas becomes identical
everywhere. For one long-lived account per profile that is the right trade and
matches what a real machine does, so we will expose it as a per-profile setting
rather than a default.
A working canvas:seed would be strictly better than the pref, because it would
give each profile its own canvas value instead of one shared true render — which
is why the report is still worth making.
Offer
Happy to write the patch if you can point at where the export randomisation is
applied; I could not find it from the repository alone and would rather not guess
at C++ I cannot build and test here. I can also verify any build against the
reproduction above on macOS arm64 and Linux x86_64 and report back.
If the intended behaviour is that canvas:seed is reserved for the unreleased
per-context work, feel free to close this — it would be worth a note in the
property manifest either way, since the Python layer emits it today.
Version:
Python Packages
Camoufox v0.5.4
Browserforge v1.2.4
Apify Fingerprints v0.15.0
Playwright v1.60.0
Browser
Active official/stable
Current browser v152.0.4-beta.28
Host: macOS 15 arm64. Also reproduced on Linux x86_64.
Who's asking
I maintain camoufox-profile-manager,
an open-source, self-hosted profile manager built on Camoufox — roughly what
AdsPower or Dolphin do commercially, for people who would rather run it
themselves. Camoufox does the hard part; we manage profiles, proxies, groups and
the browser lifecycle around it.
Thanks for the browser. Pinning a resolved config and replaying it across
launches works beautifully — user agent, screen, GPU, cores, audio and fonts all
hold steady, which is exactly what a long-lived profile needs.
This is not a request to change any default. We have a working setup and are
not blocked. It is a report that one advertised property appears to be inert, in
case that is news to you.
Describe the bug:
The finding
canvas:seedis advertised as a config property but nothing seems to consume it:settings/properties.jsonandsettings/camoucfg.jvvpythonlib/camoufox/utils.py(
set_into(config, 'canvas:seed', randint(...)))tests/patches/config-overrides.pyadditions/camoucfg/MaskConfig.hpp, and no patch inpatches/mentions canvasaudio:seedandfonts:spacing_seedsit beside it in the same code paths and dotake effect, which is what made this surprising. Setting
canvas:seedto anyvalue produces no change in the exported canvas.
Relatedly,
docs/per-context-patches.mddocumentswindow.setCanvasSeed(), butit is
undefinedin the shipped build while its siblings are defined:If that work simply is not released yet, this may already be handled on your
side — a pointer is all we need.
To Reproduce:
Camoufox
152.0.4-beta.28, Python lib 0.5.x, macOS arm64.canvas_seed_repro.pyThe first block is correct behaviour: stable per site within a session, different
across sites. Only the seed is the issue.
Scope, for the record
Since I had to measure this precisely, in case it saves you time:
toDataURL()/toBlob(), 2D contexttoDataURL(), WebGL contextgetImageData()readPixels()measureText()So it is image export that is randomised, from either context type.
What we do instead
For completeness, so this does not read as a blocker: launching with
privacy.baselineFingerprintingProtection = falsedisables the randomisation,and combined with a pinned
fonts:spacing_seedthe canvas becomes reproducibleacross launches. Text-bearing canvases need that second part — that is what
misled me at first, since the font seed masked the pref's effect.
That workaround costs cross-site unlinkability: the canvas becomes identical
everywhere. For one long-lived account per profile that is the right trade and
matches what a real machine does, so we will expose it as a per-profile setting
rather than a default.
A working
canvas:seedwould be strictly better than the pref, because it wouldgive each profile its own canvas value instead of one shared true render — which
is why the report is still worth making.
Offer
Happy to write the patch if you can point at where the export randomisation is
applied; I could not find it from the repository alone and would rather not guess
at C++ I cannot build and test here. I can also verify any build against the
reproduction above on macOS arm64 and Linux x86_64 and report back.
If the intended behaviour is that
canvas:seedis reserved for the unreleasedper-context work, feel free to close this — it would be worth a note in the
property manifest either way, since the Python layer emits it today.
Version:
Host: macOS 15 arm64. Also reproduced on Linux x86_64.