#773·camoufox

geoip resolves a timezone and ships it to the browser, but the launch-level API never applies it

Author: echoriver89Created Sep 13, 2026Updated Sep 16, 2026

Describe the bug

Camoufox(geoip=...) populates the geolocation, locale and WebRTC fields from the target IP, and the timezone it derives from the same lookup is written into the config that reaches the browser process — but the launched browser keeps the host timezone. Intl.DateTimeFormat().resolvedOptions().timeZone and new Date().getTimezoneOffset() both report the machine's real zone.

The documented snippet in https://camoufox.com/python/geoip/

By passing geoip=True, or passing in a target IP address, Camoufox will automatically use the target IP's longitude, latitude, timezone, country, locale, & spoof the WebRTC IP address.

— uses the launch-level API (with Camoufox(geoip=True, proxy={...}) as browser), which is exactly the path where the timezone is not applied.

Version

Pip package:    v0.5.6
Camoufox:       v152.0.4-beta.30 (sha256 ea52a02fb1cf, official/stable, up to date)
Playwright:     1.62.0
Python:         3.13.15
OS:             Windows 11 Home build 26200, x86_64
GeoIP database: MaxMind GeoLite2 (installed, 43.4 MB, updated 2026-09-13)
Host timezone:  Asia/Shanghai (UTC+8)

To Reproduce

Scripts (every one is pasted in full at the bottom of this issue):

file purpose
repro.py the repro; prints the eight cases below and exits 1 when the leak is present. Case 8 only runs when PROXY_URL is set
locale_vs_tz.py the locale-vs-timezone asymmetry pair quoted further down
persistent_context.py the same launch path through persistent_context=True, where Playwright does accept timezone_id
proxy_geoip_true.py the documented Camoufox(geoip=True, proxy=...) form against a real proxy; needs PROXY_URL
proxy_paths.py what each resolver answers for one real exit, and proof that the context-level path applies its answer; needs PROXY_URL

Cases 1-7 of repro.py need no proxy and no account: geoip=<ip> resolves against the local mmdb, and the expected timezone is read back from that database, so the check is self-validating on any machine.

python
from camoufox.geolocation import get_geolocation
from camoufox.sync_api import Camoufox

GEOIP_IP = "8.8.8.8"  # the local mmdb resolves this to America/Chicago
expected = get_geolocation(GEOIP_IP).timezone


def read(goto_url=None, page_kwargs=None, **launch):
    with Camoufox(headless=True, i_know_what_im_doing=True, **launch) as browser:
        page = browser.new_page(**(page_kwargs or {}))
        if goto_url:
            page.goto(goto_url, timeout=60_000)
        return (
            page.evaluate("Intl.DateTimeFormat().resolvedOptions().timeZone"),
            page.evaluate("new Date().getTimezoneOffset()"),
        )


print("expected from mmdb:", expected)
print("no geoip                    :", read())
print("geoip=<ip>                  :", read(geoip=GEOIP_IP))
print("config={'timezone': ...}    :", read(config={"timezone": expected}))
print("geoip + timezone_id on page :", read(geoip=GEOIP_IP, page_kwargs={"timezone_id": expected}))

repro.py adds the CAMOU_CONFIG_* decode (cases 2-3 below), the post-navigation case, and an optional case 8 that runs the documented proxy form; the four lines above are its cases 1, 4, 5 and 6. It exits 1 when the leak is present:

bash
python repro.py                                    # cases 1-7
PROXY_URL=http://your-proxy:8080 python repro.py   # ...and case 8 (PowerShell: $env:PROXY_URL=...)

Actual output (verbatim)

mmdb lookup for 8.8.8.8: timezone='America/Chicago' locale=Locale(language='en', region='US', script='Latn')

1. baseline, no geoip                  {"intl_tz": "Asia/Shanghai", "offset": -480, "lang": "en-US"}
2. payload sent to the browser, geoip   {"timezone": "America/Chicago", "locale:region": "US", "locale:language": "es"}
3. payload sent, explicit config        {"timezone": "America/Chicago"}
4. Camoufox(geoip='8.8.8.8')            {"intl_tz": "Asia/Shanghai", "offset": -480, "lang": "en-US"}
5. Camoufox(config={'timezone': ...})    {"intl_tz": "Asia/Shanghai", "offset": -480, "lang": "en-US"}
6. case 4 + timezone_id on new_page     {"intl_tz": "America/Chicago", "offset": 300, "lang": "en-US"}
7. case 4, after a real navigation      {"intl_tz": "Asia/Shanghai", "offset": -480, "lang": "en-US"}
8. Camoufox(geoip=True, proxy=...)        {"intl_tz": "Asia/Shanghai", "offset": -480, "lang": "ja-JP"}   # exit <exit-ip> -> Asia/Tokyo

REPRODUCED: the browser keeps the host timezone in geoip (case 4), explicit config (case 5), after navigation (case 7), geoip=True with a proxy (case 8)
  expected: 'America/Chicago'
  actual:   'Asia/Shanghai' (host), offset -480 instead of 300
  cases 2-3 show the correct value is populated and reaches the browser process;
  case 6 shows the same value works as soon as it is passed to Playwright's
  context-level timezone_id.

Case 8 ran against an http proxy whose exit IP resolves to Asia/Tokyo in the same GeoLite2 file (<exit-ip> redacted here); an earlier run through a different exit (Asia/Taipei) behaved the same way.

Exit code is 1, and every repetition agreed on all timezone fields. The lang column is not a usable signal, and this is by design rather than a symptom: get_geolocation() samples the locale for the IP's country (locales.py:265-272, np.random.choice over the territory's languages weighted by probability), so the same IP yields en-US or es-US on different launches — six consecutive decodes of case 2's payload for 8.8.8.8 gave en four times and es twice, while timezone was America/Chicago all six times. The stochastic half of the geoip lookup reaches the page; the deterministic half does not. The repro therefore asserts on the timezone only.

Expected output

Cases 4, 5 and 7 should report America/Chicago / offset 300 — the zone the local database resolved for the target IP — and case 8 should report the zone of the proxy's exit IP. Case 6 shows what that output would look like, since it is the same value handed to Playwright one level down.

Narrowing down the cause

The value is not lost in Python. Decoding the CAMOU_CONFIG_* environment chunks that utils.launch_options() passes to the browser process shows it is present and correct:

python
import json
from camoufox.utils import launch_options

env = launch_options(headless=True, i_know_what_im_doing=True,
                     geoip="8.8.8.8")["env"]
chunks = sorted((k for k in env if k.startswith("CAMOU_CONFIG")),
                key=lambda k: int(k.split("_")[-1]))  # the payload is chunked on Windows
config = json.loads("".join(env[k] for k in chunks))
print({k: config[k] for k in ("timezone", "geolocation:latitude",
                              "locale:language", "locale:region") if k in config})
# {'timezone': 'America/Chicago', 'geolocation:latitude': 37.751, 'locale:language': 'en', 'locale:region': 'US'}
# (timezone is deterministic here; locale:language is sampled per launch — see below)

So utils.py:910-934 (the geoip block) works as intended: it resolves the target IP (geoip=True first discovers the exit IP through the proxy with public_ip(), utils.py:912-917), calls get_geolocation() (line 928), and merges the result with config.setdefault('timezone', ...) at line 932 — the behaviour added in #563 and cited when #589 was closed. The value is in the payload.

The gap is that on the launch path nothing turns config['timezone'] into an actual override. timezone_id appears at exactly three places in the package:

location reaches the launch-level API?
fingerprints.py:1142, inside generate_context_fingerprint() no — only called by NewContext / AsyncNewContext
sync_api.py:183-188 / async_api.py:187-192, inside NewContext / AsyncNewContext no — context-level only, and it uses a live ip-api.com query, not the mmdb

Camoufox(...) goes through NewBrowserplaywright.firefox.launch(**from_options), and the dict built at utils.py:1041-1052 is executable_path, args, env, firefox_user_prefs, headless, proxy — plus every kwarg the caller passed that launch_options() doesn't know about (see below). firefox.launch() accepts no timezone_id, so there is nowhere on this path to put the resolved zone, and the browser process evidently does not honour the timezone config key either — case 5 proves that setting it explicitly, bypassing geoip entirely, behaves the same (and case 3 proves the value really is in the payload for that path too).

Case 6 is the control: the identical string, handed to Playwright's context-level timezone_id, works immediately.

There is also no user-side way to inject it at launch. Unknown kwargs on Camoufox() are collected by **launch_options (utils.py:610) and spread into that same dict (utils.py:1047), so they go straight to firefox.launch(), which takes no timezone_id: Camoufox(timezone_id='America/Chicago') ends in TypeError: BrowserType.launch() got an unexpected keyword argument 'timezone_id'.

That the gap is emission rather than capability shows up on the persistent-context branch: Camoufox(persistent_context=True, user_data_dir=..., timezone_id='America/Chicago') — the kwarg reaching launch_persistent_context() through the same passthrough — does move the clock, while geoip='8.8.8.8' on that branch does not:

expected from mmdb: America/Chicago | host zone is what leaks
plain                  Asia/Shanghai offset=-480
timezone_id            America/Chicago offset=300
geoip+timezone_id      America/Chicago offset=300
geoip_only             Asia/Shanghai offset=-480

So Playwright has been ready to take the value on that path all along; launch_options() just never puts it there.

Worth noting for whoever picks this up: the package has two GeoIP resolvers and only the second one can move the clock. The launch path discovers the exit IP with camoufox.ip.public_ip() (echo services, queried through the proxy — utils.py:912-917) and looks it up in the local mmdb (utils.py:928). NewContext / AsyncNewContext never see that config: when proxy is passed at context level they ask ip-api.com through the proxy instead (sync_api.py:140-150), and hand the answer to Playwright as timezone_id (sync_api.py:183-188, async_api.py:187-192). proxy_paths.py prints the mmdb answer for a real exit IP and the zone the context path applied (here: Asia/Taipei, on two runs); the launch path's value never reaches the page (case 8).

The other fields from the same lookup do reach the page

locale_vs_tz.py runs the pair on a navigated page with an IP whose zone and locale both differ unambiguously from this host and from camoufox's default:

mmdb: Asia/Tokyo Locale(language='ja', region='JP', script='Jpan')
no geoip          -> tz=Asia/Shanghai lang=en-US langs=["en-US","en"] offset=-480
geoip=133.11.11.1 -> tz=Asia/Shanghai lang=ja-JP langs=["ja-JP","ja"] offset=-480

One CAMOU_CONFIG payload, produced by one get_geolocation() call: the locale lands (en-USja-JP; ja is near-certain for a JP region, though the language is sampled as described above), timezone does not — it stays Asia/Shanghai where Asia/Tokyo / offset -540 was expected.

What this is not

  • Not a MaxMind accuracy problem — case 2 shows the resolved zone is what the local database returns, and case 6 shows that exact value is applied fine as soon as it reaches Playwright's timezone_id.
  • Not the #589 case, though it may explain what #589 saw. There, geoip=False with an explicit config['timezone'] showed a different zone on browserscan. The close comment verified that "an explicit config={'timezone': 'America/Phoenix'} is respected instead of being overwritten" — on this build the merge is respected (case 3 proves the value reaches the payload untouched) and the page still reports the host zone (case 5), so "respected" holds only at config level, not at browser level. What was verified as fixed was the setdefault ordering from #563; the zone that ends up in the page is still the host's, with or without geoip.
  • Not the worker-thread leak from #541 / #545 / #657 / #669 — this is the main thread.
  • Not the "preset has no timezone field" gap from #559 / #563 — that was about generate_context_fingerprint(), which is the path that does apply it.

Workaround in the meantime

Both variants were run here; the first is the one to rely on:

python
from camoufox.geolocation import get_geolocation
from camoufox.sync_api import Camoufox, NewContext

TZ = "Intl.DateTimeFormat().resolvedOptions().timeZone"

# 1. resolve it yourself and hand it to the context
with Camoufox(headless=True, geoip="8.8.8.8", i_know_what_im_doing=True) as browser:
    page = browser.new_page(timezone_id=get_geolocation("8.8.8.8").timezone)
    print(page.evaluate(TZ))            # America/Chicago

# 2. with a proxy: the context-level path resolves the exit IP for you
with Camoufox(headless=True, i_know_what_im_doing=True) as browser:
    page = NewContext(browser, proxy={"server": "http://your-proxy:8080"}).new_page()
    print(page.evaluate(TZ))            # the zone of the proxy's exit IP

Variant 1 printed America/Chicago, as expected. Variant 2 shifted the zone to the proxy's exit zone against a working proxy, but it depends on a live ip-api.com query through that proxy and _resolve_proxy_geo swallows failures — pointed at an unreachable proxy it quietly printed the host zone instead. Check the value rather than assuming NewContext took effect, and prefer variant 1 when you already know the zone.

Variant 3, if the code runs on a persistent profile, applies at launch time because that branch does accept the option:

python
import tempfile
from pathlib import Path

profile = Path(tempfile.mkdtemp(prefix="cf-"))

with Camoufox(headless=True, persistent_context=True, user_data_dir=str(profile),
              geoip="8.8.8.8", timezone_id=get_geolocation("8.8.8.8").timezone,
              i_know_what_im_doing=True) as context:
    print(context.new_page().evaluate(TZ))      # America/Chicago

Suggested fix

Whatever carries locale:* out of that same payload into the page has no counterpart for timezone, so the options, widest coverage first:

  • honour config['timezone'] the way the locale:* keys from the same payload are honoured. This is the only one that covers Camoufox(...)browser.new_page(), i.e. the shape in the docs, since firefox.launch() has no timezone_id to forward to;
  • have launch_options() emit timezone_id on the persistent-context branch, where launch_persistent_context() already accepts it — persistent_context.py shows the option moving the clock as soon as a caller passes it by hand (sync_api.py:116-121). Cheap, but it leaves the plain-browser path alone;
  • have NewBrowser keep the resolved zone and apply it to contexts created from the returned browser, so the launch-level geoip means what the GeoIP page says it means.

Either way, until then a line in https://camoufox.com/python/geoip/ saying that the launch-level API leaves the timezone alone (it does shift geolocation, locale and WebRTC) would save people from shipping this leak.

Unrelated: persistent_context=True needs a profile directory you have to pass yourself

While writing the repro I hit Camoufox(persistent_context=True) raising TypeError: BrowserType.launch_persistent_context() missing 1 required positional argument: 'user_data_dir' (sync_api.py:116-121), because launch_options() never emits one. Passing user_data_dir=... yourself does work — it reaches Playwright through the same unknown-kwarg passthrough (which is how #721's repro runs) — so this is a missing parameter / undocumented requirement rather than a dead end. Separate from the timezone problem, but persistent_context.py above depends on knowing it.


The scripts, inline

repro.py
python
"""Minimal reproduction: launch-level `geoip` does not apply the timezone it resolves.

Camoufox pythonlib 0.5.6 / browser v152.0.4-beta.30, Windows 11 x86_64.

Run:  python repro.py        (exit 0 = not reproduced, exit 1 = leak reproduced)

No proxy and no account needed: `geoip=<ip>` resolves against the local MaxMind mmdb,
so the expected timezone is read from the database installed on this machine.
Optional: set PROXY_URL to also run case 8, the documented `Camoufox(geoip=True, proxy=...)`
form, where Camoufox discovers the exit IP itself.
"""

import json
import os
import sys

from camoufox.geolocation import get_geolocation
from camoufox.ip import Proxy, public_ip
from camoufox.sync_api import Camoufox
from camoufox.utils import launch_options

# An IP whose mmdb timezone differs from the host's, so both the name and the
# UTC offset shift when it is applied.
GEOIP_IP = "8.8.8.8"

SENSORS = {
    "intl_tz": "Intl.DateTimeFormat().resolvedOptions().timeZone",
    "offset": "new Date().getTimezoneOffset()",
    "lang": "navigator.language",
}

INTERESTING = ("timezone", "locale:language", "locale:region")


def browser_sensors(goto_url=None, page_kwargs=None, **launch):
    """What page script sees from a launch-level Camoufox() browser."""
    with Camoufox(headless=True, i_know_what_im_doing=True, **launch) as browser:
        page = browser.new_page(**(page_kwargs or {}))
        if goto_url:
            page.goto(goto_url, timeout=60_000)
        return {name: page.evaluate(expr) for name, expr in SENSORS.items()}


def config_sent_to_browser(**launch):
    """Decode the CAMOU_CONFIG_* env chunks handed to the browser process."""
    env = launch_options(headless=True, i_know_what_im_doing=True, **launch).get("env", {})
    order = sorted(
        (k for k in env if k.startswith("CAMOU_CONFIG")),
        key=lambda k: int(k.split("_")[-1]),
    )
    cfg = json.loads("".join(env[k] for k in order))
    return {k: v for k, v in cfg.items() if k in INTERESTING}


def main() -> int:
    expected = get_geolocation(GEOIP_IP)
    print(f"mmdb lookup for {GEOIP_IP}: timezone={expected.timezone!r} locale={expected.locale}")
    print()

    host = browser_sensors()
    print(f"1. baseline, no geoip                  {json.dumps(host)}")
    if host["intl_tz"] == expected.timezone:
        print("   SKIP: the host timezone already equals the mmdb timezone; pick another GEOIP_IP.")
        return 0

    geo_cfg = config_sent_to_browser(geoip=GEOIP_IP)
    print(f"2. payload sent to the browser, geoip   {json.dumps(geo_cfg, ensure_ascii=False)}")
    manual_cfg = config_sent_to_browser(config={"timezone": expected.timezone})
    print(f"3. payload sent, explicit config        {json.dumps(manual_cfg, ensure_ascii=False)}")
    assert geo_cfg.get("timezone") == expected.timezone, "geoip did not populate config['timezone']"
    assert manual_cfg.get("timezone") == expected.timezone, "config did not carry the timezone"

    got = browser_sensors(geoip=GEOIP_IP)
    print(f"4. Camoufox(geoip={GEOIP_IP!r})            {json.dumps(got)}")

    manual = browser_sensors(config={"timezone": expected.timezone})
    print(f"5. Camoufox(config={{'timezone': ...}})    {json.dumps(manual)}")

    fixed = browser_sensors(geoip=GEOIP_IP, page_kwargs={"timezone_id": expected.timezone})
    print(f"6. case 4 + timezone_id on new_page     {json.dumps(fixed)}")

    try:
        after_nav = browser_sensors(geoip=GEOIP_IP, goto_url="https: