From b09a8248af56c8c59dff5e797cfa8d68272336ac Mon Sep 17 00:00:00 2001 From: sal Date: Sun, 1 Mar 2026 15:49:40 -0600 Subject: [PATCH] Add font enumeration hardening, document.fonts protection, Client Hints stripping, and WebGL parameter normalization - Font enumeration: seeded noise on offsetWidth/Height, scrollWidth/Height, clientWidth/Height - document.fonts: check() returns false, size returns 0, forEach is no-op - Client Hints: strip Sec-CH-UA/Full-Version-List, override Platform/Mobile per container - WebGL: merge PARAM_OVERRIDES into getParameter (MAX_TEXTURE_SIZE, attribs, etc.) - Clean up dead code in WebGL extended section - Test page: add Font DOM, document.fonts, and Client Hints test sections - README: update vector table (18 vectors), add about:config and testing docs --- README.md | 53 ++++++++++--- background.js | 18 ++++- inject.js | 88 +++++++++++++++------- test/fingerprint-test.html | 147 +++++++++++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3377112..930b692 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Every website you visit is automatically placed in its own isolated container wi - **Unique fingerprints per container** — every container presents a completely different device to websites - **Auth-aware** — login redirects (e.g. YouTube to Google) stay in the originating container so authentication works seamlessly - **Cross-site navigation** — clicking a link to a different domain automatically switches to the correct container +- **HTTP header spoofing** — User-Agent, Accept-Language, and Client Hints headers match each container's identity - **Configurable** — toggle individual fingerprint vectors, whitelist domains, manage containers from the options page - **Zero configuration** — install and browse, everything is automatic @@ -18,17 +19,23 @@ Every website you visit is automatically placed in its own isolated container wi | Vector | Method | |---|---| | Canvas | Deterministic pixel noise per container seed | -| WebGL | Spoofed GPU vendor and renderer strings | +| WebGL | Spoofed GPU vendor, renderer, max parameters, and normalized extensions | | AudioContext | Seeded noise on frequency and channel data | -| Navigator | CPU cores, platform, languages, device memory | +| Navigator | CPU cores, platform, languages, device memory, oscpu | | Screen | Resolution, color depth, window dimensions | | Timezone | getTimezoneOffset, Date.toString, Intl.DateTimeFormat | | WebRTC | Forced relay-only ICE policy (blocks local IP leak) | -| Fonts | Noise on measureText (prevents font enumeration) | +| Fonts | Noise on measureText + DOM element dimensions (offsetWidth/Height etc.) | +| Font API | document.fonts.check() blocked, size reports 0 | | ClientRects | Sub-pixel noise on getBoundingClientRect | | Plugins | Reports empty | | Battery | Always reports full/charging | | Connection | Fixed network profile | +| HTTP Headers | User-Agent, Accept-Language spoofed per container; Client Hints stripped | +| Speech Synthesis | getVoices() returns empty, voiceschanged suppressed | +| matchMedia | Screen dimension queries return spoofed values | +| Performance | performance.now() precision reduced to 0.1ms | +| Storage | navigator.storage.estimate() returns generic values | ## How it works @@ -47,15 +54,19 @@ Background Script ├── Auto-creates containers per domain (contextualIdentities API) ├── Generates deterministic fingerprint from seed (Mulberry32 PRNG) ├── Registers per-container content scripts (contentScripts.register + cookieStoreId) - └── Intercepts navigation to assign tabs to containers + ├── Intercepts navigation to assign tabs to containers + └── Spoofs HTTP headers (User-Agent, Accept-Language, Client Hints) per container Content Script (per container, ISOLATED world, document_start) └── Uses exportFunction() + wrappedJSObject to override page APIs ├── Canvas, WebGL, AudioContext prototypes - ├── Navigator, Screen properties + ├── Navigator, Screen, Performance properties ├── Timezone (Date, Intl.DateTimeFormat) ├── WebRTC (RTCPeerConnection) - └── Font metrics, ClientRects, Battery, Connection + ├── Font metrics (measureText, DOM dimensions, document.fonts) + ├── ClientRects, Battery, Connection, Storage + ├── Speech synthesis, matchMedia + └── Plugins, mimeTypes ``` Uses Firefox's `exportFunction()` API to inject overrides from the isolated content script world directly into the page context. This bypasses Content Security Policy restrictions that block inline script injection. @@ -92,7 +103,7 @@ Right-click the toolbar icon → **Manage Extension** → **Preferences** to ope ### Fingerprint Vectors -Toggle individual spoofing vectors on or off globally. All 12 vectors can be independently controlled: +Toggle individual spoofing vectors on or off globally. Vectors can be independently controlled: Canvas, WebGL, Audio, Navigator, Screen, Timezone, WebRTC, Fonts, Client Rects, Plugins, Battery, Connection @@ -113,12 +124,34 @@ Full table of all managed containers with per-container controls: - Firefox 100+ or LibreWolf - Containers must be enabled (`privacy.userContext.enabled = true` in `about:config`) +### Recommended about:config settings + +For maximum WebRTC leak protection, set these in `about:config`: + +| Setting | Value | Purpose | +|---|---|---| +| `media.peerconnection.ice.default_address_only` | `true` | Only use default route for ICE | +| `media.peerconnection.ice.no_host` | `true` | Prevent host candidate gathering | +| `media.peerconnection.ice.proxy_only_if_behind_proxy` | `true` | Force proxy-only mode | + +LibreWolf may already have some of these set by default. + +## Testing + +A built-in test page is included at `test/fingerprint-test.html`. To use it: + +1. Load the extension via `about:debugging` +2. Add a hostname alias (e.g. `127.0.0.1 containsite-test.site` in `/etc/hosts`) — localhost is excluded from containerization +3. Start a local server: `python3 -m http.server 8888 --bind 0.0.0.0` +4. Open `http://containsite-test.site:8888/test/fingerprint-test.html` in a regular (non-private) window +5. Open the same URL in a different container tab and compare composite hashes + ## File structure ``` manifest.json MV2 extension manifest -background.js Container management, navigation interception, script registration -inject.js Fingerprint overrides (exportFunction-based) +background.js Container management, navigation, HTTP header spoofing +inject.js Fingerprint overrides (exportFunction-based, 18 vectors) lib/ prng.js Mulberry32 seeded PRNG fingerprint-gen.js Deterministic seed → device profile generator @@ -130,6 +163,8 @@ options/ options.html Full options page (opens in tab) options.css Styles options.js Vector toggles, whitelist, container management +test/ + fingerprint-test.html Comprehensive fingerprint verification page icons/ icon-48.png Toolbar icon icon-96.png Extension icon diff --git a/background.js b/background.js index 4024fbc..180bc10 100644 --- a/background.js +++ b/background.js @@ -91,7 +91,8 @@ async function buildProfileAndRegister(cookieStoreId, seed) { // Cache profile for HTTP header spoofing containerProfiles[cookieStoreId] = { userAgent: profile.nav.userAgent, - languages: profile.nav.languages + languages: profile.nav.languages, + platform: profile.nav.platform }; await registerForContainer(cookieStoreId, profile); @@ -450,12 +451,25 @@ browser.webRequest.onBeforeSendHeaders.addListener( if (!profile) return {}; const headers = details.requestHeaders; - for (let i = 0; i < headers.length; i++) { + // Map platform to Client Hints platform name + const platformMap = { + "Win32": "Windows", "Linux x86_64": "Linux", "MacIntel": "macOS" + }; + const chPlatform = platformMap[profile.platform] || "Unknown"; + + for (let i = headers.length - 1; i >= 0; 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); + } else if (name === "sec-ch-ua" || name === "sec-ch-ua-full-version-list") { + // Firefox doesn't normally send these, but strip if present + headers.splice(i, 1); + } else if (name === "sec-ch-ua-platform") { + headers[i].value = `"${chPlatform}"`; + } else if (name === "sec-ch-ua-mobile") { + headers[i].value = "?0"; } } return { requestHeaders: headers }; diff --git a/inject.js b/inject.js index 3d7bb66..13e122c 100644 --- a/inject.js +++ b/inject.js @@ -92,6 +92,20 @@ const UNMASKED_VENDOR = 0x9245; const UNMASKED_RENDERER = 0x9246; + // Normalize key max parameters to common values to prevent GPU fingerprinting + 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 + }; + function patchWebGL(protoName) { const pageProto = pageWindow[protoName]; if (!pageProto) return; @@ -102,6 +116,11 @@ exportFunction(function(pname) { if (pname === UNMASKED_VENDOR) return CONFIG.webgl.vendor; if (pname === UNMASKED_RENDERER) return CONFIG.webgl.renderer; + if (PARAM_OVERRIDES[pname] !== undefined) { + // Return the normalized value, but never exceed the real GPU's capability + const real = origGetParam.call(this, pname); + return (typeof real === "number") ? Math.min(real, PARAM_OVERRIDES[pname]) : real; + } return origGetParam.call(this, pname); }, pageProto.prototype, { defineAs: "getParameter" }); } @@ -443,6 +462,44 @@ return metrics; }, pageWindow.CanvasRenderingContext2D.prototype, { defineAs: "measureText" }); + + // --- DOM Element Dimension Noise --- + // Font enumeration measures offsetWidth/Height of test spans to detect installed fonts. + // Adding seeded noise prevents consistent dimension-based font fingerprinting. + const fontDimProps = ["offsetWidth", "offsetHeight", "scrollWidth", "scrollHeight", "clientWidth", "clientHeight"]; + for (const prop of fontDimProps) { + const origDesc = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, prop); + if (origDesc && origDesc.get) { + const origGet = origDesc.get; + Object.defineProperty(pageWindow.HTMLElement.prototype, prop, { + get: exportFunction(function() { + const val = origGet.call(this); + return val + (fontRng() - 0.5) * 0.3; + }, pageWindow), + configurable: true, + enumerable: true + }); + } + } + + // --- document.fonts (FontFaceSet) API Protection --- + // document.fonts.check() directly reveals installed fonts; size/iterators expose count. + if (pageWindow.document.fonts) { + try { + Object.defineProperty(pageWindow.document.fonts, "check", { + value: exportFunction(function() { return false; }, pageWindow), + configurable: true, enumerable: true + }); + Object.defineProperty(pageWindow.document.fonts, "size", { + get: exportFunction(function() { return 0; }, pageWindow), + configurable: true, enumerable: true + }); + Object.defineProperty(pageWindow.document.fonts, "forEach", { + value: exportFunction(function() {}, pageWindow), + configurable: true, enumerable: true + }); + } catch(e) {} + } } // ========================================================================= @@ -580,17 +637,15 @@ // ========================================================================= // WEBGL EXTENDED FINGERPRINT PROTECTION // ========================================================================= - // Beyond vendor/renderer, WebGL exposes max parameters and extensions - // that vary per GPU and can be used for fingerprinting. + // Normalize getSupportedExtensions to a common baseline set if (vectorEnabled("webgl")) { - function patchWebGLExtended(protoName) { + function patchWebGLExtensions(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", @@ -606,34 +661,13 @@ 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"); + patchWebGLExtensions("WebGLRenderingContext"); + patchWebGLExtensions("WebGL2RenderingContext"); } // ========================================================================= diff --git a/test/fingerprint-test.html b/test/fingerprint-test.html index 635fc09..841decd 100644 --- a/test/fingerprint-test.html +++ b/test/fingerprint-test.html @@ -182,10 +182,29 @@
+ +
+

Font Enumeration (DOM Dimensions)

+
+
+ + +
+

document.fonts API

+
+
+ + +
+

Client Hints Headers

+
+
+ +