navigator.globalPrivacyControl differs between the main thread and a worker; navigator.doNotTrack is inert
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:
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
pip install camoufox && camoufox fetch
python gpc_worker_probe.pyVersion
152.0.4-beta.30, pythonlib 0.5.x.
gpc_worker_probe.pyimport 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())Source: daijro/camoufox