From d6dabb26467e162482e00df899db273492e7cd68 Mon Sep 17 00:00:00 2001 From: sal Date: Wed, 4 Mar 2026 21:08:45 -0600 Subject: [PATCH] Add GamepadAPI, WebGL readPixels noise, auto-prune, import/export, badge New fingerprint vectors: - Gamepad API: getGamepads() returns empty (prevents controller fingerprinting) - WebGL readPixels: seeded pixel noise on framebuffer reads New features: - Auto-prune: configurable automatic removal of inactive containers - Import/export: backup and restore all settings from options page - Toolbar badge: shows active container count - CHANGELOG.md: version history Bumped to v0.5.0 with 20 spoofed fingerprint vectors. --- CHANGELOG.md | 46 ++++++++++++++++++ README.md | 6 ++- background.js | 97 +++++++++++++++++++++++++++++++++++++- inject.js | 40 ++++++++++++++++ manifest.json | 2 +- options/options.css | 7 +++ options/options.html | 26 ++++++++++ options/options.js | 64 ++++++++++++++++++++++++- test/fingerprint-test.html | 68 ++++++++++++++++++++++++++ 9 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..03be33d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +## 0.5.0 + +- Gamepad API spoofing (returns empty, prevents controller fingerprinting) +- WebGL readPixels noise (seeded pixel noise on framebuffer reads) +- Auto-prune: automatically remove inactive containers after configurable days +- Import/export all settings (seeds, whitelist, vector config) from options page +- Active container count badge on toolbar icon +- Updated test page with new vector sections + +## 0.4.1 + +- Skip all fingerprint overrides on Google auth domains to fix login rejection +- Keep auth redirects in originating container for session isolation +- Skip User-Agent spoofing on accounts.google.com and accounts.youtube.com + +## 0.4.0 + +- Added 6 new fingerprint vectors: Font API (document.fonts), DOM element dimensions, HTTP header spoofing (User-Agent, Accept-Language, Client Hints), Speech Synthesis, Performance Timing, Storage Estimate +- WebGL parameter normalization (MAX_TEXTURE_SIZE, MAX_VERTEX_ATTRIBS, etc.) +- Font enumeration hardening via offsetWidth/Height noise +- Total spoofed vectors: 18 + +## 0.3.0 + +- HTTP header spoofing: User-Agent and Accept-Language modified per container +- Client Hints header stripping (Sec-CH-UA, Sec-CH-UA-Platform) +- Speech synthesis protection (getVoices returns empty) +- matchMedia screen dimension override +- Performance.now() precision reduction +- navigator.storage.estimate() spoofing + +## 0.2.0 + +- Coherent device profiles with 3 archetypes (Windows, Linux, macOS) +- User-Agent spoofing with matching platform, oscpu, appVersion +- Added data_collection_permissions for AMO submission + +## 0.1.0 + +- Initial release +- Per-site container isolation with automatic domain detection +- 12 fingerprint vectors: Canvas, WebGL, Audio, Navigator, Screen, Timezone, WebRTC, Fonts, ClientRects, Plugins, Battery, Connection +- Popup UI with per-container toggle, regenerate, prune, reset +- Options page with vector toggles, domain whitelist, container management diff --git a/README.md b/README.md index 930b692..378e6ad 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Every website you visit is automatically placed in its own isolated container wi - **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 +- **Auto-prune** — automatically remove inactive containers after configurable days +- **Import/export** — backup and restore all settings, seeds, and whitelist - **Zero configuration** — install and browse, everything is automatic ## Fingerprint vectors protected @@ -36,6 +38,8 @@ Every website you visit is automatically placed in its own isolated container wi | matchMedia | Screen dimension queries return spoofed values | | Performance | performance.now() precision reduced to 0.1ms | | Storage | navigator.storage.estimate() returns generic values | +| Gamepad | navigator.getGamepads() returns empty | +| WebGL readPixels | Seeded noise on framebuffer reads | ## How it works @@ -151,7 +155,7 @@ A built-in test page is included at `test/fingerprint-test.html`. To use it: ``` manifest.json MV2 extension manifest background.js Container management, navigation, HTTP header spoofing -inject.js Fingerprint overrides (exportFunction-based, 18 vectors) +inject.js Fingerprint overrides (exportFunction-based, 20 vectors) lib/ prng.js Mulberry32 seeded PRNG fingerprint-gen.js Deterministic seed → device profile generator diff --git a/background.js b/background.js index 903f8b0..f4d63b8 100644 --- a/background.js +++ b/background.js @@ -155,6 +155,7 @@ async function getOrCreateContainerForDomain(baseDomain) { await browser.storage.local.set({ containerSeeds: seeds }); await buildProfileAndRegister(cookieStoreId, seeds[cookieStoreId]); + await updateBadge(); return cookieStoreId; } @@ -251,6 +252,14 @@ browser.tabs.onRemoved.addListener((tabId) => { delete pendingTabs[tabId]; }); +// --- Badge: show active container count --- + +async function updateBadge() { + const count = Object.keys(domainMap).length; + browser.browserAction.setBadgeText({ text: count > 0 ? String(count) : "" }); + browser.browserAction.setBadgeBackgroundColor({ color: "#4a9eff" }); +} + // --- Message Handling (from popup and options page) --- browser.runtime.onMessage.addListener((message, sender) => { @@ -265,6 +274,10 @@ browser.runtime.onMessage.addListener((message, sender) => { if (message.type === "setWhitelist") return handleSetWhitelist(message.whitelist); if (message.type === "getVectorSettings") return handleGetVectorSettings(); if (message.type === "setVectorSettings") return handleSetVectorSettings(message.vectorSettings); + if (message.type === "exportSettings") return handleExportSettings(); + if (message.type === "importSettings") return handleImportSettings(message.data); + if (message.type === "getAutoPruneSettings") return handleGetAutoPruneSettings(); + if (message.type === "setAutoPruneSettings") return handleSetAutoPruneSettings(message.settings); }); async function handleGetContainerList() { @@ -362,6 +375,7 @@ async function handleResetAll() { managedContainerIds.clear(); for (const key of Object.keys(containerProfiles)) delete containerProfiles[key]; await browser.storage.local.clear(); + await updateBadge(); return { ok: true }; } @@ -381,9 +395,9 @@ async function handlePruneContainers() { await browser.contextualIdentities.remove(c.cookieStoreId); pruned++; } catch(e) {} - // onRemoved listener handles domainMap + managedContainerIds cleanup } } + await updateBadge(); return { pruned }; } @@ -422,6 +436,82 @@ async function handleSetVectorSettings(vectorSettings) { return { ok: true }; } +// --- Import/Export --- + +async function handleExportSettings() { + const stored = await browser.storage.local.get(null); // get everything + return { + version: browser.runtime.getManifest().version, + exportedAt: new Date().toISOString(), + domainMap, + containerSeeds: stored.containerSeeds || {}, + containerSettings: stored.containerSettings || {}, + vectorSettings: stored.vectorSettings || {}, + whitelist: stored.whitelist || [], + autoPrune: stored.autoPrune || { enabled: false, days: 30 } + }; +} + +async function handleImportSettings(data) { + if (!data || !data.containerSeeds) return { ok: false, error: "Invalid data" }; + await browser.storage.local.set({ + containerSeeds: data.containerSeeds, + containerSettings: data.containerSettings || {}, + vectorSettings: data.vectorSettings || {}, + whitelist: data.whitelist || [], + autoPrune: data.autoPrune || { enabled: false, days: 30 } + }); + cachedWhitelist = data.whitelist || []; + if (data.domainMap) { + domainMap = data.domainMap; + await saveDomainMap(); + } + await registerAllKnownContainers(); + await updateBadge(); + return { ok: true }; +} + +// --- Auto-Prune --- + +async function handleGetAutoPruneSettings() { + const stored = await browser.storage.local.get("autoPrune"); + return stored.autoPrune || { enabled: false, days: 30 }; +} + +async function handleSetAutoPruneSettings(settings) { + await browser.storage.local.set({ autoPrune: settings }); + return { ok: true }; +} + +async function runAutoPrune() { + const stored = await browser.storage.local.get("autoPrune"); + const settings = stored.autoPrune || { enabled: false, days: 30 }; + if (!settings.enabled) return; + + const tabs = await browser.tabs.query({}); + const activeContainers = new Set(tabs.map(t => t.cookieStoreId)); + + // Track last activity per container + const actStored = await browser.storage.local.get("containerActivity"); + const activity = actStored.containerActivity || {}; + const now = Date.now(); + const cutoff = now - (settings.days * 24 * 60 * 60 * 1000); + + for (const cid of managedContainerIds) { + if (activeContainers.has(cid)) { + activity[cid] = now; // update last active + } else if (!activity[cid]) { + activity[cid] = now; // first seen, set to now + } else if (activity[cid] < cutoff) { + // Inactive beyond threshold — prune + try { + await browser.contextualIdentities.remove(cid); + } catch(e) {} + } + } + await browser.storage.local.set({ containerActivity: activity }); +} + // --- Container Lifecycle --- browser.contextualIdentities.onRemoved.addListener(async ({ contextualIdentity }) => { @@ -507,6 +597,11 @@ async function init() { } cachedWhitelist = stored.whitelist || []; await registerAllKnownContainers(); + await updateBadge(); + + // Run auto-prune on startup and every 6 hours + await runAutoPrune(); + setInterval(runAutoPrune, 6 * 60 * 60 * 1000); } init(); diff --git a/inject.js b/inject.js index 1bce09b..ba387c4 100644 --- a/inject.js +++ b/inject.js @@ -675,6 +675,46 @@ patchWebGLExtensions("WebGLRenderingContext"); patchWebGLExtensions("WebGL2RenderingContext"); + + // --- readPixels noise --- + // Like canvas noise, adds tiny seeded perturbation to WebGL framebuffer reads + function patchWebGLReadPixels(protoName) { + const pageProto = pageWindow[protoName]; + if (!pageProto) return; + const origProto = window[protoName]; + if (!origProto) return; + + const origReadPixels = origProto.prototype.readPixels; + exportFunction(function(x, y, width, height, format, type, pixels) { + origReadPixels.call(this, x, y, width, height, format, type, pixels); + if (pixels && pixels.length > 0 && CONFIG.canvasSeed) { + const rng = mulberry32(CONFIG.canvasSeed); + for (let i = 0; i < pixels.length; i += 4) { + if (rng() < 0.1) { + const ch = (rng() * 3) | 0; + const delta = rng() < 0.5 ? -1 : 1; + pixels[i + ch] = Math.max(0, Math.min(255, pixels[i + ch] + delta)); + } + } + } + }, pageProto.prototype, { defineAs: "readPixels" }); + } + + patchWebGLReadPixels("WebGLRenderingContext"); + patchWebGLReadPixels("WebGL2RenderingContext"); + } + + // ========================================================================= + // GAMEPAD API PROTECTION + // ========================================================================= + // navigator.getGamepads() reveals connected game controllers (count, IDs) + + if (vectorEnabled("navigator") && pageWindow.navigator.getGamepads) { + try { + exportFunction(function() { + return cloneInto([null, null, null, null], pageWindow); + }, pageWindow.Navigator.prototype, { defineAs: "getGamepads" }); + } catch(e) {} } // ========================================================================= diff --git a/manifest.json b/manifest.json index 2d3c68f..1062d5d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "ContainSite", - "version": "0.4.1", + "version": "0.5.0", "description": "Per-container fingerprint isolation — each container gets its own device identity", "permissions": [ "contextualIdentities", diff --git a/options/options.css b/options/options.css index 3eb9fae..742d2b9 100644 --- a/options/options.css +++ b/options/options.css @@ -64,6 +64,13 @@ tr:hover td { background: #2a2a3a; } .td-actions { display: flex; gap: 4px; justify-content: flex-end; } +/* Auto-prune */ +.auto-prune-row { display: flex; align-items: center; gap: 16px; } +.toggle-label { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; } +.prune-days { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aaa; } +.days-input { width: 50px; padding: 4px 6px; background: #2a2a3a; border: 1px solid #444; border-radius: 4px; color: #e0e0e0; font-size: 12px; text-align: center; } +.days-input:focus { border-color: #4a9eff; outline: none; } + /* Bulk actions */ .bulk { display: flex; flex-direction: column; gap: 6px; } .bulk #regen-all { width: 100%; } diff --git a/options/options.html b/options/options.html index 1d11441..cc55366 100644 --- a/options/options.html +++ b/options/options.html @@ -46,6 +46,32 @@ +
+

Auto-Prune

+

Automatically remove inactive containers with no open tabs after a set number of days.

+
+ +
+ after + + days +
+
+
+ +
+

Import / Export

+

Backup or restore all settings, seeds, and whitelist.

+
+ + + +
+
+

Bulk Actions

diff --git a/options/options.js b/options/options.js index 5c5ed98..f3b862c 100644 --- a/options/options.js +++ b/options/options.js @@ -215,12 +215,74 @@ document.getElementById("reset").addEventListener("click", async (e) => { }, 1200); }); +// --- Auto-Prune --- + +async function loadAutoPrune() { + const settings = await browser.runtime.sendMessage({ type: "getAutoPruneSettings" }); + document.getElementById("auto-prune-enabled").checked = settings.enabled; + document.getElementById("auto-prune-days").value = settings.days || 30; +} + +async function saveAutoPrune() { + const enabled = document.getElementById("auto-prune-enabled").checked; + const days = parseInt(document.getElementById("auto-prune-days").value) || 30; + await browser.runtime.sendMessage({ + type: "setAutoPruneSettings", + settings: { enabled, days: Math.max(1, Math.min(365, days)) } + }); +} + +document.getElementById("auto-prune-enabled").addEventListener("change", saveAutoPrune); +document.getElementById("auto-prune-days").addEventListener("change", saveAutoPrune); + +// --- Import / Export --- + +document.getElementById("export-btn").addEventListener("click", async (e) => { + e.target.textContent = "Exporting..."; + const data = await browser.runtime.sendMessage({ type: "exportSettings" }); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `containsite-backup-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + e.target.textContent = "Exported!"; + setTimeout(() => { e.target.textContent = "Export Settings"; }, 1200); +}); + +document.getElementById("import-btn").addEventListener("click", () => { + document.getElementById("import-file").click(); +}); + +document.getElementById("import-file").addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (!file) return; + const btn = document.getElementById("import-btn"); + btn.textContent = "Importing..."; + try { + const text = await file.text(); + const data = JSON.parse(text); + const result = await browser.runtime.sendMessage({ type: "importSettings", data }); + if (result.ok) { + btn.textContent = "Imported!"; + await Promise.all([loadVectors(), loadWhitelist(), loadContainers(), loadAutoPrune()]); + } else { + btn.textContent = "Error: " + (result.error || "Unknown"); + } + } catch(err) { + btn.textContent = "Invalid file"; + } + e.target.value = ""; + setTimeout(() => { btn.textContent = "Import Settings"; }, 2000); +}); + // --- Init --- async function init() { const manifest = browser.runtime.getManifest(); document.getElementById("version").textContent = `v${manifest.version}`; - await Promise.all([loadVectors(), loadWhitelist(), loadContainers()]); + await Promise.all([loadVectors(), loadWhitelist(), loadContainers(), loadAutoPrune()]); } init(); diff --git a/test/fingerprint-test.html b/test/fingerprint-test.html index 841decd..7d3a7df 100644 --- a/test/fingerprint-test.html +++ b/test/fingerprint-test.html @@ -200,6 +200,18 @@
+ +
+

Gamepad API

+
+
+ + +
+

WebGL readPixels

+
+
+ @@ -981,6 +993,60 @@ async function testClientHints() { } } +// ── Gamepad API ── +function testGamepad() { + const el = document.getElementById("gamepad-results"); + if (!navigator.getGamepads) { + report.gamepad = { status: "API not available" }; + el.innerHTML = row("Status", "navigator.getGamepads not available", ""); + sectionStatus.gamepad = "warn"; + setDot("dot-gamepad", "warn"); + return; + } + + const gamepads = navigator.getGamepads(); + const count = Array.from(gamepads).filter(g => g !== null).length; + report.gamepad = { totalSlots: gamepads.length, connectedCount: count }; + el.innerHTML = row("Gamepad slots", gamepads.length) + + row("Connected gamepads", count, count === 0 ? "spoofed" : "real"); + sectionStatus.gamepad = count === 0 ? "pass" : "warn"; + setDot("dot-gamepad", sectionStatus.gamepad); +} + +// ── WebGL readPixels ── +async function testReadPixels() { + const el = document.getElementById("readpixels-results"); + const c = document.createElement("canvas"); + c.width = 64; + c.height = 64; + const gl = c.getContext("webgl"); + if (!gl) { + report.readpixels = { status: "WebGL not available" }; + el.innerHTML = row("Status", "WebGL not available", ""); + sectionStatus.readpixels = "warn"; + setDot("dot-readpixels", "warn"); + return; + } + + // Draw something + gl.clearColor(0.5, 0.3, 0.7, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + + const pixels = new Uint8Array(64 * 64 * 4); + gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + + // Hash the pixel data + let sum = 0; + for (let i = 0; i < pixels.length; i++) sum += pixels[i]; + const hash = await sha256Short(sum.toString()); + + report.readpixels = { pixelSum: sum, hash }; + el.innerHTML = row("readPixels hash", hash) + + row("Pixel sum", sum); + sectionStatus.readpixels = "pass"; + setDot("dot-readpixels", "pass"); +} + // ── Summary ── function updateSummary() { let pass = 0, fail = 0, warn = 0; @@ -1060,6 +1126,7 @@ async function runAll() { try { testPerf(); } catch(e) { console.error("perf test:", e); } try { testFontDOM(); } catch(e) { console.error("fontdom test:", e); } try { testDocFonts(); } catch(e) { console.error("docfonts test:", e); } + try { testGamepad(); } catch(e) { console.error("gamepad test:", e); } // Async tests — run in parallel with individual error handling await Promise.allSettled([ @@ -1070,6 +1137,7 @@ async function runAll() { testHeaders(), testStorage(), testClientHints(), + testReadPixels(), ]); updateSummary();