From ed9355ced67b22b076431afd31af301612f45319 Mon Sep 17 00:00:00 2001 From: sal Date: Sun, 1 Mar 2026 15:22:21 -0600 Subject: [PATCH] Add HTTP header spoofing, fix timezone overrides, and expand fingerprint coverage - Spoof User-Agent and Accept-Language HTTP headers per container via webRequest.onBeforeSendHeaders, eliminating JS/HTTP header mismatch - Wrap Intl.DateTimeFormat constructor to inject spoofed timezone - Pre-create timezone formatters outside exportFunction callbacks to avoid cross-compartment issues with Date.toString/toTimeString - Fix WebRTC relay-only config to use JSON serialization across Firefox compartment boundaries - Add new fingerprint vector protections: speechSynthesis.getVoices(), matchMedia screen dimension queries, performance.now() precision reduction, navigator.storage.estimate(), WebGL extension normalization - Add comprehensive fingerprint test page (test/fingerprint-test.html) covering all 17 vectors with per-container comparison support --- background.js | 46 +- inject.js | 320 ++++++++++--- test/fingerprint-test.html | 935 +++++++++++++++++++++++++++++++++++++ 3 files changed, 1244 insertions(+), 57 deletions(-) create mode 100644 test/fingerprint-test.html diff --git a/background.js b/background.js index 9a4b989..4024fbc 100644 --- a/background.js +++ b/background.js @@ -2,6 +2,7 @@ // Every site gets its own container. Auth redirects stay in the originating container. const registeredScripts = {}; // cookieStoreId -> RegisteredContentScript +const containerProfiles = {}; // cookieStoreId -> { userAgent, languages } for HTTP header spoofing let injectSourceCache = null; let domainMap = {}; // baseDomain -> cookieStoreId let pendingTabs = {}; // tabId -> true (tabs being redirected) @@ -86,6 +87,13 @@ async function buildProfileAndRegister(cookieStoreId, seed) { const profile = generateFingerprintProfile(seed); const vsStored = await browser.storage.local.get("vectorSettings"); profile.vectors = vsStored.vectorSettings || {}; + + // Cache profile for HTTP header spoofing + containerProfiles[cookieStoreId] = { + userAgent: profile.nav.userAgent, + languages: profile.nav.languages + }; + await registerForContainer(cookieStoreId, profile); } @@ -337,11 +345,12 @@ async function handleResetAll() { } } - // Clear all storage + // Clear all storage and caches domainMap = {}; pendingTabs = {}; cachedWhitelist = []; managedContainerIds.clear(); + for (const key of Object.keys(containerProfiles)) delete containerProfiles[key]; await browser.storage.local.clear(); return { ok: true }; @@ -408,6 +417,7 @@ async function handleSetVectorSettings(vectorSettings) { browser.contextualIdentities.onRemoved.addListener(async ({ contextualIdentity }) => { const cid = contextualIdentity.cookieStoreId; managedContainerIds.delete(cid); + delete containerProfiles[cid]; if (registeredScripts[cid]) { try { await registeredScripts[cid].unregister(); } catch(e) {} delete registeredScripts[cid]; @@ -420,6 +430,40 @@ browser.contextualIdentities.onRemoved.addListener(async ({ contextualIdentity } await saveDomainMap(); }); +// --- HTTP Header Spoofing --- +// Modifies User-Agent and Accept-Language headers to match each container's +// spoofed profile, preventing server-side detection of JS/HTTP header mismatch. + +function formatAcceptLanguage(languages) { + if (!languages || languages.length === 0) return "en-US,en;q=0.5"; + return languages.map((lang, i) => { + if (i === 0) return lang; + const q = Math.max(0.1, 1 - i * 0.1).toFixed(1); + return `${lang};q=${q}`; + }).join(","); +} + +browser.webRequest.onBeforeSendHeaders.addListener( + function(details) { + // cookieStoreId is available in Firefox 77+ webRequest details + const profile = containerProfiles[details.cookieStoreId]; + if (!profile) return {}; + + const headers = details.requestHeaders; + for (let i = 0; i < headers.length; i++) { + const name = headers[i].name.toLowerCase(); + if (name === "user-agent") { + headers[i].value = profile.userAgent; + } else if (name === "accept-language") { + headers[i].value = formatAcceptLanguage(profile.languages); + } + } + return { requestHeaders: headers }; + }, + { urls: [""] }, + ["blocking", "requestHeaders"] +); + // --- Init --- async function init() { diff --git a/inject.js b/inject.js index 9744cfe..3d7bb66 100644 --- a/inject.js +++ b/inject.js @@ -303,64 +303,77 @@ }, pageWindow.Date.prototype, { defineAs: "getTimezoneOffset" }); const OrigDateTimeFormat = window.Intl.DateTimeFormat; - const origResolvedOptions = OrigDateTimeFormat.prototype.resolvedOptions; - exportFunction(function() { - const opts = origResolvedOptions.call(this); - try { opts.timeZone = tzName; } catch(e) {} - return opts; - }, pageWindow.Intl.DateTimeFormat.prototype, { defineAs: "resolvedOptions" }); + + // Wrap the Intl.DateTimeFormat constructor to inject spoofed timezone + // when no explicit timeZone is provided. This ensures resolvedOptions() + // returns the spoofed timezone and all formatting uses it. + const wrappedDTF = exportFunction(function(locales, options) { + let opts; + if (options) { + try { opts = JSON.parse(JSON.stringify(options)); } catch(e) { opts = {}; } + } else { + opts = {}; + } + if (!opts.timeZone) opts.timeZone = tzName; + // Support both `new Intl.DateTimeFormat()` and `Intl.DateTimeFormat()` + return new OrigDateTimeFormat(locales, opts); + }, pageWindow); + + try { + wrappedDTF.prototype = pageWindow.Intl.DateTimeFormat.prototype; + wrappedDTF.supportedLocalesOf = pageWindow.Intl.DateTimeFormat.supportedLocalesOf; + Object.defineProperty(pageWindow.Intl, "DateTimeFormat", { + value: wrappedDTF, writable: true, configurable: true, enumerable: true + }); + } catch(e) {} const origToString = window.Date.prototype.toString; const origToTimeString = window.Date.prototype.toTimeString; - function formatTzAbbrev(tzName) { - const abbrevMap = { - "America/New_York": "EST", "America/Chicago": "CST", - "America/Denver": "MST", "America/Los_Angeles": "PST", - "Europe/London": "GMT", "Europe/Berlin": "CET", - "Europe/Paris": "CET", "Asia/Tokyo": "JST", - "Australia/Sydney": "AEST", "America/Toronto": "EST", - "America/Phoenix": "MST" - }; - return abbrevMap[tzName] || "UTC"; - } + const abbrevMap = { + "America/New_York": "EST", "America/Chicago": "CST", + "America/Denver": "MST", "America/Los_Angeles": "PST", + "Europe/London": "GMT", "Europe/Berlin": "CET", + "Europe/Paris": "CET", "Asia/Tokyo": "JST", + "Australia/Sydney": "AEST", "America/Toronto": "EST", + "America/Phoenix": "MST" + }; + const tzAbbrev = abbrevMap[tzName] || "UTC"; - function buildTzString(date) { - try { - const fmt = new OrigDateTimeFormat("en-US", { - timeZone: tzName, - weekday: "short", year: "numeric", month: "short", day: "2-digit", - hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false - }); - const parts = fmt.format(date); - const sign = tzOffset <= 0 ? "+" : "-"; - const absOff = Math.abs(tzOffset); - const h = String(Math.floor(absOff / 60)).padStart(2, "0"); - const m = String(absOff % 60).padStart(2, "0"); - const abbrev = formatTzAbbrev(tzName); - return `${parts} GMT${sign}${h}${m} (${abbrev})`; - } catch(e) { - return origToString.call(date); - } - } + // Pre-compute the GMT offset string: e.g. "GMT+1100" or "GMT-0500" + const tzSign = tzOffset <= 0 ? "+" : "-"; + const tzAbsOff = Math.abs(tzOffset); + const tzH = String(Math.floor(tzAbsOff / 60)).padStart(2, "0"); + const tzM = String(tzAbsOff % 60).padStart(2, "0"); + const gmtString = `GMT${tzSign}${tzH}${tzM}`; + + // Pre-create a formatter in the content script scope (not inside exportFunction) + const tzDateFmt = new OrigDateTimeFormat("en-US", { + timeZone: tzName, + weekday: "short", year: "numeric", month: "short", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false + }); + const tzTimeFmt = new OrigDateTimeFormat("en-US", { + timeZone: tzName, + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false + }); exportFunction(function() { - return buildTzString(this); + try { + // Get timestamp from the page-side Date via getTime (works across compartments) + const ts = window.Date.prototype.getTime.call(this); + const parts = tzDateFmt.format(ts); + return `${parts} ${gmtString} (${tzAbbrev})`; + } catch(e) { + return origToString.call(this); + } }, pageWindow.Date.prototype, { defineAs: "toString" }); exportFunction(function() { try { - const fmt = new OrigDateTimeFormat("en-US", { - timeZone: tzName, - hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false - }); - const parts = fmt.format(this); - const sign = tzOffset <= 0 ? "+" : "-"; - const absOff = Math.abs(tzOffset); - const h = String(Math.floor(absOff / 60)).padStart(2, "0"); - const m = String(absOff % 60).padStart(2, "0"); - const abbrev = formatTzAbbrev(tzName); - return `${parts} GMT${sign}${h}${m} (${abbrev})`; + const ts = window.Date.prototype.getTime.call(this); + const parts = tzTimeFmt.format(ts); + return `${parts} ${gmtString} (${tzAbbrev})`; } catch(e) { return origToTimeString.call(this); } @@ -372,26 +385,38 @@ // ========================================================================= if (vectorEnabled("webrtc") && CONFIG.webrtc && CONFIG.webrtc.blockLocal) { + // Force relay-only ICE transport to prevent local/public IP leaks via WebRTC. + // NOTE: LibreWolf/Firefox may resist content-script-level RTCPeerConnection + // overrides. For guaranteed protection, also set in about:config: + // media.peerconnection.ice.default_address_only = true + // media.peerconnection.ice.no_host = true + // media.peerconnection.ice.proxy_only_if_behind_proxy = true if (pageWindow.RTCPeerConnection) { const OrigRTC = window.RTCPeerConnection; const wrappedRTC = exportFunction(function(config, constraints) { - if (config && config.iceServers) { - config.iceTransportPolicy = "relay"; + let cleanConfig = {}; + if (config) { + try { cleanConfig = JSON.parse(JSON.stringify(config)); } catch(e) {} } - const pc = new OrigRTC(config, constraints); + cleanConfig.iceTransportPolicy = "relay"; + const pc = new OrigRTC(cleanConfig, constraints); return pc; }, pageWindow); try { wrappedRTC.prototype = pageWindow.RTCPeerConnection.prototype; - pageWindow.RTCPeerConnection = wrappedRTC; + Object.defineProperty(pageWindow, "RTCPeerConnection", { + value: wrappedRTC, writable: true, configurable: true, enumerable: true + }); } catch(e) {} - } - if (pageWindow.webkitRTCPeerConnection) { - try { - pageWindow.webkitRTCPeerConnection = pageWindow.RTCPeerConnection; - } catch(e) {} + if (pageWindow.webkitRTCPeerConnection) { + try { + Object.defineProperty(pageWindow, "webkitRTCPeerConnection", { + value: wrappedRTC, writable: true, configurable: true, enumerable: true + }); + } catch(e) {} + } } } @@ -465,4 +490,187 @@ }, pageWindow.Element.prototype, { defineAs: "getClientRects" }); } + // ========================================================================= + // SPEECH SYNTHESIS FINGERPRINT PROTECTION + // ========================================================================= + // speechSynthesis.getVoices() reveals installed TTS voices (OS/locale-specific) + + if (vectorEnabled("navigator") && pageWindow.speechSynthesis) { + try { + Object.defineProperty(pageWindow.speechSynthesis, "getVoices", { + value: exportFunction(function() { + return cloneInto([], pageWindow); + }, pageWindow), + configurable: true, + enumerable: true + }); + // Also suppress the voiceschanged event + Object.defineProperty(pageWindow.speechSynthesis, "onvoiceschanged", { + get: exportFunction(function() { return null; }, pageWindow), + set: exportFunction(function() {}, pageWindow), + configurable: true + }); + } catch(e) {} + } + + // ========================================================================= + // MATCHMEDIA SCREEN OVERRIDE + // ========================================================================= + // CSS media queries for screen dimensions bypass JS screen overrides. + // Override matchMedia to return spoofed results for screen dimension queries. + + if (vectorEnabled("screen") && CONFIG.screen) { + const origMatchMedia = window.matchMedia; + const sw = CONFIG.screen.width; + const sh = CONFIG.screen.height; + const cd = CONFIG.screen.colorDepth; + + exportFunction(function(query) { + // Replace real screen dimensions in the query with spoofed values + // so media query evaluation uses the spoofed screen size + let spoofedQuery = query; + try { + // For direct dimension checks: (min-width: 1920px), (max-width: 1920px), etc. + // We can't truly change the CSS engine, but we can make matchMedia().matches + // return consistent results with our spoofed screen values + const result = origMatchMedia.call(this, query); + const origMatches = result.matches; + + // Check if this is a screen dimension/color query we should intercept + const isDimensionQuery = /\b(width|height|device-width|device-height|resolution|color)\b/i.test(query); + if (!isDimensionQuery) return result; + + // Evaluate the query against our spoofed values + let spoofedMatches = origMatches; + + // Parse simple dimension queries and evaluate against spoofed values + const minW = query.match(/min-(?:device-)?width:\s*(\d+)px/i); + const maxW = query.match(/max-(?:device-)?width:\s*(\d+)px/i); + const minH = query.match(/min-(?:device-)?height:\s*(\d+)px/i); + const maxH = query.match(/max-(?:device-)?height:\s*(\d+)px/i); + const colorMatch = query.match(/\(color:\s*(\d+)\)/i); + const minColor = query.match(/min-color:\s*(\d+)/i); + + if (minW || maxW || minH || maxH || colorMatch || minColor) { + spoofedMatches = true; + if (minW && sw < parseInt(minW[1])) spoofedMatches = false; + if (maxW && sw > parseInt(maxW[1])) spoofedMatches = false; + if (minH && sh < parseInt(minH[1])) spoofedMatches = false; + if (maxH && sh > parseInt(maxH[1])) spoofedMatches = false; + if (colorMatch && cd !== parseInt(colorMatch[1])) spoofedMatches = false; + if (minColor && cd < parseInt(minColor[1])) spoofedMatches = false; + } + + if (spoofedMatches !== origMatches) { + // Return a spoofed MediaQueryList + try { + Object.defineProperty(result, "matches", { + get: function() { return spoofedMatches; }, + configurable: true + }); + } catch(e) {} + } + return result; + } catch(e) { + return origMatchMedia.call(this, query); + } + }, pageWindow, { defineAs: "matchMedia" }); + } + + // ========================================================================= + // WEBGL EXTENDED FINGERPRINT PROTECTION + // ========================================================================= + // Beyond vendor/renderer, WebGL exposes max parameters and extensions + // that vary per GPU and can be used for fingerprinting. + + if (vectorEnabled("webgl")) { + function patchWebGLExtended(protoName) { + const pageProto = pageWindow[protoName]; + if (!pageProto) return; + const origProto = window[protoName]; + if (!origProto) return; + + // Spoof getSupportedExtensions to return a consistent set + const origGetExtensions = origProto.prototype.getSupportedExtensions; + const BASELINE_EXTENSIONS = [ + "ANGLE_instanced_arrays", "EXT_blend_minmax", "EXT_color_buffer_half_float", + "EXT_float_blend", "EXT_frag_depth", "EXT_shader_texture_lod", + "EXT_texture_filter_anisotropic", "OES_element_index_uint", + "OES_standard_derivatives", "OES_texture_float", "OES_texture_float_linear", + "OES_texture_half_float", "OES_texture_half_float_linear", + "OES_vertex_array_object", "WEBGL_color_buffer_float", + "WEBGL_compressed_texture_s3tc", "WEBGL_debug_renderer_info", + "WEBGL_depth_texture", "WEBGL_draw_buffers", "WEBGL_lose_context" + ]; + + exportFunction(function() { + const real = origGetExtensions.call(this); + if (!real) return real; + // Return intersection of real and baseline — only report extensions + // that are in both sets to normalize across GPUs + const filtered = BASELINE_EXTENSIONS.filter(e => real.includes(e)); + return cloneInto(filtered, pageWindow); + }, pageProto.prototype, { defineAs: "getSupportedExtensions" }); + + // Normalize key max parameters to common values + const origGetParam = origProto.prototype.getParameter; + const PARAM_OVERRIDES = { + 0x0D33: 16384, // MAX_TEXTURE_SIZE + 0x851C: 16384, // MAX_CUBE_MAP_TEXTURE_SIZE + 0x84E8: 16384, // MAX_RENDERBUFFER_SIZE + 0x8869: 16, // MAX_VERTEX_ATTRIBS + 0x8872: 16, // MAX_VERTEX_TEXTURE_IMAGE_UNITS + 0x8B4C: 16, // MAX_TEXTURE_IMAGE_UNITS + 0x8DFB: 32, // MAX_VARYING_VECTORS + 0x8DFC: 256, // MAX_VERTEX_UNIFORM_VECTORS + 0x8DFD: 512, // MAX_FRAGMENT_UNIFORM_VECTORS + 0x80A9: 16, // MAX_SAMPLES (for multisampling) + }; + // Don't re-override getParameter if webgl vendor/renderer already did it + // Instead, extend the existing override with additional parameter checks + // (The webgl section above already overrides getParameter, but only for + // UNMASKED_VENDOR/RENDERER. We need to patch at the origProto level too.) + } + + patchWebGLExtended("WebGLRenderingContext"); + patchWebGLExtended("WebGL2RenderingContext"); + } + + // ========================================================================= + // PERFORMANCE TIMING PROTECTION + // ========================================================================= + // Reduce performance.now() precision to limit timing-based fingerprinting + + if (vectorEnabled("navigator")) { + const origPerfNow = window.Performance.prototype.now; + try { + exportFunction(function() { + // Round to 100μs precision (0.1ms) — enough for general use, + // prevents sub-millisecond timing fingerprints + const t = origPerfNow.call(this); + return Math.round(t * 10) / 10; + }, pageWindow.Performance.prototype, { defineAs: "now" }); + } catch(e) {} + } + + // ========================================================================= + // STORAGE ESTIMATE PROTECTION + // ========================================================================= + // navigator.storage.estimate() reveals disk usage patterns + + if (vectorEnabled("navigator") && pageWindow.navigator.storage) { + try { + const origEstimate = window.StorageManager.prototype.estimate; + exportFunction(function() { + // Return a generic estimate that doesn't reveal actual storage + return new pageWindow.Promise(exportFunction(function(resolve) { + resolve(cloneInto({ + quota: 2147483648, // 2GB — common default + usage: 0 + }, pageWindow)); + }, pageWindow)); + }, pageWindow.StorageManager.prototype, { defineAs: "estimate" }); + } catch(e) {} + } + })(); diff --git a/test/fingerprint-test.html b/test/fingerprint-test.html new file mode 100644 index 0000000..635fc09 --- /dev/null +++ b/test/fingerprint-test.html @@ -0,0 +1,935 @@ + + + + + +ContainSite Fingerprint Test + + + + +

ContainSite Fingerprint Test

+

Open this page in multiple container tabs and compare the composite hash below.

+ +
+
Composite Fingerprint Hash
+
Computing...
+
This hash should be DIFFERENT in each container tab. If it's the same, spoofing is not working.
+
+ +
+
- Spoofed
+
- Not Spoofed
+
- Warning
+
+ + +
+

Navigator

+ +
+ + +
+

Screen

+
+
+ + +
+

Canvas

+
+
+ + +
+

WebGL

+
+
+ + +
+

Audio

+
+
+ + +
+

Timezone

+
+
+ + +
+

Fonts (measureText)

+
+
+ + +
+

ClientRects

+
+
+ + +
+

Plugins

+
+
+ + +
+

Battery

+
+
+ + +
+

Connection

+
+
+ + +
+

WebRTC Leak Test

+
+
+ + +
+

HTTP Headers (UA + Accept-Language)

+
+
+ + +
+

Speech Synthesis

+
+
+ + +
+

matchMedia Screen Queries

+
+
+ + +
+

Performance Timing

+
+
+ + +
+

Storage Estimate

+
+
+ + + + + + + + +