Skip to content

canvas:seed is declared and passed through, but nothing consumes it #721

Description

@polyackiy

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions