Technical documentation for the GeoSpoof browser extension (Firefox + Chromium + Safari).
- API Override Summary
- Injected Script Architecture
- Message Types and Payloads
- Settings Schema
- Data Models
- External API Integration
- Known Limitations
- Performance Targets
- Browser Compatibility
All overrides are injected synchronously at document_start before any page JavaScript runs.
Chromium note: the tables below describe the page-world content-script injection path, which is the default on every engine. Chromium also ships an optional Engine-level Spoofing mode that instead drives the timezone override through the Chrome DevTools Protocol (
chrome.debugger) — see Engine-level Spoofing (Chromium). It is off by default; geolocation always stays on the injected path.
Overrides are installed on Geolocation.prototype (not on the per-window navigator.geolocation instance), preserving the WebIDL-specified native descriptor shape (writable: true, configurable: true, enumerable: true). This matches native layout: Object.getOwnPropertyDescriptor(navigator.geolocation, "getCurrentPosition") correctly returns undefined because the method is inherited from the prototype.
Spoofed GeolocationPosition and GeolocationCoordinates instances are allocated via Object.create(...) with values stored in WeakMaps, so they have zero own properties — Object.getOwnPropertyNames(pos) returns [], matching native.
| API | Override Behavior |
|---|---|
Geolocation.prototype.getCurrentPosition |
Validates this and arguments per WebIDL; invalid calls are delegated to the unbound native method so the browser throws its own TypeError (correct message + native stack, extension frames scrubbed — see Argument validation & error fidelity). Otherwise returns spoofed coords with 10-50ms realistic delay; serves cached result sub-millisecond when maximumAge is honored |
Geolocation.prototype.watchPosition |
Same argument/this validation and native error delegation as getCurrentPosition. Otherwise initial callback at 10-50ms; re-fires every 1-2s matching native stationary-device cadence |
Geolocation.prototype.clearWatch |
Brand-checks this (foreign this delegated to native for a faithful TypeError); clears spoofed watch |
GeolocationCoordinates.prototype.{latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed} |
Accessor getters that read from WeakMap slots for spoofed instances and fall through to native for pristine instances |
GeolocationPosition.prototype.{coords, timestamp} |
Same accessor pattern as above |
GeolocationPosition.prototype.toJSON |
Returns populated shape from WeakMap slots for spoofed instances, keys in the engine's native serialization order (see Geolocation toJSON key order); falls through to native for pristine instances |
GeolocationCoordinates.prototype.toJSON |
Same pattern; returns the seven coordinate fields in the engine's native key order |
getCurrentPosition, watchPosition, and clearWatch reproduce native WebIDL semantics for invalid input instead of silently accepting it:
thisand argument validation. The override mirrors the WebIDL checks —thismust implementGeolocation,successCallbackmust be callable,errorCallback(if present) must be callable, andoptions(if present) must be an object. A call that fails any check (e.g.getCurrentPosition(),getCurrentPosition({}),getCurrentPosition(fn, fn, "b"), or a detachedgetCurrentPosition.call({}, fn)) is delegated to the unbound native method so the browser throws its ownTypeError— correct type, exact per-engine message (e.g. Chrome'sIllegal invocation), and a native-shaped stack. Validation happens before any permission prompt or location acquisition, so no prompt fires and no real position is requested.- No extension-id leak. Because the injected script runs in
world: "MAIN", a thrown error's stack would otherwise carry achrome-extension://<id>/…frame, exposing the extension. The delegating helper strips this script's own frames from the error's stack before rethrowing, leaving a native-equivalent stack (message + page frames only). Detection is restricted to extension-scheme URLs so page frames are never removed. - Leak-safe delegation. Any valid callback is swapped for a no-op before delegating, so even in the spec-impossible case the native call doesn't throw, the page's real callbacks can never receive a real position. Original argument count is preserved so the native message ("1 argument required…" vs a type error) matches exactly.
The [Default] object toJSON() serializer emits keys in the interface's WebIDL attribute-declaration order, which is engine-specific and is not the prototype's own-property order. Spoofed instances are Object.create(...) fakes, so native toJSON can't be called on them (it brand-checks and throws), and the order can't be sampled without a real position — so the extension encodes the two known native orders and selects by build target (__CHROMIUM__ / __FIREFOX__ / __SAFARI__). In preserve-prompt mode, where a genuine native position is briefly available, the order is sampled at runtime and takes precedence over the hardcoded default (self-correcting if an engine ever changes).
| Engine family | GeolocationCoordinates order |
GeolocationPosition order |
|---|---|---|
| Blink (Chromium/Edge/etc.) | accuracy, latitude, longitude, altitude, altitudeAccuracy, heading, speed |
timestamp, coords |
| Gecko (Firefox) | latitude, longitude, altitude, accuracy, altitudeAccuracy, heading, speed |
coords, timestamp |
| WebKit (Safari) | latitude, longitude, altitude, accuracy, altitudeAccuracy, heading, speed |
coords, timestamp |
Sources:
- Serializer definition (spec): https://www.w3.org/TR/geolocation/ — added in https://github.com/w3c/geolocation/commit/09b48e6
- MDN (documents the observed Blink output): https://developer.mozilla.org/en-US/docs/Web/API/GeolocationCoordinates/toJSON, https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition/toJSON
- Gecko IDL: https://github.com/mozilla/gecko-dev/blob/master/dom/webidl/GeolocationCoordinates.webidl, https://github.com/mozilla/gecko-dev/blob/master/dom/webidl/GeolocationPosition.webidl
- WebKit IDL: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/Modules/geolocation/GeolocationCoordinates.idl, https://github.com/WebKit/WebKit/blob/main/Source/WebCore/Modules/geolocation/GeolocationPosition.idl
- Blink intent-to-ship: https://groups.google.com/a/chromium.org/g/blink-dev/c/JQkvFd0oXUI
Installed on Permissions.prototype preserving native descriptor flags.
| API | Override Behavior |
|---|---|
Permissions.prototype.query({ name: "geolocation" }) |
Returns a prototype-linked PermissionStatus with state: "granted" when spoofing is enabled |
Permissions.prototype.query({ name: other }) |
Passthrough to native |
| API | Override Behavior |
|---|---|
Date (global) |
Replaced entirely — spoofing-aware constructor |
Date() constructor (no-arg) |
Passthrough (current time) |
Date(string) — ambiguous |
Epoch adjusted by offset difference |
Date(string) — explicit tz |
Passthrough |
Date(y, m, d, ...) — multi-arg |
Epoch adjusted by offset difference |
Date.parse(string) |
Same adjustment as constructor |
Date.now |
Behavior passthrough; registered for toString masking |
Date.UTC |
Behavior passthrough; registered for toString masking |
Date.prototype.constructor |
Re-pointed to the replacement constructor |
| API | Override Behavior |
|---|---|
Date.prototype.getTimezoneOffset |
Negated Intl-based offset for spoofed tz |
Date.prototype.toString |
Formatted with spoofed tz name and offset |
Date.prototype.toDateString |
Formatted in spoofed timezone |
Date.prototype.toTimeString |
Formatted with spoofed offset and tz name |
Date.prototype.toLocaleString |
Timezone injected into options |
Date.prototype.toLocaleDateString |
Timezone injected into options |
Date.prototype.toLocaleTimeString |
Timezone injected into options |
Date.prototype.getHours |
Computed via engine-specific path in spoofed tz |
Date.prototype.getMinutes |
Computed via engine-specific path in spoofed tz |
Date.prototype.getSeconds |
Computed via engine-specific path in spoofed tz |
Date.prototype.getMilliseconds |
Behavior passthrough (timezone-independent) |
Date.prototype.getDate |
Computed via engine-specific path in spoofed tz |
Date.prototype.getDay |
Computed via engine-specific path in spoofed tz |
Date.prototype.getMonth |
Computed via engine-specific path in spoofed tz |
Date.prototype.getFullYear |
Computed via engine-specific path in spoofed tz |
Date.prototype.setHours(h,m?,s?,ms?) |
Components interpreted in spoofed tz; omitted args preserve current spoofed-zone component; DST-refined |
Date.prototype.setMinutes(m,s?,ms?) |
Components interpreted in spoofed tz; omitted args preserve current spoofed-zone component; DST-refined |
Date.prototype.setSeconds(s,ms?) |
Components interpreted in spoofed tz; omitted args preserve current spoofed-zone component; DST-refined |
Date.prototype.setDate(d) |
Day-of-month interpreted in spoofed tz; time-of-day preserved in spoofed tz; DST-refined |
Date.prototype.setMonth(m,d?) |
Month (and optional day) interpreted in spoofed tz; time-of-day preserved; DST-refined |
Date.prototype.setFullYear(y,m?,d?) |
Year (and optional month/day) interpreted in spoofed tz; time-of-day preserved; DST-refined; small-year 0-99 literal (not 1900+y) per spec |
Date.prototype.setMilliseconds |
Not overridden — timezone-independent, round-trips natively |
Date.prototype.setTime |
Not overridden — absolute UTC epoch write with no timezone interpretation |
Date.prototype.setUTC* (all seven) |
Not overridden — UTC surfaces, true UTC epoch preserved |
Date.prototype.toISOString |
Not overridden — UTC surface, true UTC epoch preserved |
Date.prototype.toJSON |
Not overridden — delegates to toISOString, true UTC epoch preserved |
Date.prototype.getUTC* (all eight) |
Not overridden — UTC surfaces, true UTC values preserved |
| API | Override Behavior |
|---|---|
Intl.DateTimeFormat constructor |
Injects spoofed timezone when no explicit tz |
Intl.DateTimeFormat.prototype.resolvedOptions |
Returns spoofed tz for non-explicit instances |
Intl.DateTimeFormat.prototype.formatToParts |
Inherits spoofed timezone from the constructor; no separate override needed |
Intl.DateTimeFormat.prototype.formatRange |
Inherits spoofed timezone from the constructor (feature-gated) |
Intl.DateTimeFormat.prototype.formatRangeToParts |
Inherits spoofed timezone from the constructor (feature-gated) |
Date and Intl.DateTimeFormat are the only two constructors the extension replaces, and both honor new.target: construction routes through Reflect.construct(Original, args, new.target). So class X extends Date {} / class X extends Intl.DateTimeFormat {}, and Reflect.construct(Date, args, F), yield an instance carrying the caller's prototype — new X() instanceof X is true and Object.getPrototypeOf(new X()) === X.prototype, exactly like native — while the spoofing (epoch adjustment for Date, spoofed-zone injection for Intl.DateTimeFormat) still applies through the subclass. A plain-function override that returned a fresh instance would discard new.target, making new X() instanceof X false — a deterministic tell, and a break of legitimate subclassing. Installed identically across the top-level, same-origin iframe, and worker realms.
| API | Override Behavior |
|---|---|
Temporal.Now.timeZoneId |
Returns spoofed identifier |
Temporal.Now.plainDateTimeISO |
Passes spoofed tz when no arg |
Temporal.Now.plainDateISO |
Passes spoofed tz when no arg |
Temporal.Now.plainTimeISO |
Passes spoofed tz when no arg |
Temporal.Now.zonedDateTimeISO |
Passes spoofed tz when no arg |
Temporal.ZonedDateTime.prototype.offsetNanoseconds |
Passthrough getter; kept for toString masking |
Temporal.ZonedDateTime.prototype.offset |
Passthrough getter; kept for toString masking |
Web / Shared / Service Workers get a fresh global that never passes through the main-world content-script injection, so Date / Intl.DateTimeFormat / Temporal.Now inside a worker would otherwise report the real timezone. The extension closes this by prepending a self-contained ES5 spoofing payload (SPOOF_CORE, src/shared/worker-payload.ts) to worker scripts — via the content-script Worker / SharedWorker constructor wrapper for blob / data / nested workers on every engine, and via the Firefox webRequest.filterResponseData listener for URL-based / module / service workers (see Known Limitations for the engine matrix; geolocation isn't exposed to workers, so this is a timezone-only concern).
The payload reaches full parity with the main realm — every timezone-affecting surface is overridden with identical behavior:
| Surface (in worker scope) | Override behavior |
|---|---|
Date constructor + Date.parse |
new.target-aware (subclassing / Reflect.construct preserve the subclass prototype); ambiguous-string and multi-arg construction epoch-adjusted to the spoofed zone; no-new Date() returns a spoofed-zone string (CreepJS valid.date consistency) |
Date.prototype.getTimezoneOffset |
Intl-derived spoofed offset (engine truncation matched) |
Date.prototype local getters (getHours…getFullYear, getDay) |
Read in the spoofed zone |
Date.prototype.getMilliseconds |
Passthrough (timezone-independent); registered for toString masking |
Date.prototype local setters (setHours…setFullYear) |
Components interpreted in the spoofed zone, DST-refined; round-trip against the spoofed getters |
Date.prototype.{toString,toDateString,toTimeString,toLocale*} |
Formatted in the spoofed zone |
Intl.DateTimeFormat constructor + resolvedOptions |
new.target-aware; injects the spoofed zone when none is explicit |
Temporal.Now.{timeZoneId,plainDateTimeISO,plainDateISO,plainTimeISO,zonedDateTimeISO} |
Spoofed identifier / spoofed zone when no explicit arg (feature-detected) |
Function.prototype.toString |
Masks every override as [native code]; derives the engine-specific surround format at runtime |
Native-method shape fidelity. Every non-constructor override above is installed through a concise-method wrapper (__nativeMethod, the worker-payload twin of the main realm's stripConstruct), so it has no own prototype and no [[Construct]] slot — matching native methods, where new Date.prototype.getHours() and Reflect.construct(Date.prototype.getHours, []) throw TypeError — and carries the correct native name / length / [native code] toString. The two real constructors (Date, Intl.DateTimeFormat) keep their own prototype and [[Construct]]. Without this, a naive function(){} override would carry a prototype and be constructable, a worker-side tell distinct from the timezone value.
Not applicable in worker scope (correctly absent): geolocation, permissions, document.lastModified, WebRTC, XSLT, iframe / DOM cascade — those APIs don't exist there.
| API | Override Behavior |
|---|---|
Document.prototype.lastModified (getter) |
Reformats the native "MM/DD/YYYY HH:MM:SS" string from the real system timezone into the spoofed timezone. Covers document.lastModified, DOMParser-constructed documents, and iframe.contentDocument.lastModified via prototype-level patching. |
| API | Override Behavior |
|---|---|
Function.prototype.toString |
Returns [native code] for registered overrides |
Two accessor properties on HTMLIFrameElement.prototype are wrapped at the top level. When a same-origin iframe window is first accessed, patchIframeWindow runs synchronously and installs overrides on the iframe's realm:
Function.prototype.toString— native-[native code]-reporting mask- Geolocation —
getCurrentPosition/watchPosition/clearWatchon the iframe'sGeolocation.prototype - Permissions —
queryon the iframe'sPermissions.prototype, returning a prototype-linkedPermissionStatuswithstate: "granted"for geolocation - Intl —
Intl.DateTimeFormatconstructor +resolvedOptionson the iframe's realm - Date — full spoofing-aware
Dateconstructor +Date.parseon the iframe's realm - Temporal —
Temporal.Now.{timeZoneId,plainDateTimeISO,plainDateISO,plainTimeISO,zonedDateTimeISO}when available - Document —
Document.prototype.lastModifiedgetter override on the iframe's realm - Nested-iframe cascade — iframe-realm copies of
HTMLIFrameElement.prototype.{contentWindow,contentDocument}getters,Node.prototype.{appendChild,insertBefore,replaceChild},Element.prototype.{append,prepend,replaceWith,insertAdjacentElement,insertAdjacentHTML}, theinnerHTMLsetter, plus aMutationObserveron the iframe's owndocumentElement. This causes nested iframes to triggerpatchIframeWindowrecursively to any depth.
Cross-origin iframes throw SecurityError on any access and are silently skipped. All shape/descriptor checks in the cascade use tag-name / duck-typing rather than instanceof, because elements created in the iframe's realm are not instances of the top-level constructors.
| API | Override Behavior |
|---|---|
HTMLIFrameElement.prototype.contentWindow (getter) |
Patches iframe on access |
HTMLIFrameElement.prototype.contentDocument (getter) |
Triggers contentWindow patching first |
Wrapped to synchronously scan inserted subtrees for iframes and patch them before the next line of JavaScript executes. A MutationObserver on document.documentElement catches anything missed by the wrappers.
| API | Override Behavior |
|---|---|
Node.prototype.appendChild |
Calls original, then scans inserted node |
Node.prototype.insertBefore |
Calls original, then scans inserted node |
Node.prototype.replaceChild |
Calls original, then scans inserted node |
Element.prototype.append |
Calls original, then scans inserted nodes |
Element.prototype.prepend |
Calls original, then scans inserted nodes |
Element.prototype.replaceWith |
Calls original, then scans inserted nodes |
Element.prototype.insertAdjacentElement |
Calls original, then scans inserted element |
Element.prototype.insertAdjacentHTML |
Calls original, then scans parent subtree |
Element.prototype.innerHTML (setter) |
Calls original setter, then scans target subtree |
An optional, off-by-default Chromium mode (Settings.debuggerModeEnabled,
implemented in src/background/debugger-spoof.ts) that applies the timezone
override through the Chrome DevTools Protocol instead of page-world injection.
It exists to close two gaps the content-script path cannot cover on Chromium
MV3 (which has no webRequest.filterResponseData):
- Workers —
Emulation.setTimezoneOverrideapplies across every frame and worker, including module and service workers, so URL-based workers that run unpatched under the injection path on Chromium are covered. - Cold-start race — the override is attached on
webNavigation.onBeforeNavigate, before the document's first script runs, so there is no early window where the real zone leaks.
| Aspect | Behavior |
|---|---|
| Trigger | Opt-in only (debuggerModeEnabled); no-op when off, on non-Chromium builds, or when chrome.debugger is unavailable |
| Mechanism | chrome.debugger.attach per in-scope http/https tab, then Emulation.setTimezoneOverride with the spoofed IANA id |
| Early attach | webNavigation.onBeforeNavigate (runtime webNavigation permission); falls back to tabs.onUpdated when that permission isn't granted |
| Scope | Honors the same per-site all/allowlist/denylist scope as the injection path; out-of-scope or denylisted tabs are detached |
| Geolocation | Not handled here — CDP geolocation override races the first getCurrentPosition grant, so geolocation stays on the prompt-free injected path |
| Timezone injection | While CDP owns the timezone, the page-world timezone overrides no-op (the broadcast withholds timezone); Date/Intl still report the spoofed zone, now from the engine |
| Teardown | Reverts explicitly (setTimezoneOverride with empty id) then detaches; never persists a stale session |
| Permission | debugger is declared required (Chrome forbids it in optional_permissions) but maps to the existing "access your data on all websites" warning, so it adds no new install prompt. Chrome shows a "GeoSpoof started debugging this browser" bar only while the mode is actually attached |
| State | Stateless across MV3 service-worker respawns — chrome.debugger.getTargets() is the source of truth; settings are read fresh per event |
The test suite on the verification site validates that every override is indistinguishable from native. For each overridden function, the following eight properties are checked:
- toString masking —
Function.prototype.toString.call(fn)contains[native code] - name —
fn.nameequals the property name - length —
fn.lengthmatches native arity (validated against a clean iframe reference) - no own prototype — non-constructors have no own
prototypeproperty; constructors do - non-constructable — non-constructors throw
TypeErroronReflect.construct; constructors succeed - descriptor flags —
Object.getOwnPropertyDescriptorflags match native (validated against a clean iframe reference) - strict-mode .caller — accessing
.callerthrowsTypeError - strict-mode .arguments — accessing
.argumentsthrowsTypeError
For accessor properties (contentWindow, contentDocument, innerHTML), a separate battery validates:
- Accessor descriptor shape (get/set presence, configurable, enumerable)
- No data descriptor where native has an accessor
- Getter/setter function stealth (toString, name, length)
| Check | What it validates |
|---|---|
Object.prototype.toString.call(navigator.geolocation) |
Returns [object Geolocation] (Symbol.toStringTag brand) |
Object.prototype.toString.call(position) |
Returns [object GeolocationPosition] |
Object.prototype.toString.call(position.coords) |
Returns [object GeolocationCoordinates] |
Object.prototype.toString.call(navigator.permissions) |
Returns [object Permissions] |
Object.prototype.toString.call(permissionStatus) |
Returns [object PermissionStatus] |
Object.keys(position) |
Returns [] (no own enumerable keys) |
Object.keys(position.coords) |
Returns [] (no own enumerable keys) |
Object.getOwnPropertyNames(position) |
Returns [] (no own string keys at all) |
Object.getOwnPropertyNames(position.coords) |
Returns [] (no own string keys at all) |
Object.getOwnPropertySymbols(position) |
Returns [] (no own symbol keys) |
Object.getOwnPropertySymbols(position.coords) |
Returns [] (no own symbol keys) |
Object.getOwnPropertyDescriptor(coords, "latitude") |
Returns undefined (inherited from prototype) |
JSON.stringify(position) |
Produces native-shaped output with coords + timestamp |
Geolocation.prototype.getCurrentPosition.call({}, noop) |
Throws TypeError (brand check) |
GeolocationPositionError.{PERMISSION_DENIED,POSITION_UNAVAILABLE,TIMEOUT} |
Constants are 1, 2, 3 |
position.coords.latitude decimal precision |
≥6 decimal places (realistic GPS precision via jitter padding) |
These tests cross-check independent APIs against each other to detect partial spoofing:
| Cross-check | APIs compared |
|---|---|
| Offset agreement | Date.getTimezoneOffset() vs Intl.DateTimeFormat shortOffset |
| Hour agreement | Date.getHours() vs toLocaleTimeString("en-US", { hour12: false }) |
| Minute agreement | Date.getMinutes() vs toLocaleTimeString("en-US", { minute: "2-digit" }) |
| Second agreement | Date.getSeconds() vs toLocaleTimeString("en-US", { second: "2-digit" }) |
| Day-of-week agreement | Date.getDay() vs toLocaleDateString("en-US", { weekday: "long" }) |
| Month agreement | Date.getMonth() vs toLocaleDateString("en-US", { month: "long" }) |
| Date agreement | Date.getDate() vs toLocaleDateString("en-US", { day: "numeric" }) |
| Year agreement | Date.getFullYear() vs toLocaleDateString("en-US", { year: "numeric" }) |
| toString offset vs longOffset | Date.toString() GMT±HHMM vs toLocaleString(undefined, { timeZoneName: "longOffset" }) |
| toTimeString vs getTimezoneOffset | Date.toTimeString() GMT offset vs -getTimezoneOffset() |
| toDateString vs Intl | Date.toDateString() weekday/month vs Intl.DateTimeFormat short weekday/month |
| Temporal vs Intl | Temporal.Now.zonedDateTimeISO().timeZoneId vs Intl.DateTimeFormat().resolvedOptions().timeZone |
| format vs formatToParts | Intl.DateTimeFormat.format() vs concatenation of formatToParts() values |
| toLocaleString vs Intl.format | Date.toLocaleString("en-US", opts) vs new Intl.DateTimeFormat("en-US", opts).format() |
| Offset stability | Two Date instances from the same ISO string have identical getTimezoneOffset() |
| Clock monotonicity | Two consecutive Date.now() calls are monotonically non-decreasing |
| DST across seasons | Jan vs Jul getTimezoneOffset() differ for DST-observing zones |
| Cross-method offset (6 surfaces) | getTimezoneOffset, epoch arithmetic, Date.parse, Intl shortOffset, component arithmetic (getUTC* vs get*), Temporal.Instant.toZonedDateTimeISO().offsetNanoseconds — all must agree for the same instant across 4 historical years (2025, 1976, 1952, 1879) |
Validates that geolocation spoofing works across all iframe injection paths:
| Injection path | What's tested |
|---|---|
contentWindow getter |
Static access after iframe is in the tree |
Node.prototype.appendChild |
DOM insertion wrapper scans subtree |
Element.prototype.innerHTML setter |
String-based injection path |
srcdoc attribute |
Synchronous document load from attribute |
document.write("<iframe>") |
Parser-based injection (nested iframe via outer iframe's document) |
Each test creates an about:blank same-origin iframe, calls getCurrentPosition inside it, and asserts the returned coordinates match the top-level identity.
Validates that timezone spoofing is installed in iframe realms:
| Surface | What's tested |
|---|---|
iframe.contentWindow.Intl.DateTimeFormat().resolvedOptions().timeZone |
Must match top-level Intl resolved zone |
new iframe.contentWindow.Date("2024-01-01T12:00:00").getTime() |
Ambiguous string must parse identically to top-level Date |
iframe.contentWindow.Temporal.Now.timeZoneId() |
Must match top-level Intl resolved zone (feature-gated) |
| Technique | What's tested |
|---|---|
eval("navigator.geolocation") |
Dynamic resolution still hits spoofed coords |
new Function("return navigator.geolocation")() |
Function constructor indirection still hits spoofed coords |
sandbox="allow-same-origin" iframe |
Sandboxed iframe's navigator.geolocation still returns spoofed coords |
| Instance descriptor parity | Object.getOwnPropertyDescriptor(navigator.geolocation, "getCurrentPosition") matches pristine iframe baseline |
| Prototype toString | Geolocation.prototype.getCurrentPosition.toString() contains [native code] |
| Surface | What's tested |
|---|---|
| Public IP via srflx candidate | ICE gathering with STUN server must not reveal a public IP in SDP candidates |
| Host candidate mDNS obfuscation | Host-type candidates must use .local mDNS hostnames, not raw LAN IPs |
| Iframe-realm bypass | iframe.contentWindow.RTCPeerConnection gather must not reveal a public IP |
RTCPeerConnection.getStats() |
Stats reports must not expose local candidate IP addresses |
| Check | What's tested |
|---|---|
| Country match | Public IP country (via geojs.io / freeipapi) must match the country of the spoofed coordinates (via Nominatim reverse geocoding) |
| Regional proximity | Public IP location must be within 500km of the spoofed coordinates (Haversine distance) |
| Surface | What's tested |
|---|---|
document.lastModified |
Top-level document's lastModified offset matches the spoofed timezone |
new DOMParser().parseFromString("", "text/html").lastModified |
DOMParser-constructed document's lastModified offset matches |
iframe.contentDocument.lastModified |
Iframe realm's lastModified offset matches |
Validates timezone spoofing across every Worker construction pattern. Each surface has three cards — an Intl-zone reach check, a Temporal.Now.timeZoneId() reach check, and a full Date/Intl/Temporal parity signature — plus a native-method fidelity card. Blob / data / nested workers are patched on every engine (constructor interception) and live under timezone-stealth; URL-based / SharedWorker / importScripts / module workers are patched only on Firefox (filterResponseData) and reclassify to known-limitations on Chromium/Safari; ServiceWorkers are always known-limitations.
| Surface | What's tested |
|---|---|
| Blob-URL / data-URL / nested Worker | Reported Intl zone, Temporal zone, and full parity signature match the main thread (all engines) |
| URL-based classic / SharedWorker / importScripts / module | Same, on Firefox; documented reach limitation on Chromium/Safari |
| ServiceWorker | Same, documented as a known limitation (stable browser-managed URL rules out interception) |
| Full parity signature | Intl zone, getTimezoneOffset, ambiguous/multi-arg construction, Date.parse, all local getters, a setHours round-trip, the formatter family, and Temporal.Now — every field must equal the main thread's |
| Native-method fidelity (per surface) | Every override in the worker has no own prototype, is not constructable, and matches native name / length / [native code] toString; the two constructors stay constructable. Served-surface cards skip when the payload didn't reach the worker (unpatched = vacuously native), deferring to that surface's timezone card |
Validates that replacing Date and Intl.DateTimeFormat preserves native constructor semantics (see Constructor subclassing fidelity):
| Surface | What's tested |
|---|---|
class X extends Date {} |
new X() instanceof X and Object.getPrototypeOf(new X()) === X.prototype |
Reflect.construct(Date, [], F) |
Result's prototype is F.prototype |
class X extends Intl.DateTimeFormat {} |
Same subclass-prototype fidelity, with the spoofed zone still applied through the subclass |
| Iframe realm (Date + Intl.DateTimeFormat) | Same checks against a same-origin iframe's constructors |
| Worker realm (Date + Intl.DateTimeFormat) | Same checks inside the worker payload, with the spoof still intact |
The injected script runs in page context and overrides browser APIs. It's organized as modular TypeScript files bundled into a single IIFE by Vite.
Order matters — each module depends on the ones before it:
- function-masking —
toStringmasking infrastructure - state — Original API references captured at import time
- date-constructor — Global
Datereplaced - settings-listener — CustomEvent listener for settings updates
- geolocation —
getCurrentPosition,watchPosition,clearWatch - permissions —
navigator.permissions.query - timezone-overrides —
getTimezoneOffset,Intl.DateTimeFormatconstructor +resolvedOptions - date-formatting —
toString,toDateString,toTimeString,toLocale* - date-getters —
getHours,getMinutes,getSeconds,getDate,getDay,getMonth,getFullYear - date-setters —
setHours,setMinutes,setSeconds,setDate,setMonth,setFullYear - temporal —
Temporal.Now.*(feature-detected, no-ops if unavailable) - document-overrides —
Document.prototype.lastModifiedgetter - iframe-patching —
contentWindow/contentDocumentgetter overrides - dom-insertion — DOM method wrapping + MutationObserver fallback
- worker-patching —
Worker/SharedWorkerconstructor wrappers that prepend theSPOOF_COREpayload to blob / data / nested workers (all engines) and announce URL-based worker requests to the background for the FirefoxfilterResponseDatapath
Centralized mutable state and original API references. All originals captured at module load time before any overrides.
Exports:
spoofingEnabled,spoofedLocation,timezoneData,settingsReceived— mutable state with setter functionsOriginalDate,OriginalDateParse,OriginalDateTimeFormat— pristine constructorsoriginalGetTimezoneOffset,originalToString,originalGetHours, etc. — unbound prototype methodsoverrideRegistry—Map<Function, string>for toString maskingexplicitTimezoneInstances—WeakSet<Intl.DateTimeFormat>for self-interference preventionEVENT_NAME— configurable event name (build-time)
Anti-fingerprinting infrastructure. Key exports:
initFunctionMasking()— InstallsFunction.prototype.toStringoverride. Must be called first.registerOverride(fn, nativeName)— Register a function in the override registrydisguiseAsNative(fn, nativeName, length)— Set name/length, delete prototypestripConstruct(fn)— Wrap in method shorthand to remove[[Construct]]andprototypeinstallOverride(target, prop, overrideFn)— Full install: register, disguise, define property. Auto-wraps function expressions viastripConstruct.
Randomness the page can't observe. Overrides must never call the page's Math.random: a page can wrap it and see calls native code never makes, and its xorshift128+ stream (V8, SpiderMonkey, JavaScriptCore) is predictable, so a consumed value shows as a skip.
randomUnit()— Uniform float in [0, 1) with 53 bits of entropy fromcrypto.getRandomValues, captured and bound at module load into a pre-allocatedUint32Array. Returns0.5ifcryptois missing rather than falling back toMath.random.
Pure utility functions (no side effects on globals):
getIntlBasedOffset(date, timezoneId, fallback)— Resolve UTC offset viaIntl.DateTimeFormatwithshortOffsetformatGMTOffset(offsetMinutes)— Convert offset toGMT±HHMMstringgetLongTimezoneName(date, timezoneId)— Extract long timezone name (e.g. "Pacific Daylight Time")isAmbiguousDateString(str)— Detect date strings without explicit timezone indicatorcomputeEpochAdjustment(parsedDate, timezoneId, fallback)— Compute ms adjustment for Date constructor spoofing with iterative DST refinementparseGMTOffset(gmtString)— ParseGMT+5:30style strings to minutesvalidateTimezoneData(tz)— Runtime type guard for TimezoneData
Installs geolocation and position/coordinate overrides on Geolocation.prototype, GeolocationPosition.prototype, and GeolocationCoordinates.prototype. Key exports:
installGeolocationOverrides()— InstallsgetCurrentPosition/watchPosition/clearWatchonGeolocation.prototype, plus prototype-level accessor andtoJSONinstallers so spoofed instances have zero own properties (matching native layout —Object.getOwnPropertyNames(pos)returns[]).getPaddedCoords(location)— Returns a padded coordinate pair for the given raw spoofed location. Pads values with fewer than 7 decimal places by appending a ±5mm jitter (well below any accuracy a user could configure), caches per unique raw pair, and is shared withiframe-patching.tsso main-window and iframe paths emit identical values. Closes the "suspiciously round spoofed coordinates" detection vector.
All randomness here (coordinate jitter, fix latency, watch intervals) comes from safe-random.ts, never the page's Math.random.
Spoofed positions are allocated via Object.create(GeolocationPosition.prototype) with coordinate/timestamp values stored in a module-level WeakMap<object, slots>. Prototype getters consult the WeakMap for our instances and fall through to the native getters for pristine browser-allocated objects.
Overrides Document.prototype.lastModified to reformat the native string in the spoofed timezone. Key exports:
installDocumentOverrides()— Installs thelastModifiedgetter override on the top-levelDocument.prototype.installLastModifiedOverride(documentProto)— Installs the override on anyDocument.prototype(used byiframe-patching.tsfor iframe realms).
The override parses the native "MM/DD/YYYY HH:MM:SS" string (which is in the real system timezone), recovers the UTC epoch, and reformats it in the spoofed timezone using Intl.DateTimeFormat via formatToParts.
Per-iframe realm patcher. When a same-origin iframe window is first accessed, patchIframeWindow installs ten sections of overrides on that iframe's realm:
Function.prototype.toString— native-[native code]-reporting mask- Geolocation —
getCurrentPosition/watchPosition/clearWatchon the iframe'sGeolocation.prototype - Permissions —
queryon the iframe'sPermissions.prototype, returning a prototype-linkedPermissionStatuswithstate: "granted"for geolocation - Intl —
Intl.DateTimeFormatconstructor +resolvedOptionson the iframe's realm - Date — full spoofing-aware
Dateconstructor +Date.parseon the iframe's realm - Date.prototype methods — full per-method overrides on the iframe's
Date.prototypevia the parameterized installers fromdate-getters.ts,date-setters.ts,date-formatting.ts, andtimezone-overrides.ts. Captures the iframe realm's native methods for fallback paths so each realm stays self-contained. - Temporal —
Temporal.Now.{timeZoneId,plainDateTimeISO,plainDateISO,plainTimeISO,zonedDateTimeISO}when available - Nested-iframe cascade — iframe-realm copies of
HTMLIFrameElement.prototype.{contentWindow,contentDocument}getters,Node.prototype.{appendChild,insertBefore,replaceChild},Element.prototype.{append,prepend,replaceWith,insertAdjacentElement,insertAdjacentHTML}, theinnerHTMLsetter, plus aMutationObserveron the iframe's owndocumentElement. This causes a nested iframe created from inside the outer iframe to triggerpatchIframeWindowon itself recursively — so grand-nested iframes get the same treatment, and so on. - Document —
Document.prototype.lastModifiedgetter override on the iframe's realm - RTCPeerConnection — iframe-realm copy of the top-level WebRTC override
Cross-origin iframes throw SecurityError on any access and are silently skipped. All shape/descriptor checks in the cascade use tag-name / duck-typing rather than instanceof, because elements created in the iframe's realm are not instances of the top-level constructors.
Feature-detected Temporal API overrides. Overrides Temporal.Now methods when available:
timeZoneId()— returns spoofed identifierplainDateTimeISO(),plainDateISO(),plainTimeISO(),zonedDateTimeISO()— pass spoofed identifier when no explicit timezone argument provided
Wraps the Worker and SharedWorker constructors so worker realms receive the spoofing payload. For blob / data / nested workers it prepends SPOOF_CORE at construction time (every engine) and recursively wraps self.Worker inside each worker so nested workers to any depth are covered. For URL-based workers it announces the URL to the background (ANNOUNCE_WORKER_FETCH) and passes the original URL through, so the Firefox webRequest.filterResponseData listener can prepend the payload at the network layer. Skipped in cross-origin frame contexts.
Defines SPOOF_CORE — the self-contained ES5 spoofing payload injected into worker scopes — and buildStandaloneWorkerPayload(identifier), which wraps it in an IIFE with the spoofed IANA id substituted. Shared by both worker-patching.ts (content-script path) and src/background/worker-request-filter.ts (Firefox filterResponseData path) so both paths inject byte-identical behavior. Provides full main-realm parity — Date constructor / Date.parse, getTimezoneOffset, local getters and setters, getMilliseconds, the formatter family, Intl.DateTimeFormat + resolvedOptions, and Temporal.Now.* — with new.target-aware constructors and, via the __nativeMethod concise-method wrapper, native method shape (no prototype / [[Construct]], correct name / length / [native code] toString). See Worker-scope overrides.
Uses CustomEvent with a configurable event name (EVENT_NAME, set at build time, defaults to "__x_evt"). CSP-safe.
interface SettingsEventDetail {
enabled: boolean;
location: Location | null;
timezone: Timezone | null;
}On Firefox, cloneInto() is used to make the detail object accessible across content-script / page-context boundaries.
The isolated-world content script also owns a deliberately narrow window.postMessage
handshake for the iOS onboarding page. It is active only in the top frame at
https://geospoof.com/activate or https://www.geospoof.com/activate (an optional
trailing slash and query parameters are allowed). Development-mode Safari bundles also
accept the exact loopback origins localhost:3000 and 127.0.0.1:3000 over HTTP or
HTTPS so the hosted flow can be exercised locally. Production bundles never enable
those loopback origins.
The page sends a random, per-load nonce:
{
source: "com.geospoof.activation-page";
type: "GEOSPOOF_ACTIVATION_PING";
protocolVersion: 1;
nonce: string;
}The content script responds only after background settings have loaded, protection is enabled, and a spoofed location exists:
{
source: "com.moonloaf.geospoof.extension";
type: "GEOSPOOF_ACTIVATION_READY";
protocolVersion: 1;
nonce: string;
}Both the page origin and event.source === window are checked, and the response is
bound to the request nonce. No coordinates, settings, entitlement, install identifier,
or other user data are sent. Silence is intentionally ambiguous: it can mean the
extension is absent, lacks website access, is blocked by restrictions, is still loading,
or does not currently have protection plus a location enabled. The activation page must
therefore describe silence as “not detected yet,” never as proof that the extension is
disabled.
After Safari returns a spoofed location successfully, the page offers
geospoof://onboarding/safari-complete. The iOS SceneDelegate routes that URL for both
cold and warm app opens through a process-local, one-shot AppRouter request. An active
first-run or debug onboarding flow consumes the request and presents the native
“Safari is ready” screen; users who already completed onboarding simply return to the
app. The custom URL is navigation, not a security attestation—the nonce-bound extension
handshake and successful browser geolocation read are the proof shown by the hosted page.
The extension uses browser.runtime.sendMessage and browser.runtime.onMessage for inter-component communication. All messages follow this structure:
{
type: string, // Message type identifier
payload?: object // Message-specific data
}{ type: "SET_LOCATION", payload: { latitude: number, longitude: number } }Side effects: Calculates timezone (local lookup via browser-geo-tz boundary data fetched from geospoof.com), performs reverse geocoding, saves to storage, broadcasts to all content scripts, updates badge.
{ type: "SET_PROTECTION_STATUS", payload: { enabled: boolean } }Side effects: Saves status, updates badge (green/gray), broadcasts to content scripts.
{ type: "SET_WEBRTC_PROTECTION", payload: { enabled: boolean } }Side effects: Configures browser privacy API settings, saves to storage.
{ type: "GEOCODE_QUERY", payload: { query: string } }Response: { results: [{ name, latitude, longitude, city, country }] }
Error: { error: "TIMEOUT" | "NETWORK" | "NO_RESULTS", message: string }
{
type: "GET_SETTINGS";
}Response: Complete settings object.
{
type: "COMPLETE_ONBOARDING";
}{ type: "SYNC_VPN", payload?: { forceRefresh?: boolean } }Detects the public exit IP, geolocates it, applies the location (with timezone hint), and sets vpnSyncEnabled: true. Clears per-endpoint cooldowns first so every endpoint gets a fresh shot. forceRefresh bypasses caches and in-flight dedup (the Re-sync button).
Response: { latitude, longitude, city, country, ip } or { error: "IP_DETECTION_FAILED" | "GEOLOCATION_FAILED" | "IP_BLOCKED" | "NETWORK", message }. See VPN_SYNC.md.
{
type: "DISABLE_VPN_SYNC";
}Clears the IP-geo cache and lastSyncedIp, sets vpnSyncEnabled: false, and nulls location / timezone / locationName.
{
type: "CLEAR_LOCATION";
}Nulls location / timezone / locationName and broadcasts to content scripts.
{ type: "CHECK_TAB_INJECTION", payload: { tabId: number } }Response: { injected: boolean, error: string | null }.
{ type: "SET_DEBUG_LOGGING", payload: { enabled: boolean } }
{ type: "SET_VERBOSITY_LEVEL", payload: { level: string } }
{ type: "SET_THEME", payload: { theme: "system" | "light" | "dark" } }{ type: "SAVE_FAVORITE", payload: { id, latitude, longitude, city, country, displayName, label } }
{ type: "REMOVE_FAVORITE", payload: { id: string } }
{ type: "RENAME_FAVORITE", payload: { id: string, label: string } }Response: { success: true } or { error: "AT_CAPACITY" | "STORAGE_ERROR" }. Capacity is 10; saves dedupe by coordinates rounded to 4 decimal places.
{ type: "SET_PRECISION", payload: { precision: { mode: "exact" } | { mode: "approximate", radiusMeters: number } } }Repairs the value through validateLocationPrecision (unknown mode → exact; radius clamped to 50–50,000 m), persists it, and broadcasts to content scripts.
Response: { success: true } or { error: "STORAGE_ERROR" }.
{ type: "SET_PRECISION_CADENCE", payload: { cadence: "never" | "session" | "hour" | "day" } }Any other value (including "page") is stored as "never" and still returns { success: true }. A non-Never cadence persists a valid precisionSeed when none is stored; a valid stored seed is never changed, and locationPrecision is never touched. Switching to hour or day starts from the clock's current bucket; re-selecting the current cadence keeps the held one. After the save it re-registers the Firefox bootstrap, broadcasts, and creates (hour, day) or clears (never, session) the precision-rollover alarm. See Location precision and cadence. Implemented by applyPrecisionCadence, which the Safari app bridge also calls.
Response: { success: true }; { error: "STORAGE_ERROR" } when the save fails (nothing changes); or { error: message } when the broadcast fails (the save and the alarm stand).
{ type: "ANNOUNCE_WORKER_FETCH", payload: { url: string } }Fire-and-forget. Sent by the content-script Worker wrapper just before handing off to the real constructor, so the Firefox webRequest.filterResponseData listener knows the next request for url is a worker script. Only same-origin worker URLs are allowlisted; cross-origin workers are never patched.
{
type: "UPDATE_SETTINGS",
payload: {
enabled: boolean,
location: { latitude: number, longitude: number, accuracy: number } | null,
timezone: { identifier: string, offset: number, dstOffset: number } | null,
debugLogging: boolean,
verbosityLevel: string,
webrtcProtection: boolean
}
}{
type: "PING";
}Response: { pong: true } — used for injection health checks via browser.alarms.
Persisted in browser.storage.local under the key "settings".
{
enabled: boolean,
location: { latitude: number, longitude: number, accuracy: number } | null,
timezone: { identifier: string, offset: number, dstOffset: number, fallback?: boolean } | null,
locationName: { city: string, country: string, displayName: string } | null,
webrtcProtection: boolean,
onboardingCompleted: boolean,
vpnSyncEnabled: boolean, // VPN sync is the active location method
debugLogging: boolean, // verbose logging toggle
verbosityLevel: string, // debug logger threshold (e.g. "INFO")
theme: "system" | "light" | "dark",
favorites: Favorite[], // up to 50 saved locations
locationPrecision: { mode: "exact" } | { mode: "approximate", radiusMeters: number },
precisionSeed: number, // per-install seed for the approximate offset; 0 = unseeded
precisionCadence: "never" | "session" | "hour" | "day", // default "never"; bridged to the Safari app as a plain string
version: string,
lastUpdated: number // epoch ms; default 0 (never-saved sentinel — see app-bridge.ts)
}Every page-bound builder (the per-tab broadcast in sendSettingsToTab, the content-script GET_SETTINGS reply, tabs.onCreated, the injection-check alarm, and the Firefox bootstrap) gets location from one resolver, resolveCurrentPageLocation in src/background/precision-cadence.ts:
point = applyPrecisionOffset(anchor, effectivePrecision, periodSeed)
periodSeed = precisionCadence === "never"
? precisionSeed
: derivePeriodSeed(precisionSeed, { cadence, index })
effectivePrecision is computeEffectiveLocationPrecision(locationPrecision, proFeaturesBlocked), so Exact and the iOS Pro gate return the Anchor under every cadence. Under Never the expression is the pre-cadence one, with no storage.session I/O and no alarm. The pure derivation (period seeds, phase, buckets) lives in src/shared/precision/cadence.ts.
Period sources:
- Session: a random uint32 id in
storage.session(cache:precisionCadence:session), minted the first time it's needed. A failed read uses a per-spawn fallback id and never overwrites the stored one. - Hour / day: the bucket
floor((Date.now() − phase) / P), withPone hour or one day and a per-installphasein[0, P)derived fromprecisionSeed. The background holds the current bucket, mirrored incache:precisionCadence:bucketso a service-worker restart keeps it. It advances only in the handler for the one-shotprecision-rolloveralarm, or at a background spawn that finds that alarm more than 2 minutes overdue. When the bucket changes and the point would differ, the handler re-reads settings, broadcasts to every tab, and re-registers the Firefox bootstrap. It then schedules the next boundary, never less than 1 s out.
No session id, phase, or bucket reaches pages, and payload shapes are unchanged. Every page load is planned for a later release.
Safari app bridge. precisionCadence crosses the bridge as its own plain string, never inside locationPrecision, whose JSON is unchanged:
- Extension → app.
REGION_UPDATE.precisionCadencecarries the stored value after every successful save. The native handler stores it asregion_precisionCadenceverbatim, with no validation or trimming, and leaves the stored value alone when the field is absent or not a string. - App → extension. The app writes
pending_precisionCadenceonly once it knows a value, verbatim. The handler relays it aspending.precisionCadence. - Adoption. Step 7a of
adoptPendingSnapshotruns after thelocationPrecisionstep, which leaves the cadence alone. It adopts only when the value is a string whosevalidatePrecisionCadencerepair differs from the stored value, throughapplyPrecisionCadence, so it has the same side effects asSET_PRECISION_CADENCE. An absent key is a no-op, a non-string is ignored, an unknown string (such as"page") repairs to"never", and a failure is logged at WARN while the later steps still run. - Only the preference crosses. The session id, held bucket, phase, and
precisionSeednever do. A rollover saves nothing, so it sends noREGION_UPDATE. - Pro. The app's UI gate is a convenience;
proFeaturesBlockedis what enforces it, returning the Anchor for a non-Pro user under every cadence.
A widget or Shortcut action restamps the app's snapshot, so the app's last-written cadence can override a newer popup change, as for every bridged setting.
Known limits:
- Alarm timing. Chrome alarms have a 30 s floor and can fire late; alarms missed during sleep fire on wake; a dropped alarm is re-created on the next spawn. Until the rollover runs, every frame keeps the previous point, so a new point arrives at or after its boundary, never before. Where alarms don't fire at all (unverified on iOS Safari), the rollover happens at the first background spawn more than 2 minutes after the boundary.
- Rollover race. A
GET_SETTINGSreply or push computed just before the advance and delivered after the rollover push leaves that frame on the previous point until the next push or a reload. Every settings change has the same race today. - Firefox bootstrap window. Documents starting during re-registration can get the previous point (or no bootstrap) inline, then the current one from the async path milliseconds later. A registration persisted across a browser restart can carry the previous period's point until startup re-registers. Both are observable only through an early
watchPosition, and only with Instant timezone protection on. - Safari
storage.sessionlifetime is unverified. It's expected to survive background unload and clear when Safari quits (on iOS, when iOS terminates Safari). If it behaves differently, that defines what a session is on Safari. - Tabs open across an extension update, reload, or disable/enable keep their old point until reloaded: their orphaned content scripts get no more pushes. New pages and frames in those tabs use the new session id or the recomputed bucket.
- Degraded
storage.session. Failed reads or writes fall back to a per-spawn session id or the clock bucket. They fail toward more draws, never toward the Anchor. - Averaging. Redrawing around a fixed center lets a site average samples back toward it; the error shrinks roughly as radius ÷ √n.
- Cached positions. A
getCurrentPositionwithmaximumAgeright after a rollover can return the previous point from the page's cache, as a real device would.
{ latitude: number, longitude: number, accuracy: number }{ identifier: string, offset: number, dstOffset: number, fallback?: boolean }
// identifier: IANA timezone ID (e.g. "America/Los_Angeles")
// offset: Minutes from UTC (e.g. -480 for PST)
// dstOffset: DST offset in minutes
// fallback: true when estimated from longitude (Etc/GMT±N) rather than a real lookup — never persisted{ city: string, country: string, displayName: string }{
id: string, // timestamp-based unique id
latitude: number,
longitude: number,
city: string,
country: string,
displayName: string, // capped at 100 chars
label: string | null // user-defined; overrides city when non-empty
}{
coords: {
latitude: number, longitude: number, accuracy: number,
altitude: null, altitudeAccuracy: null, heading: null, speed: null
},
timestamp: number
}Base URL: https://nominatim.openstreetmap.org
Rate Limit: 1 req/sec | Auth: None | Timeout: 5s | Retry: 3x exponential backoff
- Forward geocoding:
/search?q=...&format=json&limit=5&addressdetails=1 - Reverse geocoding:
/reverse?lat=...&lon=...&format=json&addressdetails=1 - Caching: In-memory by rounded coordinates (4 decimal places)
- User-Agent:
GeoSpoof-Extension/1.0
Uses browser-geo-tz npm package for offline coordinate-to-timezone resolution. No external geocoding API calls needed for timezone lookup — the boundary dataset is fetched as a version-scoped static asset from cdn.geospoof.com/geo-tz/<version>/ (CloudFront over a private S3 bucket, provisioned by the CDK app in cdk/; range-requested). See TIMEZONE_GEO_DATA.md for hosting, versioning, and the data-update runbook.
getTimezoneForCoordinates(lat, lng, ianaHint?) resolves in priority order: (1) the offline browser-geo-tz boundary lookup, (2) the optional ianaHint — an IANA identifier from the winning geo service on the VPN-sync path, used when the boundary lookup throws or returns nothing, and (3) the crude longitude estimate (Etc/GMT±N) as a last resort. The hint is validated by format and guarded with a try/catch around Intl (it's external/untrusted). Longitude-estimate fallbacks are never cached or persisted, so a transient CDN failure retries on the next call. See VPN_SYNC.md for details.
VPN Sync detects the public (exit) IP and geolocates it to align the spoofed location with the VPN region. The public IP is resolved via a failover pool of HTTP IP-echo endpoints (AWS → Cloudflare → Akamai → ipify, first success wins), and geolocation is resolved via a parallel race of geo services (first success wins):
| Geo service | URL | Notes |
|---|---|---|
| GeoJS (primary) | https://get.geojs.io/v1/ip/geo/{ip}.json |
CORS-friendly, no key; lat/lng strings |
| FreeIPAPI (fallback #1) | https://free.freeipapi.com/api/json/{ip} |
|
| ReallyFreeGeoIP (fallback #2) | https://reallyfreegeoip.org/json/{ip} |
|
| ipinfo.io (fallback #3) | https://ipinfo.io/{ip}/json |
Google Cloud network; loc is lat,lng |
Timeout: 10s (IP detection), 5s per geo service | Retry: 2x exponential backoff | Results cached in browser.storage.session and browser.storage.local (30 days). Each endpoint pool has independent per-endpoint rate-limit cooldowns, and the spoofed location auto-resyncs when the exit IP changes.
See VPN_SYNC.md for the full auto-resync architecture — IP detection failover, per-endpoint cooldowns, the shared resync gate (debounce / min-interval / switch-settle / circuit breaker / IP-diff), the proxy-change and activity watchers, engine support, and timezone-hint resolution.
-
Web Workers — Content scripts cannot directly inject into Worker/SharedWorker/ServiceWorker contexts because they run in separate global scopes. The extension takes different paths for different URL schemes to balance protection coverage with site-breakage risk:
-
URL-based workers (
new Worker("/foo.js"), module workers, SharedWorker with a URL,navigator.serviceWorker.register(url)) — the content script announces the URL to the background and passes the original URL through to the real constructor. On Firefox the background'swebRequest.filterResponseDatalistener prepends the spoofing payload to the response bytes, but only for workers whose registrable domain (eTLD+1) matches the tab's page origin. Cross-origin workers — such as Cloudflare Turnstile (challenges.cloudflare.com), Stripe (js.stripe.com), or any other third-party worker loaded from a different domain — are classified as"pass"and left completely unmodified. This is an accepted limitation analogous to the cross-origin iframe limitation: the timezone leak in cross-origin workers is not closed by this extension. On Chromium and Safari nothing listens (the API doesn't exist on those engines) and URL-based workers run unpatched on the injection path — a documented engine limit, not a site-breakage path. On Chromium this is closed when the user enables the opt-in Engine-level Spoofing mode (chrome.debugger→Emulation.setTimezoneOverride), which applies across workers including module/service workers (see Engine-level Spoofing (Chromium)); Safari has no equivalent. -
Inline workers (
new Worker(URL.createObjectURL(new Blob([...])))ornew Worker("data:application/javascript,...")) — blob-wrapped on every engine. The site already committed to an inline URL and its CSP necessarily allows it, so our replacement blob stays within the same allowance.
Rewriting URL-based workers to blob URLs is deliberately avoided because blob wrapping fails on strict-CSP origins (breaking the site's worker entirely), shifts
self.locationwhich breaks relativefetch/importcalls inside the worker, and can't preserve module-workerimportresolution. ThefilterResponseDatapath has cleaner failure modes: on the rare site that ships Subresource Integrity on worker scripts, the browser rejects the modified bytes, ouronerrorhandler disconnects, the original response serves unmodified, and the site keeps working (with no protection on that origin).Where the payload does reach a worker (blob/data/nested on every engine; URL-based/module/service on Firefox), it provides full main-realm parity and native-indistinguishable overrides — see Worker-scope overrides. Every override matches native method shape (no own
prototype, no[[Construct]], correctname/length/[native code]toString), theDateandIntl.DateTimeFormatconstructors arenew.target-aware, and CreepJS'scode:worker-fingerprint hash is closed on Firefox: the payload derives the engine-specific"[native code]"surround format at runtime and matches Firefox's multi-line shape, so spoofedIntl.DateTimeFormat,Dateconstructor + prototype methods,Worker, andimportScriptsall pass the native-shape check. -
-
XSLT/EXSLT datetime (Firefox only) — closed, with edges.
XSLTProcessorruns inside a C++ engine (libxslt on Gecko) that doesn't round-trip through JavaScript date machinery, so EXSLT'sdate:date-time()emits an ISO datetime carrying the real system UTC offset, bypassing every Date/Intl/Temporal override — the ground-truth surface arkenfox's TZP uses. The result is still delivered back through the JSXSLTProcessorAPI, so GeoSpoof wrapstransformToFragment/transformToDocument(src/content/injected/xslt-overrides.ts), walks the result's text nodes, and rewrites any offset-bearing ISO datetime within ~10s of the current instant into the spoofed zone (the tight "is it now?" gate avoids corrupting legitimate datetimes in transformed content). The iframe patcher installs the same wrap on each same-origin iframe realm'sXSLTProcessor. Residual edges: cross-origin iframe XSLT can't be reached (see #4). Chromium/WebKit don't ship EXSLT, and Firefox is winding XSLT down behinddom.xslt.enabled(FF151+; when off, no EXSLT date is produced and this no-ops), so the surface is shrinking regardless. -
Extension initialization race — Browser extensions install overrides at
document_start, but spoofing settings arrive asynchronously (typically 50-250ms on cold page loads). A fingerprinting script that reads timezone synchronously in<head>can win that race and learn the real zone. This is a fundamental MV3 limitation for the content-script path and requires browser-level fixes to eliminate (see Tor Browser, which patches C++ directly). On Chromium, the opt-in Engine-level Spoofing mode closes this for the timezone: it attaches viawebNavigation.onBeforeNavigatebefore the document's first script runs (see Engine-level Spoofing (Chromium)). Geolocation still uses the injected path on every engine, so its cold-start window remains. -
Cross-origin iframes —
SecurityErrorprevents any access from the content script to cross-origin iframes, sopatchIframeWindowsilently skips them. Additionally, the injected script runs in every frame context including cross-origin ones — Worker patching, iframe patching, and DOM insertion wrapping are skipped in cross-origin frame contexts to avoid interfering with third-party scripts (e.g. Cloudflare Turnstile) that perform self-integrity checks and break when their execution environment is modified. Timing side-channels can reveal discrepancies between the parent's spoofed view and the cross-origin iframe's real view. -
Timing channels — A content-script spoofer returns fake positions via
setTimeouton the JavaScript event loop. Real GPS/Wi-Fi/cell-tower lookups go through browser-internal threads with wider, heavier-tailed latency distributions. A detector with enough samples can fingerprint the artificial bounds (10-50ms floor). Additionally, nativemaximumAgecaching serves positions sub-millisecond, while asetTimeout-based spoofer cannot emit below its configured delay. Only a browser-native implementation can match real hardware timing signatures. -
SharedArrayBuffer timing — High-resolution timing can fingerprint override execution patterns. Unfixable at the content-script level.
-
IP geolocation — Extension does not mask IP address. Fingerprinting scripts cross-check public IP country/region against browser-reported geolocation. Closing this gap requires routing traffic through a VPN exit in the spoofed region (hence the VPN Sync feature).
-
WebRTC IP leaks — Without
browser.privacy.network.webRTCIPHandlingPolicyset todisable_non_proxied_udp, WebRTC ICE gathering can expose the real public IP via server-reflexive (srflx) candidates, even when geolocation is spoofed. On Firefox this only holds when behind a proxy/VPN; on Chromium it's strict. Safari doesn't exposebrowser.privacy. -
Precision cadence timing — Under Every hour and Every day the reported point moves while pages are open, at an install-specific time that can be late (alarm scheduling, sleep) but never early, and redrawing gives a site more samples to average. See Location precision and cadence for these and the other cadence limits.
| Operation | Target |
|---|---|
| Geolocation API overhead | <50ms per call |
| Popup open time | <200ms |
| Settings save | <500ms |
| Settings load on startup | <1 second |
| Geocoding request | <5 seconds (with timeout) |
| Badge update | <100ms |
| Browser | Minimum Version |
|---|---|
| Firefox | 140+ (MV3) |
| Firefox Android | 140+ |
| Chrome / Brave / Edge | MV3 |
| Safari (macOS) | 15+ |
| Safari (iOS / iPadOS) | 15+ |