location-guard
Version:
A UserScript that hide/spoof your geographic location from websites.
436 lines (429 loc) • 16.8 kB
JavaScript
// ==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();
}
}
})();})();