#760·camoufox

navigator.globalPrivacyControl differs between the main thread and a worker; navigator.doNotTrack is inert

Author: RayenMlayehCreated Sep 6, 2026Updated Sep 6, 2026

Describe the bug

navigator.globalPrivacyControl reports different values on the main thread and in a worker spawned from the same page. A page can read both and compare; genuine Firefox derives both from one pref, so they cannot differ. No reference data is needed.

Separately, navigator.doNotTrack is inert: the configured value never reaches the browser in either its JS or its header half.

Measured

152.0.4-beta.30, Windows 11 host, AsyncCamoufox(os="macos"), default config. The generated CAMOU_CONFIG carries navigator.globalPrivacyControl=True and navigator.doNotTrack='1':

property main thread worker header
navigator.globalPrivacyControl False True Sec-GPC absent
navigator.doNotTrack unspecified n/a DNT absent
navigator.platform (control) MacIntel MacIntel

The platform row is the control: the worker does inherit the spoofed identity, so this is specific to GPC rather than a general worker problem.

Cause

globalPrivacyControl is read in exactly one place, dom/workers/WorkerNavigator.cpp via fingerprint-injection.patch:

cpp
bool WorkerNavigator::GlobalPrivacyControl() const {
  if (auto value = MaskConfig::GetBool("navigator.globalPrivacyControl");
      value.has_value())
    return value.value();
  bool gpcStatus = StaticPrefs::privacy_globalprivacycontrol_enabled();
  ...

The main-thread Navigator::GlobalPrivacyControl has no matching read, so it stays on the pref while the worker follows the config. Nothing reads navigator.doNotTrack at all, and neither property's header half is driven by anything.

Both are generated by BrowserForge, mapped in pythonlib/camoufox/browserforge.yml (lines 8 and 28), declared in settings/properties.json, and placed in CAMOU_CONFIG — the config surface exists, the application does not. Same shape as #696 (maxTouchPoints) and #721 (canvas:seed).

Note on fixing it

The obvious fix — a MaskConfig early-return in Navigator::GlobalPrivacyControl to match the worker — would close the main/worker gap and open a JS/header one, since Sec-GPC would still be absent while the property read true. That is a sharper tell than the current one, because the header is trivially observable server-side.

In Firefox one pref drives all three: privacy.globalprivacycontrol.enabled sets the main-thread property, the worker fallback, and the Sec-GPC header, and privacy.donottrackheader.enabled does the same for navigator.doNotTrack and DNT. Driving the prefs from the config value keeps every route in agreement, needs no browser patch, and is what the PR does.

Verified on the same build:

property main thread worker header
navigator.globalPrivacyControl True True Sec-GPC: 1
navigator.doNotTrack 1 n/a DNT: 1

To Reproduce

bash
pip install camoufox && camoufox fetch
python gpc_worker_probe.py

Version

152.0.4-beta.30, pythonlib 0.5.x.

gpc_worker_probe.py
python
import asyncio, json
from camoufox.utils import launch_options
from playwright.async_api import async_playwright

WORKER_SRC = """
self.onmessage = function() {
  postMessage(JSON.stringify({
    gpc: navigator.globalPrivacyControl === undefined ? 'undefined' : navigator.globalPrivacyControl,
    dnt: navigator.doNotTrack === undefined ? '(not on WorkerNavigator)' : navigator.doNotTrack,
    platform: navigator.platform
  }));
};
"""

PROBE = """async (src) => {
  const main = {
    gpc: navigator.globalPrivacyControl === undefined ? 'undefined' : navigator.globalPrivacyControl,
    dnt: navigator.doNotTrack,
    platform: navigator.platform
  };
  const url = URL.createObjectURL(new Blob([src], {type: 'application/javascript'}));
  const w = new Worker(url);
  const worker = await new Promise((res, rej) => {
    w.onmessage = e => res(JSON.parse(e.data));
    w.onerror = e => rej(new Error('worker error: ' + (e.message || 'unknown')));
    setTimeout(() => rej(new Error('timeout')), 10000);
    w.postMessage(0);
  });
  return {main, worker};
}"""


async def run(label, extra_prefs):
    opts = launch_options(os="macos", headless=True, i_know_what_im_doing=True)
    prefs = dict(opts.get("firefox_user_prefs") or {}); prefs.update(extra_prefs)
    env = opts["env"]
    ch = sorted((k for k in env if k.startswith("CAMOU_CONFIG_")), key=lambda k: int(k.rsplit("_",1)[1]))
    cfg = json.loads("".join(env[k] for k in ch))
    hdr = {}
    async with async_playwright() as p:
        b = await p.firefox.launch(executable_path=opts.get("executable_path"), headless=True,
                                   args=opts.get("args") or [], env=env, firefox_user_prefs=prefs)
        pg = await b.new_page()
        async def on_req(route):
            if route.request.resource_type == "document":
                hdr.update(await route.request.all_headers())
            await route.continue_()
        await pg.route("**/*", on_req)
        await pg.goto("https://example.com", wait_until="domcontentloaded")
        r = await pg.evaluate(PROBE, WORKER_SRC)
        await b.close()

    m, w = r["main"], r["worker"]
    print(f"\n{label}")
    print(f"  config: globalPrivacyControl={cfg.get('navigator.globalPrivacyControl')!r}  doNotTrack={cfg.get('navigator.doNotTrack')!r}")
    print(f"  {'':22} {'main thread':<14} {'worker':<14} {'Sec-GPC header'}")
    print(f"  {'globalPrivacyControl':22} {str(m['gpc']):<14} {str(w['gpc']):<14} {hdr.get('sec-gpc','(absent)')}")
    print(f"  {'doNotTrack':22} {str(m['dnt']):<14} {str(w.get('dnt','n/a')):<14} {hdr.get('dnt','(absent)')}")
    print(f"  {'platform (control)':22} {m['platform']:<14} {w['platform']:<14}")
    if m["gpc"] != w["gpc"]:
        print("  >>> main thread and worker DISAGREE on navigator.globalPrivacyControl")


async def main():
    await run("CURRENT BEHAVIOUR", {})
    await run("WITH THE PREFS SET", {
        "privacy.donottrackheader.enabled": True,
        "privacy.globalprivacycontrol.enabled": True,
        "privacy.globalprivacycontrol.functionality.enabled": True,
    })

asyncio.run(main())