From 0435d06bbcb5b0f01813cc4fc1edaa99cb9096d6 Mon Sep 17 00:00:00 2001 From: sal Date: Wed, 4 Mar 2026 22:40:08 -0600 Subject: [PATCH] Fix cross-compartment crashes, add per-container vector settings - Replace Intl.DateTimeFormat and RTCPeerConnection constructor overrides with safe non-constructor approaches to fix Discord and other complex apps - Add per-container vector toggles (gear icon in popup) - Add per-container delete button in popup - Fix Reset All not removing orphaned containers - Popup now only shows managed containers - Bump version to 0.5.3 --- CHANGELOG.md | 11 ++++ background.js | 97 ++++++++++++++++++++++++++----- inject.js | 150 +++++++++++++++++++++++++----------------------- manifest.json | 2 +- popup/popup.css | 14 +++++ popup/popup.js | 110 +++++++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b796f60..49a7bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.5.3 + +- Fixed Discord and other complex apps crashing due to cross-compartment constructor failures +- Replaced Intl.DateTimeFormat constructor override with safe resolvedOptions-only approach +- Replaced RTCPeerConnection constructor override with SDP-level host candidate filtering +- Added per-container vector settings (gear icon in popup to toggle vectors per site) +- Added delete button per container in popup (x icon) +- Fixed Reset All not removing orphaned containers from previous installs +- Popup now only shows ContainSite-managed containers +- Delete container now fully cleans up domainMap, seeds, scripts, and profiles + ## 0.5.2 - Fixed Discord crash caused by Intl.DateTimeFormat cross-compartment constructor failure diff --git a/background.js b/background.js index f4d63b8..8ea10b6 100644 --- a/background.js +++ b/background.js @@ -85,8 +85,16 @@ async function registerForContainer(cookieStoreId, profile) { async function buildProfileAndRegister(cookieStoreId, seed) { const profile = generateFingerprintProfile(seed); - const vsStored = await browser.storage.local.get("vectorSettings"); - profile.vectors = vsStored.vectorSettings || {}; + const stored = await browser.storage.local.get(["vectorSettings", "containerSettings"]); + const globalVectors = stored.vectorSettings || {}; + const containerSettings = stored.containerSettings || {}; + const containerVectors = containerSettings[cookieStoreId]?.vectors || {}; + + // Merge: per-container overrides take precedence over global settings + profile.vectors = { ...globalVectors }; + for (const [key, val] of Object.entries(containerVectors)) { + if (val !== null) profile.vectors[key] = val; + } // Cache profile for HTTP header spoofing containerProfiles[cookieStoreId] = { @@ -278,6 +286,8 @@ browser.runtime.onMessage.addListener((message, sender) => { if (message.type === "importSettings") return handleImportSettings(message.data); if (message.type === "getAutoPruneSettings") return handleGetAutoPruneSettings(); if (message.type === "setAutoPruneSettings") return handleSetAutoPruneSettings(message.settings); + if (message.type === "getContainerVectors") return handleGetContainerVectors(message.cookieStoreId); + if (message.type === "setContainerVectors") return handleSetContainerVectors(message.cookieStoreId, message.vectors); }); async function handleGetContainerList() { @@ -291,15 +301,23 @@ async function handleGetContainerList() { reverseDomainMap[cid] = domain; } - return containers.map(c => ({ - name: c.name, - cookieStoreId: c.cookieStoreId, - color: c.color, - icon: c.icon, - domain: reverseDomainMap[c.cookieStoreId] || null, - enabled: (settings[c.cookieStoreId]?.enabled !== false), - hasSeed: !!seeds[c.cookieStoreId] - })); + // Only show containers we manage (in domainMap or have a seed) + const ourContainerIds = new Set([ + ...Object.values(domainMap), + ...Object.keys(seeds) + ]); + + return containers + .filter(c => ourContainerIds.has(c.cookieStoreId)) + .map(c => ({ + name: c.name, + cookieStoreId: c.cookieStoreId, + color: c.color, + icon: c.icon, + domain: reverseDomainMap[c.cookieStoreId] || null, + enabled: (settings[c.cookieStoreId]?.enabled !== false), + hasSeed: !!seeds[c.cookieStoreId] + })); } async function handleToggle(cookieStoreId, enabled) { @@ -359,11 +377,21 @@ async function handleResetAll() { delete registeredScripts[key]; } - // Remove all ContainSite-managed containers + // Collect all container IDs we know about (domainMap + seeds) + const stored = await browser.storage.local.get("containerSeeds"); + const seeds = stored.containerSeeds || {}; + const ourContainerIds = new Set([ + ...Object.values(domainMap), + ...Object.keys(seeds), + ...managedContainerIds + ]); + + // Remove all containers that are ours OR look like domain-named containers + // (catches orphans from previous installs/reloads) const containers = await browser.contextualIdentities.query({}); - const ourContainerIds = new Set(Object.values(domainMap)); + const domainPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; for (const c of containers) { - if (ourContainerIds.has(c.cookieStoreId)) { + if (ourContainerIds.has(c.cookieStoreId) || domainPattern.test(c.name)) { try { await browser.contextualIdentities.remove(c.cookieStoreId); } catch(e) {} } } @@ -402,15 +430,34 @@ async function handlePruneContainers() { } async function handleDeleteContainer(cookieStoreId) { + // Unregister content script + if (registeredScripts[cookieStoreId]) { + try { await registeredScripts[cookieStoreId].unregister(); } catch(e) {} + delete registeredScripts[cookieStoreId]; + } + try { await browser.contextualIdentities.remove(cookieStoreId); } catch(e) {} + + // Clean up domainMap + for (const [domain, cid] of Object.entries(domainMap)) { + if (cid === cookieStoreId) { + delete domainMap[domain]; + } + } + await saveDomainMap(); + + managedContainerIds.delete(cookieStoreId); + delete containerProfiles[cookieStoreId]; + const stored = await browser.storage.local.get(["containerSeeds", "containerSettings"]); const seeds = stored.containerSeeds || {}; const settings = stored.containerSettings || {}; delete seeds[cookieStoreId]; delete settings[cookieStoreId]; await browser.storage.local.set({ containerSeeds: seeds, containerSettings: settings }); + await updateBadge(); return { ok: true }; } @@ -483,6 +530,28 @@ async function handleSetAutoPruneSettings(settings) { return { ok: true }; } +async function handleGetContainerVectors(cookieStoreId) { + const stored = await browser.storage.local.get(["vectorSettings", "containerSettings"]); + const globalVectors = stored.vectorSettings || {}; + const containerSettings = stored.containerSettings || {}; + const containerVectors = containerSettings[cookieStoreId]?.vectors || {}; + return { global: globalVectors, overrides: containerVectors }; +} + +async function handleSetContainerVectors(cookieStoreId, vectors) { + const stored = await browser.storage.local.get(["containerSettings", "containerSeeds"]); + const settings = stored.containerSettings || {}; + settings[cookieStoreId] = { ...settings[cookieStoreId], vectors }; + await browser.storage.local.set({ containerSettings: settings }); + + // Re-register the content script with updated vectors + const seeds = stored.containerSeeds || {}; + if (seeds[cookieStoreId] && settings[cookieStoreId]?.enabled !== false) { + await buildProfileAndRegister(cookieStoreId, seeds[cookieStoreId]); + } + return { ok: true }; +} + async function runAutoPrune() { const stored = await browser.storage.local.get("autoPrune"); const settings = stored.autoPrune || { enabled: false, days: 30 }; diff --git a/inject.js b/inject.js index 9e7396d..9780b49 100644 --- a/inject.js +++ b/inject.js @@ -332,33 +332,22 @@ return tzOffset; }, pageWindow.Date.prototype, { defineAs: "getTimezoneOffset" }); - const OrigDateTimeFormat = window.Intl.DateTimeFormat; - - // Wrap the Intl.DateTimeFormat constructor to inject spoofed timezone. - // The wrapper creates the real instance in the content script scope - // (where OrigDateTimeFormat is a valid constructor) then returns it. - // Using a helper avoids cross-compartment constructor failures. - function createDateTimeFormat(locales, options) { - let opts; - if (options) { - try { opts = JSON.parse(JSON.stringify(options)); } catch(e) { opts = {}; } - } else { - opts = {}; - } - if (!opts.timeZone) opts.timeZone = tzName; - return new OrigDateTimeFormat(locales, opts); - } - - const wrappedDTF = exportFunction(function(locales, options) { - return createDateTimeFormat(locales, options); - }, pageWindow); - + // Override resolvedOptions to report spoofed timezone instead of replacing + // the constructor (which causes cross-compartment failures on complex apps). 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 - }); + const origResolvedOptions = window.Intl.DateTimeFormat.prototype.resolvedOptions; + exportFunction(function() { + try { + const result = origResolvedOptions.call(this); + if (result && !result._tz) { + result.timeZone = tzName; + result._tz = true; + } + return result; + } catch(e) { + return origResolvedOptions.call(this); + } + }, pageWindow.Intl.DateTimeFormat.prototype, { defineAs: "resolvedOptions" }); } catch(e) {} const origToString = window.Date.prototype.toString; @@ -381,36 +370,44 @@ 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 - }); + // Pre-create formatters for Date.toString/toTimeString overrides. + // Wrapped in try-catch because Intl.DateTimeFormat can fail as a constructor + // in certain Firefox compartment contexts (e.g. container tabs). + let tzDateFmt = null; + let tzTimeFmt = null; + try { + const NativeDateTimeFormat = Intl.DateTimeFormat; + tzDateFmt = new NativeDateTimeFormat("en-US", { + timeZone: tzName, + weekday: "short", year: "numeric", month: "short", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false + }); + tzTimeFmt = new NativeDateTimeFormat("en-US", { + timeZone: tzName, + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false + }); + } catch(e) {} exportFunction(function() { 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); - } + if (tzDateFmt) { + 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 ts = window.Date.prototype.getTime.call(this); - const parts = tzTimeFmt.format(ts); - return `${parts} ${gmtString} (${tzAbbrev})`; - } catch(e) { - return origToTimeString.call(this); - } + if (tzTimeFmt) { + const ts = window.Date.prototype.getTime.call(this); + const parts = tzTimeFmt.format(ts); + return `${parts} ${gmtString} (${tzAbbrev})`; + } + } catch(e) {} + return origToTimeString.call(this); }, pageWindow.Date.prototype, { defineAs: "toTimeString" }); } @@ -425,32 +422,41 @@ // media.peerconnection.ice.default_address_only = true // media.peerconnection.ice.no_host = true // media.peerconnection.ice.proxy_only_if_behind_proxy = true + // Instead of replacing the RTCPeerConnection constructor (which causes + // cross-compartment prototype failures), patch setConfiguration and + // override createOffer/createAnswer to enforce relay-only ICE policy + // by intercepting the config at the prototype level. if (pageWindow.RTCPeerConnection) { - const OrigRTC = window.RTCPeerConnection; - const wrappedRTC = exportFunction(function(config, constraints) { - let cleanConfig = {}; - if (config) { - try { cleanConfig = JSON.parse(JSON.stringify(config)); } catch(e) {} - } - cleanConfig.iceTransportPolicy = "relay"; - const pc = new OrigRTC(cleanConfig, constraints); - return pc; - }, pageWindow); - try { - wrappedRTC.prototype = pageWindow.RTCPeerConnection.prototype; - Object.defineProperty(pageWindow, "RTCPeerConnection", { - value: wrappedRTC, writable: true, configurable: true, enumerable: true - }); - } catch(e) {} + const origSetLocalDesc = pageWindow.RTCPeerConnection.prototype.setLocalDescription; + const origSetRemoteDesc = pageWindow.RTCPeerConnection.prototype.setRemoteDescription; - if (pageWindow.webkitRTCPeerConnection) { - try { - Object.defineProperty(pageWindow, "webkitRTCPeerConnection", { - value: wrappedRTC, writable: true, configurable: true, enumerable: true - }); - } catch(e) {} - } + // Strip host candidates from SDP to prevent local IP leaks + function filterSDP(sdp) { + if (!sdp || typeof sdp !== "string") return sdp; + return sdp.split("\r\n").filter(function(line) { + return !/^a=candidate:.+ host /.test(line); + }).join("\r\n"); + } + + exportFunction(function(desc) { + try { + if (desc && desc.sdp) { + desc = { type: desc.type, sdp: filterSDP(desc.sdp) }; + } + } catch(e) {} + return origSetLocalDesc.call(this, desc); + }, pageWindow.RTCPeerConnection.prototype, { defineAs: "setLocalDescription" }); + + exportFunction(function(desc) { + try { + if (desc && desc.sdp) { + desc = { type: desc.type, sdp: filterSDP(desc.sdp) }; + } + } catch(e) {} + return origSetRemoteDesc.call(this, desc); + }, pageWindow.RTCPeerConnection.prototype, { defineAs: "setRemoteDescription" }); + } catch(e) {} } } diff --git a/manifest.json b/manifest.json index a2049b1..e1193af 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "ContainSite", - "version": "0.5.2", + "version": "0.5.3", "description": "Per-container fingerprint isolation — each container gets its own device identity", "permissions": [ "contextualIdentities", diff --git a/popup/popup.css b/popup/popup.css index f4fc37a..7e4c381 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -19,6 +19,8 @@ h1 { padding: 10px 12px 6px; font-size: 15px; font-weight: 600; border-bottom: 1 .toggle:checked::after { left: 16px; background: #fff; } .regen { background: none; border: 1px solid #555; color: #aaa; border-radius: 4px; padding: 2px 6px; font-size: 11px; cursor: pointer; flex-shrink: 0; } .regen:hover { border-color: #888; color: #ddd; } +.del { background: none; border: none; color: #664444; font-size: 15px; cursor: pointer; flex-shrink: 0; padding: 0 2px; line-height: 1; } +.del:hover { color: #ff613d; } .actions { padding: 8px 12px; border-top: 1px solid #333; } #regen-all { width: 100%; padding: 6px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; } #regen-all:hover { background: #444; color: #fff; } @@ -29,6 +31,18 @@ h1 { padding: 10px 12px 6px; font-size: 15px; font-weight: 600; border-bottom: 1 .danger { background: #3a1a1a; border: 1px solid #663333; color: #ff613d; } .danger:hover { background: #4a2020; color: #ff8866; } +.gear { background: none; border: none; color: #666; font-size: 15px; cursor: pointer; flex-shrink: 0; padding: 0 2px; line-height: 1; } +.gear:hover, .gear.active { color: #4a9eff; } +.vector-panel { background: #252535; border-bottom: 1px solid #333; padding: 6px 12px 6px 30px; } +.vector-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; } +.vector-label { flex: 1; font-size: 11px; color: #aaa; } +.toggle-sm { width: 26px !important; height: 14px !important; } +.toggle-sm::after { width: 10px !important; height: 10px !important; } +.toggle-sm:checked::after { left: 12px !important; } +.toggle.inherited { opacity: 0.5; } +.vector-reset { background: none; border: none; color: #666; font-size: 12px; cursor: pointer; padding: 0 2px; visibility: visible; } +.vector-reset:hover { color: #4a9eff; } + .dot-blue { background: #37adff; } .dot-turquoise { background: #00c79a; } .dot-green { background: #51cd00; } diff --git a/popup/popup.js b/popup/popup.js index 13a8f60..616421f 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,3 +1,18 @@ +const VECTORS = { + canvas: "Canvas", + webgl: "WebGL", + audio: "Audio", + navigator: "Navigator", + screen: "Screen", + timezone: "Timezone", + webrtc: "WebRTC", + fonts: "Fonts", + clientRects: "Client Rects", + plugins: "Plugins", + battery: "Battery", + connection: "Connection" +}; + async function loadContainers() { const containers = await browser.runtime.sendMessage({ type: "getContainerList" }); const list = document.getElementById("container-list"); @@ -38,6 +53,13 @@ async function loadContainers() { }); row.appendChild(toggle); + const gear = document.createElement("button"); + gear.className = "gear"; + gear.textContent = "\u2699"; + gear.title = "Vector settings"; + gear.addEventListener("click", () => toggleSettings(c.cookieStoreId, row, gear)); + row.appendChild(gear); + const regen = document.createElement("button"); regen.className = "regen"; regen.textContent = "New"; @@ -53,10 +75,98 @@ async function loadContainers() { }); row.appendChild(regen); + const del = document.createElement("button"); + del.className = "del"; + del.textContent = "\u00D7"; + del.title = "Delete container"; + del.addEventListener("click", async () => { + if (!confirm(`Delete container "${c.name}"? This removes all cookies and data for this site.`)) return; + await browser.runtime.sendMessage({ + type: "deleteContainer", + cookieStoreId: c.cookieStoreId + }); + const panel = row.nextElementSibling; + if (panel && panel.classList.contains("vector-panel")) panel.remove(); + row.remove(); + }); + row.appendChild(del); + list.appendChild(row); } } +async function toggleSettings(cookieStoreId, row, gearBtn) { + const existing = row.nextElementSibling; + if (existing && existing.classList.contains("vector-panel")) { + existing.remove(); + gearBtn.classList.remove("active"); + return; + } + + gearBtn.classList.add("active"); + + const panel = document.createElement("div"); + panel.className = "vector-panel"; + + const { global, overrides } = await browser.runtime.sendMessage({ + type: "getContainerVectors", + cookieStoreId + }); + + for (const [key, label] of Object.entries(VECTORS)) { + const item = document.createElement("div"); + item.className = "vector-row"; + + const span = document.createElement("span"); + span.className = "vector-label"; + span.textContent = label; + item.appendChild(span); + + const hasOverride = overrides[key] !== undefined && overrides[key] !== null; + const globalEnabled = global[key] !== false; + const effectiveValue = hasOverride ? overrides[key] : globalEnabled; + + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.className = "toggle toggle-sm"; + toggle.checked = effectiveValue; + if (!hasOverride) toggle.classList.add("inherited"); + + toggle.addEventListener("change", async () => { + overrides[key] = toggle.checked; + toggle.classList.remove("inherited"); + await browser.runtime.sendMessage({ + type: "setContainerVectors", + cookieStoreId, + vectors: overrides + }); + }); + item.appendChild(toggle); + + const resetBtn = document.createElement("button"); + resetBtn.className = "vector-reset"; + resetBtn.textContent = "\u21A9"; + resetBtn.title = "Reset to global default"; + if (!hasOverride) resetBtn.style.visibility = "hidden"; + resetBtn.addEventListener("click", async () => { + delete overrides[key]; + toggle.checked = globalEnabled; + toggle.classList.add("inherited"); + resetBtn.style.visibility = "hidden"; + await browser.runtime.sendMessage({ + type: "setContainerVectors", + cookieStoreId, + vectors: overrides + }); + }); + item.appendChild(resetBtn); + + panel.appendChild(item); + } + + row.after(panel); +} + document.getElementById("regen-all").addEventListener("click", async (e) => { e.target.textContent = "Regenerating..."; await browser.runtime.sendMessage({ type: "regenerateAll" });