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.
This commit is contained in:
sal
2026-03-04 21:08:45 -06:00
parent bbe40f87dc
commit d6dabb2646
9 changed files with 352 additions and 4 deletions

46
CHANGELOG.md Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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) {}
}
// =========================================================================

View File

@@ -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",

View File

@@ -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%; }

View File

@@ -46,6 +46,32 @@
<div id="no-containers" class="empty" hidden>No containers yet. Browse a website to create one.</div>
</section>
<section>
<h2>Auto-Prune</h2>
<p class="desc">Automatically remove inactive containers with no open tabs after a set number of days.</p>
<div class="auto-prune-row">
<label class="toggle-label">
<input type="checkbox" id="auto-prune-enabled" class="toggle">
<span>Enable auto-prune</span>
</label>
<div class="prune-days">
<span>after</span>
<input type="number" id="auto-prune-days" min="1" max="365" value="30" class="days-input">
<span>days</span>
</div>
</div>
</section>
<section>
<h2>Import / Export</h2>
<p class="desc">Backup or restore all settings, seeds, and whitelist.</p>
<div class="bulk-row">
<button id="export-btn" class="btn">Export Settings</button>
<button id="import-btn" class="btn secondary">Import Settings</button>
<input type="file" id="import-file" accept=".json" hidden>
</div>
</section>
<section>
<h2>Bulk Actions</h2>
<div class="bulk">

View File

@@ -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();

View File

@@ -200,6 +200,18 @@
<div class="section-body" id="clienthints-results"></div>
</div>
<!-- Gamepad API -->
<div class="section" id="sec-gamepad">
<div class="section-header"><span class="status-dot pending" id="dot-gamepad"></span><h2>Gamepad API</h2></div>
<div class="section-body" id="gamepad-results"></div>
</div>
<!-- WebGL readPixels -->
<div class="section" id="sec-readpixels">
<div class="section-header"><span class="status-dot pending" id="dot-readpixels"></span><h2>WebGL readPixels</h2></div>
<div class="section-body" id="readpixels-results"></div>
</div>
<button class="copy-btn" onclick="copyReport()">Copy Full Report to Clipboard</button>
<canvas id="test-canvas" width="300" height="60"></canvas>
@@ -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();