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
This commit is contained in:
sal
2026-03-04 22:40:08 -06:00
parent 464a570201
commit 0435d06bbc
6 changed files with 297 additions and 87 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # 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 ## 0.5.2
- Fixed Discord crash caused by Intl.DateTimeFormat cross-compartment constructor failure - Fixed Discord crash caused by Intl.DateTimeFormat cross-compartment constructor failure

View File

@@ -85,8 +85,16 @@ async function registerForContainer(cookieStoreId, profile) {
async function buildProfileAndRegister(cookieStoreId, seed) { async function buildProfileAndRegister(cookieStoreId, seed) {
const profile = generateFingerprintProfile(seed); const profile = generateFingerprintProfile(seed);
const vsStored = await browser.storage.local.get("vectorSettings"); const stored = await browser.storage.local.get(["vectorSettings", "containerSettings"]);
profile.vectors = vsStored.vectorSettings || {}; 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 // Cache profile for HTTP header spoofing
containerProfiles[cookieStoreId] = { containerProfiles[cookieStoreId] = {
@@ -278,6 +286,8 @@ browser.runtime.onMessage.addListener((message, sender) => {
if (message.type === "importSettings") return handleImportSettings(message.data); if (message.type === "importSettings") return handleImportSettings(message.data);
if (message.type === "getAutoPruneSettings") return handleGetAutoPruneSettings(); if (message.type === "getAutoPruneSettings") return handleGetAutoPruneSettings();
if (message.type === "setAutoPruneSettings") return handleSetAutoPruneSettings(message.settings); 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() { async function handleGetContainerList() {
@@ -291,15 +301,23 @@ async function handleGetContainerList() {
reverseDomainMap[cid] = domain; reverseDomainMap[cid] = domain;
} }
return containers.map(c => ({ // Only show containers we manage (in domainMap or have a seed)
name: c.name, const ourContainerIds = new Set([
cookieStoreId: c.cookieStoreId, ...Object.values(domainMap),
color: c.color, ...Object.keys(seeds)
icon: c.icon, ]);
domain: reverseDomainMap[c.cookieStoreId] || null,
enabled: (settings[c.cookieStoreId]?.enabled !== false), return containers
hasSeed: !!seeds[c.cookieStoreId] .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) { async function handleToggle(cookieStoreId, enabled) {
@@ -359,11 +377,21 @@ async function handleResetAll() {
delete registeredScripts[key]; 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 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) { 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) {} try { await browser.contextualIdentities.remove(c.cookieStoreId); } catch(e) {}
} }
} }
@@ -402,15 +430,34 @@ async function handlePruneContainers() {
} }
async function handleDeleteContainer(cookieStoreId) { async function handleDeleteContainer(cookieStoreId) {
// Unregister content script
if (registeredScripts[cookieStoreId]) {
try { await registeredScripts[cookieStoreId].unregister(); } catch(e) {}
delete registeredScripts[cookieStoreId];
}
try { try {
await browser.contextualIdentities.remove(cookieStoreId); await browser.contextualIdentities.remove(cookieStoreId);
} catch(e) {} } 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 stored = await browser.storage.local.get(["containerSeeds", "containerSettings"]);
const seeds = stored.containerSeeds || {}; const seeds = stored.containerSeeds || {};
const settings = stored.containerSettings || {}; const settings = stored.containerSettings || {};
delete seeds[cookieStoreId]; delete seeds[cookieStoreId];
delete settings[cookieStoreId]; delete settings[cookieStoreId];
await browser.storage.local.set({ containerSeeds: seeds, containerSettings: settings }); await browser.storage.local.set({ containerSeeds: seeds, containerSettings: settings });
await updateBadge();
return { ok: true }; return { ok: true };
} }
@@ -483,6 +530,28 @@ async function handleSetAutoPruneSettings(settings) {
return { ok: true }; 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() { async function runAutoPrune() {
const stored = await browser.storage.local.get("autoPrune"); const stored = await browser.storage.local.get("autoPrune");
const settings = stored.autoPrune || { enabled: false, days: 30 }; const settings = stored.autoPrune || { enabled: false, days: 30 };

150
inject.js
View File

@@ -332,33 +332,22 @@
return tzOffset; return tzOffset;
}, pageWindow.Date.prototype, { defineAs: "getTimezoneOffset" }); }, pageWindow.Date.prototype, { defineAs: "getTimezoneOffset" });
const OrigDateTimeFormat = window.Intl.DateTimeFormat; // Override resolvedOptions to report spoofed timezone instead of replacing
// the constructor (which causes cross-compartment failures on complex apps).
// 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);
try { try {
wrappedDTF.prototype = pageWindow.Intl.DateTimeFormat.prototype; const origResolvedOptions = window.Intl.DateTimeFormat.prototype.resolvedOptions;
wrappedDTF.supportedLocalesOf = pageWindow.Intl.DateTimeFormat.supportedLocalesOf; exportFunction(function() {
Object.defineProperty(pageWindow.Intl, "DateTimeFormat", { try {
value: wrappedDTF, writable: true, configurable: true, enumerable: true 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) {} } catch(e) {}
const origToString = window.Date.prototype.toString; const origToString = window.Date.prototype.toString;
@@ -381,36 +370,44 @@
const tzM = String(tzAbsOff % 60).padStart(2, "0"); const tzM = String(tzAbsOff % 60).padStart(2, "0");
const gmtString = `GMT${tzSign}${tzH}${tzM}`; const gmtString = `GMT${tzSign}${tzH}${tzM}`;
// Pre-create a formatter in the content script scope (not inside exportFunction) // Pre-create formatters for Date.toString/toTimeString overrides.
const tzDateFmt = new OrigDateTimeFormat("en-US", { // Wrapped in try-catch because Intl.DateTimeFormat can fail as a constructor
timeZone: tzName, // in certain Firefox compartment contexts (e.g. container tabs).
weekday: "short", year: "numeric", month: "short", day: "2-digit", let tzDateFmt = null;
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false let tzTimeFmt = null;
}); try {
const tzTimeFmt = new OrigDateTimeFormat("en-US", { const NativeDateTimeFormat = Intl.DateTimeFormat;
timeZone: tzName, tzDateFmt = new NativeDateTimeFormat("en-US", {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false 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() { exportFunction(function() {
try { try {
// Get timestamp from the page-side Date via getTime (works across compartments) if (tzDateFmt) {
const ts = window.Date.prototype.getTime.call(this); const ts = window.Date.prototype.getTime.call(this);
const parts = tzDateFmt.format(ts); const parts = tzDateFmt.format(ts);
return `${parts} ${gmtString} (${tzAbbrev})`; return `${parts} ${gmtString} (${tzAbbrev})`;
} catch(e) { }
return origToString.call(this); } catch(e) {}
} return origToString.call(this);
}, pageWindow.Date.prototype, { defineAs: "toString" }); }, pageWindow.Date.prototype, { defineAs: "toString" });
exportFunction(function() { exportFunction(function() {
try { try {
const ts = window.Date.prototype.getTime.call(this); if (tzTimeFmt) {
const parts = tzTimeFmt.format(ts); const ts = window.Date.prototype.getTime.call(this);
return `${parts} ${gmtString} (${tzAbbrev})`; const parts = tzTimeFmt.format(ts);
} catch(e) { return `${parts} ${gmtString} (${tzAbbrev})`;
return origToTimeString.call(this); }
} } catch(e) {}
return origToTimeString.call(this);
}, pageWindow.Date.prototype, { defineAs: "toTimeString" }); }, pageWindow.Date.prototype, { defineAs: "toTimeString" });
} }
@@ -425,32 +422,41 @@
// media.peerconnection.ice.default_address_only = true // media.peerconnection.ice.default_address_only = true
// media.peerconnection.ice.no_host = true // media.peerconnection.ice.no_host = true
// media.peerconnection.ice.proxy_only_if_behind_proxy = 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) { 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 { try {
wrappedRTC.prototype = pageWindow.RTCPeerConnection.prototype; const origSetLocalDesc = pageWindow.RTCPeerConnection.prototype.setLocalDescription;
Object.defineProperty(pageWindow, "RTCPeerConnection", { const origSetRemoteDesc = pageWindow.RTCPeerConnection.prototype.setRemoteDescription;
value: wrappedRTC, writable: true, configurable: true, enumerable: true
});
} catch(e) {}
if (pageWindow.webkitRTCPeerConnection) { // Strip host candidates from SDP to prevent local IP leaks
try { function filterSDP(sdp) {
Object.defineProperty(pageWindow, "webkitRTCPeerConnection", { if (!sdp || typeof sdp !== "string") return sdp;
value: wrappedRTC, writable: true, configurable: true, enumerable: true return sdp.split("\r\n").filter(function(line) {
}); return !/^a=candidate:.+ host /.test(line);
} catch(e) {} }).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) {}
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "ContainSite", "name": "ContainSite",
"version": "0.5.2", "version": "0.5.3",
"description": "Per-container fingerprint isolation — each container gets its own device identity", "description": "Per-container fingerprint isolation — each container gets its own device identity",
"permissions": [ "permissions": [
"contextualIdentities", "contextualIdentities",

View File

@@ -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; } .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 { 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; } .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; } .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 { 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; } #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 { background: #3a1a1a; border: 1px solid #663333; color: #ff613d; }
.danger:hover { background: #4a2020; color: #ff8866; } .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-blue { background: #37adff; }
.dot-turquoise { background: #00c79a; } .dot-turquoise { background: #00c79a; }
.dot-green { background: #51cd00; } .dot-green { background: #51cd00; }

View File

@@ -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() { async function loadContainers() {
const containers = await browser.runtime.sendMessage({ type: "getContainerList" }); const containers = await browser.runtime.sendMessage({ type: "getContainerList" });
const list = document.getElementById("container-list"); const list = document.getElementById("container-list");
@@ -38,6 +53,13 @@ async function loadContainers() {
}); });
row.appendChild(toggle); 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"); const regen = document.createElement("button");
regen.className = "regen"; regen.className = "regen";
regen.textContent = "New"; regen.textContent = "New";
@@ -53,10 +75,98 @@ async function loadContainers() {
}); });
row.appendChild(regen); 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); 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) => { document.getElementById("regen-all").addEventListener("click", async (e) => {
e.target.textContent = "Regenerating..."; e.target.textContent = "Regenerating...";
await browser.runtime.sendMessage({ type: "regenerateAll" }); await browser.runtime.sendMessage({ type: "regenerateAll" });