UNPKG

location-guard

Version:

A UserScript that hide/spoof your geographic location from websites.

436 lines (429 loc) 16.8 kB
// ==UserScript== // @name Location Guard // @description A UserScript that hide/spoof your geographic location from websites. // @namespace https://skk.moe // @run-at document-end // @match *://*/* // @updateURL https://unpkg.com/location-guard@latest/dist/location-guard-ng.meta.js // @downloadURL https://unpkg.com/location-guard@latest/dist/location-guard-ng.user.js // @version 0.2.2 // @author Sukka <https://skk.moe> // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM.getValue // @grant GM.setValue // @grant GM_deleteValue // @grant GM.deleteValue // @grant GM.registerMenuCommand // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM.addValueChangeListener // @grant GM.removeValueChangeListener // ==/UserScript== (function(){'use strict';const DEFAULT_VALUE = { defaultLevel: 'fixed', paused: false, cachedPos: {}, fixedPos: { latitude: -4.448784, longitude: -171.24832 }, updateAccuracy: true, epsilon: 2, levels: { low: { radius: 200, cacheTime: 10 }, medium: { radius: 500, cacheTime: 30 }, high: { radius: 2000, cacheTime: 60 } } }; function getStoredValueAsync(key, providedDefaultValue) { return GM.getValue(key, providedDefaultValue ?? DEFAULT_VALUE[key]); } function setStoredValueAsync(key, value) { return GM.setValue(key, value); }// Planar Laplace mechanism, based on Marco's demo // // This class just implements the mechanism, does no budget management or // selection of epsilon // // constructor const PlanarLaplace = { /** convert an angle in radians to degrees and viceversa */ rad_of_deg (ang) { return ang * Math.PI / 180; }, deg_of_rad (ang) { return ang * 180 / Math.PI; }, /** * Mercator projection * https://wiki.openstreetmap.org/wiki/Mercator * https://en.wikipedia.org/wiki/Mercator_projection * * getLatLon and getCartesianPosition are inverse functions * They are used to transfer { x: ..., y: ... } and { latitude: ..., longitude: ... } into one another */ getLatLon ({ x, y }) { const rLon = x / PlanarLaplace.earth_radius; const rLat = 2 * Math.atan(Math.exp(y / PlanarLaplace.earth_radius)) - Math.PI / 2; // convert to degrees return { latitude: PlanarLaplace.deg_of_rad(rLat), longitude: PlanarLaplace.deg_of_rad(rLon) }; }, getCartesian ({ longitude, latitude }) { // latitude and longitude are converted in radiants return { x: PlanarLaplace.earth_radius * PlanarLaplace.rad_of_deg(longitude), y: PlanarLaplace.earth_radius * Math.log(Math.tan(Math.PI / 4 + PlanarLaplace.rad_of_deg(latitude) / 2)) }; }, /** LamberW function on branch -1 (http://en.wikipedia.org/wiki/Lambert_W_function) */ LambertW (x) { // min_diff decides when the while loop should stop const min_diff = 1e-10; if (x === -1 / Math.E) { return -1; } if (x < 0 && x > -1 / Math.E) { let q = Math.log(-x); let p = 1; while(Math.abs(p - q) > min_diff){ p = (q * q + x / Math.exp(q)) / (q + 1); q = (p * p + x / Math.exp(p)) / (p + 1); } // This line decides the precision of the float number that would be returned return Math.round(1000000 * q) / 1000000; } if (x === 0) { return 0; } // TODO why do you need this if branch? return 0; }, /** This is the inverse cumulative polar laplacian distribution function. */ inverseCumulativeGamma (epsilon, z) { const x = (z - 1) / Math.E; return -(PlanarLaplace.LambertW(x) + 1) / epsilon; }, /** * returns alpha such that the noisy pos is within alpha from the real pos with * probability at least delta * (comes directly from the inverse cumulative of the gamma distribution) */ alphaDeltaAccuracy (epsilon, delta) { return PlanarLaplace.inverseCumulativeGamma(epsilon, delta); }, // returns the average distance between the real and the noisy pos // expectedError (epsilon) { return 2 / epsilon; }, addPolarNoise (epsilon, pos) { // random number in [0, 2*PI) const theta = Math.random() * Math.PI * 2; // random variable in [0,1) const z = Math.random(); const r = PlanarLaplace.inverseCumulativeGamma(epsilon, z); return PlanarLaplace.addVectorToPos(pos, r, theta); }, addPolarNoiseCartesian (epsilon, pos) { let x, y; if ('latitude' in pos) { const tmp = PlanarLaplace.getCartesian(pos); x = tmp.x; y = tmp.y; } else { x = pos.x; y = pos.y; } // random number in [0, 2*PI) const theta = Math.random() * Math.PI * 2; // random variable in [0,1) const z = Math.random(); const r = PlanarLaplace.inverseCumulativeGamma(epsilon, z); return PlanarLaplace.getLatLon({ x: x + r * Math.cos(theta), y: y + r * Math.sin(theta) }); }, /** http://www.movable-type.co.uk/scripts/latlong.html */ addVectorToPos ({ latitude, longitude }, distance, angle) { const ang_distance = distance / PlanarLaplace.earth_radius; const lat1 = PlanarLaplace.rad_of_deg(latitude); const lon1 = PlanarLaplace.rad_of_deg(longitude); const lat2 = Math.asin(Math.sin(lat1) * Math.cos(ang_distance) + Math.cos(lat1) * Math.sin(ang_distance) * Math.cos(angle)); let lon2 = lon1 + Math.atan2(Math.sin(angle) * Math.sin(ang_distance) * Math.cos(lat1), Math.cos(ang_distance) - Math.sin(lat1) * Math.sin(lat2)); // eslint-disable-next-line @stylistic/js/no-mixed-operators -- copy other's formula lon2 = (lon2 + 3 * Math.PI) % (2 * Math.PI) - Math.PI; // normalise to -180..+180 return { latitude: PlanarLaplace.deg_of_rad(lat2), longitude: PlanarLaplace.deg_of_rad(lon2) }; }, /** This function generates the position of a point with Laplacian noise */ addNoise (epsilon, pos) { // TODO: use latlon.js return PlanarLaplace.addPolarNoise(epsilon, pos); }, earth_radius: 6378137 // const, in meters };function klona(x) { if (typeof x !== 'object') return x; var k, tmp, str=Object.prototype.toString.call(x); if (str === '[object Object]') { if (x.constructor !== Object && typeof x.constructor === 'function') { tmp = new x.constructor(); for (k in x) { if (x.hasOwnProperty(k) && tmp[k] !== x[k]) { tmp[k] = klona(x[k]); } } } else { tmp = {}; // null for (k in x) { if (k === '__proto__') { Object.defineProperty(tmp, k, { value: klona(x[k]), configurable: true, enumerable: true, writable: true, }); } else { tmp[k] = klona(x[k]); } } } return tmp; } if (str === '[object Array]') { k = x.length; for (tmp=Array(k); k--;) { tmp[k] = klona(x[k]); } return tmp; } if (str === '[object Date]') { return new Date(+x); } if (str === '[object RegExp]') { tmp = new RegExp(x.source, x.flags); tmp.lastIndex = x.lastIndex; return tmp; } return x; }const pattern = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i; let isMobile = null; const isMobileDevice = ()=>{ if (isMobile !== null) return isMobile; isMobile = pattern.test(window.navigator.userAgent); return isMobile; }; function randomInt(from, to) { return Math.floor(Math.random() * (to - from + 1)) + from; }// eslint-disable-next-line @typescript-eslint/unbound-method -- cache original function and will be called with proper this const watchPosition = navigator.geolocation.watchPosition; // eslint-disable-next-line @typescript-eslint/unbound-method -- cache original function and will be called with proper this const getCurrentPosition = navigator.geolocation.getCurrentPosition; // eslint-disable-next-line @typescript-eslint/unbound-method -- cache original function and will be called with proper this const clearWatch = navigator.geolocation.clearWatch; async function callGeoCb(cb, arg, checkAllowed) { if (cb && (!checkAllowed || await isWatchAllowed())) { cb(arg); } } const spoofLocation = ()=>{ // We replace geolocation methods with our own. // getCurrentPosition will be called by the content script (not by the page) // so we dont need to keep it at all. navigator.geolocation.getCurrentPosition = async function(positionCb, positionOnError, options) { // call getNoisyPosition on the content-script // call cb1 on success, cb2 on failure const res = await getNoisyPosition(options); if (res.success) { callGeoCb(positionCb, res.position, false); } else { callGeoCb(positionOnError, res.position, false); } // callCb(res.success ? positionCb : positionOnError, res.position, false); }; /** store all watchPosition method's handle id */ const handlers = new Map(); navigator.geolocation.watchPosition = function(cb1, cb2, options) { // We need to return a handler synchronously, but decide whether we'll use the real watchPosition or not // asynchronously. So we create our own handler, and we'll associate it with the real one later. const handler = Math.floor(Math.random() * 10000); (async ()=>{ if (await isWatchAllowed()) { // We're allowed to call the real watchPosition (note: remember the handler) handlers.set(handler, watchPosition.apply(navigator.geolocation, [ (position)=>callGeoCb(cb1, position, true), (error)=>callGeoCb(cb2, error, true), options ])); } else { // Not allowed, we don't install a real watch, just return the position once this.getCurrentPosition(cb1, cb2, options); } })(); return handler; }; navigator.geolocation.clearWatch = function(handler) { if (handlers.has(handler)) { clearWatch.apply(navigator.geolocation, [ handlers.get(handler) ]); handlers.delete(handler); } }; }; const inFrame = window !== window.top; async function isWatchAllowed() { // Returns true if using the real watch is allowed. Only if paused or level == 'real'. // Also don't allow in iframes (to simplify the code). const level = await getStoredValueAsync('defaultLevel'); // TODO: per domain level const paused = await getStoredValueAsync('paused'); return !inFrame && (paused || level === 'real'); } async function getNoisyPosition(opt) { // const domain = window.location.hostname; // TODO: per domain level const level = await getStoredValueAsync('defaultLevel'); const paused = await getStoredValueAsync('paused'); if (!paused && level === 'fixed') { const fixedPos = await getStoredValueAsync('fixedPos'); const noisy = { coords: { latitude: fixedPos.latitude, longitude: fixedPos.longitude, accuracy: 10, altitude: isMobileDevice() ? randomInt(10, 100) : null, altitudeAccuracy: isMobileDevice() ? 10 : null, heading: isMobileDevice() ? randomInt(0, 360) : null, speed: null }, timestamp: Date.now() }; return { success: true, position: noisy }; } return new Promise((resolve)=>{ // we call getCurrentPosition here in the content script, instead of // inside the page, because the content-script/page communication is not secure // getCurrentPosition.apply(navigator.geolocation, [ async function(position) { // clone, modifying/sending the native object returns error const noisy = await addNoise(klona(position)); resolve({ success: true, position: noisy }); }, function(error) { resolve({ success: false, position: klona(error) }); // clone, sending the native object returns error }, opt ]); }); } // gets position, returs noisy version based on the privacy options // async function addNoise(position) { const paused = await getStoredValueAsync('paused'); // TODO: per domain level const level = await getStoredValueAsync('defaultLevel'); if (paused || level === 'real') ; else if (level === 'fixed') { const fixedPos = await getStoredValueAsync('fixedPos'); position.coords = { latitude: fixedPos.latitude, longitude: fixedPos.longitude, accuracy: 10, altitude: isMobileDevice() ? randomInt(10, 100) : null, altitudeAccuracy: isMobileDevice() ? 10 : null, heading: isMobileDevice() ? randomInt(0, 360) : null, speed: null }; } else { const cachedPos = await getStoredValueAsync('cachedPos'); const storedEpsilon = await getStoredValueAsync('epsilon'); const levels = await getStoredValueAsync('levels'); if ('level' in cachedPos && cachedPos[level] && (Date.now() - cachedPos[level].epoch) / 60000 < cachedPos[level].cacheTime) { position = cachedPos[level].position; console.log('using cached', position); } else { // add noise const epsilon = storedEpsilon / levels[level].radius; const noisy = PlanarLaplace.addNoise(epsilon, position.coords); position.coords.latitude = noisy.latitude; position.coords.longitude = noisy.longitude; // update accuracy if (position.coords.accuracy && await getStoredValueAsync('updateAccuracy')) { position.coords.accuracy += Math.round(PlanarLaplace.alphaDeltaAccuracy(epsilon, .9)); } // don't know how to add noise to those, so we set to null (they're most likely null anyway) position.coords.altitude = null; position.coords.altitudeAccuracy = null; position.coords.heading = null; position.coords.speed = null; // cache cachedPos[level] = { epoch: Date.now(), position, cacheTime: levels[level].cacheTime }; await setStoredValueAsync('cachedPos', cachedPos); console.log('noisy coords', position.coords); } } // return noisy position return position; }const renderConfigUI = async ()=>{ const $locationGuard = { ready: true, PlanarLaplace, epsilon: await getStoredValueAsync('epsilon'), emptyCachedPos () { return setStoredValueAsync('cachedPos', {}); }, setValue: setStoredValueAsync, getValue: getStoredValueAsync, async resetConfig () { const keys = Object.keys(DEFAULT_VALUE); await Promise.all(keys.map((key)=>setStoredValueAsync(key, DEFAULT_VALUE[key]))); } }; Object.defineProperty(unsafeWindow, '$locationGuard', { value: $locationGuard, enumerable: true, writable: false }); unsafeWindow.dispatchEvent(new CustomEvent('location-guard-config-ui-ready')); };(()=>{ spoofLocation(); if ('registerMenuCommand' in GM && typeof GM.registerMenuCommand === 'function') { GM.registerMenuCommand('Configuration', ()=>{ const a = document.createElement('a'); a.href = 'https://location-guard-ng.skk.moe/options'; a.target = '_blank'; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); }); } if (window.location.host === 'localhost:3000' || window.location.host === 'location-guard-ng.skk.moe') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', renderConfigUI); } else { renderConfigUI(); } } })();})();