Add HTTP header spoofing, fix timezone overrides, and expand fingerprint coverage

- Spoof User-Agent and Accept-Language HTTP headers per container via
  webRequest.onBeforeSendHeaders, eliminating JS/HTTP header mismatch
- Wrap Intl.DateTimeFormat constructor to inject spoofed timezone
- Pre-create timezone formatters outside exportFunction callbacks to
  avoid cross-compartment issues with Date.toString/toTimeString
- Fix WebRTC relay-only config to use JSON serialization across
  Firefox compartment boundaries
- Add new fingerprint vector protections: speechSynthesis.getVoices(),
  matchMedia screen dimension queries, performance.now() precision
  reduction, navigator.storage.estimate(), WebGL extension normalization
- Add comprehensive fingerprint test page (test/fingerprint-test.html)
  covering all 17 vectors with per-container comparison support
This commit is contained in:
sal
2026-03-01 15:22:21 -06:00
parent 264a401cef
commit ed9355ced6
3 changed files with 1244 additions and 57 deletions

View File

@@ -2,6 +2,7 @@
// Every site gets its own container. Auth redirects stay in the originating container.
const registeredScripts = {}; // cookieStoreId -> RegisteredContentScript
const containerProfiles = {}; // cookieStoreId -> { userAgent, languages } for HTTP header spoofing
let injectSourceCache = null;
let domainMap = {}; // baseDomain -> cookieStoreId
let pendingTabs = {}; // tabId -> true (tabs being redirected)
@@ -86,6 +87,13 @@ async function buildProfileAndRegister(cookieStoreId, seed) {
const profile = generateFingerprintProfile(seed);
const vsStored = await browser.storage.local.get("vectorSettings");
profile.vectors = vsStored.vectorSettings || {};
// Cache profile for HTTP header spoofing
containerProfiles[cookieStoreId] = {
userAgent: profile.nav.userAgent,
languages: profile.nav.languages
};
await registerForContainer(cookieStoreId, profile);
}
@@ -337,11 +345,12 @@ async function handleResetAll() {
}
}
// Clear all storage
// Clear all storage and caches
domainMap = {};
pendingTabs = {};
cachedWhitelist = [];
managedContainerIds.clear();
for (const key of Object.keys(containerProfiles)) delete containerProfiles[key];
await browser.storage.local.clear();
return { ok: true };
@@ -408,6 +417,7 @@ async function handleSetVectorSettings(vectorSettings) {
browser.contextualIdentities.onRemoved.addListener(async ({ contextualIdentity }) => {
const cid = contextualIdentity.cookieStoreId;
managedContainerIds.delete(cid);
delete containerProfiles[cid];
if (registeredScripts[cid]) {
try { await registeredScripts[cid].unregister(); } catch(e) {}
delete registeredScripts[cid];
@@ -420,6 +430,40 @@ browser.contextualIdentities.onRemoved.addListener(async ({ contextualIdentity }
await saveDomainMap();
});
// --- HTTP Header Spoofing ---
// Modifies User-Agent and Accept-Language headers to match each container's
// spoofed profile, preventing server-side detection of JS/HTTP header mismatch.
function formatAcceptLanguage(languages) {
if (!languages || languages.length === 0) return "en-US,en;q=0.5";
return languages.map((lang, i) => {
if (i === 0) return lang;
const q = Math.max(0.1, 1 - i * 0.1).toFixed(1);
return `${lang};q=${q}`;
}).join(",");
}
browser.webRequest.onBeforeSendHeaders.addListener(
function(details) {
// cookieStoreId is available in Firefox 77+ webRequest details
const profile = containerProfiles[details.cookieStoreId];
if (!profile) return {};
const headers = details.requestHeaders;
for (let i = 0; i < headers.length; 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);
}
}
return { requestHeaders: headers };
},
{ urls: ["<all_urls>"] },
["blocking", "requestHeaders"]
);
// --- Init ---
async function init() {

320
inject.js
View File

@@ -303,64 +303,77 @@
}, pageWindow.Date.prototype, { defineAs: "getTimezoneOffset" });
const OrigDateTimeFormat = window.Intl.DateTimeFormat;
const origResolvedOptions = OrigDateTimeFormat.prototype.resolvedOptions;
exportFunction(function() {
const opts = origResolvedOptions.call(this);
try { opts.timeZone = tzName; } catch(e) {}
return opts;
}, pageWindow.Intl.DateTimeFormat.prototype, { defineAs: "resolvedOptions" });
// Wrap the Intl.DateTimeFormat constructor to inject spoofed timezone
// when no explicit timeZone is provided. This ensures resolvedOptions()
// returns the spoofed timezone and all formatting uses it.
const wrappedDTF = exportFunction(function(locales, options) {
let opts;
if (options) {
try { opts = JSON.parse(JSON.stringify(options)); } catch(e) { opts = {}; }
} else {
opts = {};
}
if (!opts.timeZone) opts.timeZone = tzName;
// Support both `new Intl.DateTimeFormat()` and `Intl.DateTimeFormat()`
return new OrigDateTimeFormat(locales, opts);
}, pageWindow);
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
});
} catch(e) {}
const origToString = window.Date.prototype.toString;
const origToTimeString = window.Date.prototype.toTimeString;
function formatTzAbbrev(tzName) {
const abbrevMap = {
"America/New_York": "EST", "America/Chicago": "CST",
"America/Denver": "MST", "America/Los_Angeles": "PST",
"Europe/London": "GMT", "Europe/Berlin": "CET",
"Europe/Paris": "CET", "Asia/Tokyo": "JST",
"Australia/Sydney": "AEST", "America/Toronto": "EST",
"America/Phoenix": "MST"
};
return abbrevMap[tzName] || "UTC";
}
const abbrevMap = {
"America/New_York": "EST", "America/Chicago": "CST",
"America/Denver": "MST", "America/Los_Angeles": "PST",
"Europe/London": "GMT", "Europe/Berlin": "CET",
"Europe/Paris": "CET", "Asia/Tokyo": "JST",
"Australia/Sydney": "AEST", "America/Toronto": "EST",
"America/Phoenix": "MST"
};
const tzAbbrev = abbrevMap[tzName] || "UTC";
function buildTzString(date) {
try {
const fmt = 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 parts = fmt.format(date);
const sign = tzOffset <= 0 ? "+" : "-";
const absOff = Math.abs(tzOffset);
const h = String(Math.floor(absOff / 60)).padStart(2, "0");
const m = String(absOff % 60).padStart(2, "0");
const abbrev = formatTzAbbrev(tzName);
return `${parts} GMT${sign}${h}${m} (${abbrev})`;
} catch(e) {
return origToString.call(date);
}
}
// Pre-compute the GMT offset string: e.g. "GMT+1100" or "GMT-0500"
const tzSign = tzOffset <= 0 ? "+" : "-";
const tzAbsOff = Math.abs(tzOffset);
const tzH = String(Math.floor(tzAbsOff / 60)).padStart(2, "0");
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
});
exportFunction(function() {
return buildTzString(this);
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);
}
}, pageWindow.Date.prototype, { defineAs: "toString" });
exportFunction(function() {
try {
const fmt = new OrigDateTimeFormat("en-US", {
timeZone: tzName,
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
});
const parts = fmt.format(this);
const sign = tzOffset <= 0 ? "+" : "-";
const absOff = Math.abs(tzOffset);
const h = String(Math.floor(absOff / 60)).padStart(2, "0");
const m = String(absOff % 60).padStart(2, "0");
const abbrev = formatTzAbbrev(tzName);
return `${parts} GMT${sign}${h}${m} (${abbrev})`;
const ts = window.Date.prototype.getTime.call(this);
const parts = tzTimeFmt.format(ts);
return `${parts} ${gmtString} (${tzAbbrev})`;
} catch(e) {
return origToTimeString.call(this);
}
@@ -372,26 +385,38 @@
// =========================================================================
if (vectorEnabled("webrtc") && CONFIG.webrtc && CONFIG.webrtc.blockLocal) {
// Force relay-only ICE transport to prevent local/public IP leaks via WebRTC.
// NOTE: LibreWolf/Firefox may resist content-script-level RTCPeerConnection
// overrides. For guaranteed protection, also set in about:config:
// media.peerconnection.ice.default_address_only = true
// media.peerconnection.ice.no_host = true
// media.peerconnection.ice.proxy_only_if_behind_proxy = true
if (pageWindow.RTCPeerConnection) {
const OrigRTC = window.RTCPeerConnection;
const wrappedRTC = exportFunction(function(config, constraints) {
if (config && config.iceServers) {
config.iceTransportPolicy = "relay";
let cleanConfig = {};
if (config) {
try { cleanConfig = JSON.parse(JSON.stringify(config)); } catch(e) {}
}
const pc = new OrigRTC(config, constraints);
cleanConfig.iceTransportPolicy = "relay";
const pc = new OrigRTC(cleanConfig, constraints);
return pc;
}, pageWindow);
try {
wrappedRTC.prototype = pageWindow.RTCPeerConnection.prototype;
pageWindow.RTCPeerConnection = wrappedRTC;
Object.defineProperty(pageWindow, "RTCPeerConnection", {
value: wrappedRTC, writable: true, configurable: true, enumerable: true
});
} catch(e) {}
}
if (pageWindow.webkitRTCPeerConnection) {
try {
pageWindow.webkitRTCPeerConnection = pageWindow.RTCPeerConnection;
} catch(e) {}
if (pageWindow.webkitRTCPeerConnection) {
try {
Object.defineProperty(pageWindow, "webkitRTCPeerConnection", {
value: wrappedRTC, writable: true, configurable: true, enumerable: true
});
} catch(e) {}
}
}
}
@@ -465,4 +490,187 @@
}, pageWindow.Element.prototype, { defineAs: "getClientRects" });
}
// =========================================================================
// SPEECH SYNTHESIS FINGERPRINT PROTECTION
// =========================================================================
// speechSynthesis.getVoices() reveals installed TTS voices (OS/locale-specific)
if (vectorEnabled("navigator") && pageWindow.speechSynthesis) {
try {
Object.defineProperty(pageWindow.speechSynthesis, "getVoices", {
value: exportFunction(function() {
return cloneInto([], pageWindow);
}, pageWindow),
configurable: true,
enumerable: true
});
// Also suppress the voiceschanged event
Object.defineProperty(pageWindow.speechSynthesis, "onvoiceschanged", {
get: exportFunction(function() { return null; }, pageWindow),
set: exportFunction(function() {}, pageWindow),
configurable: true
});
} catch(e) {}
}
// =========================================================================
// MATCHMEDIA SCREEN OVERRIDE
// =========================================================================
// CSS media queries for screen dimensions bypass JS screen overrides.
// Override matchMedia to return spoofed results for screen dimension queries.
if (vectorEnabled("screen") && CONFIG.screen) {
const origMatchMedia = window.matchMedia;
const sw = CONFIG.screen.width;
const sh = CONFIG.screen.height;
const cd = CONFIG.screen.colorDepth;
exportFunction(function(query) {
// Replace real screen dimensions in the query with spoofed values
// so media query evaluation uses the spoofed screen size
let spoofedQuery = query;
try {
// For direct dimension checks: (min-width: 1920px), (max-width: 1920px), etc.
// We can't truly change the CSS engine, but we can make matchMedia().matches
// return consistent results with our spoofed screen values
const result = origMatchMedia.call(this, query);
const origMatches = result.matches;
// Check if this is a screen dimension/color query we should intercept
const isDimensionQuery = /\b(width|height|device-width|device-height|resolution|color)\b/i.test(query);
if (!isDimensionQuery) return result;
// Evaluate the query against our spoofed values
let spoofedMatches = origMatches;
// Parse simple dimension queries and evaluate against spoofed values
const minW = query.match(/min-(?:device-)?width:\s*(\d+)px/i);
const maxW = query.match(/max-(?:device-)?width:\s*(\d+)px/i);
const minH = query.match(/min-(?:device-)?height:\s*(\d+)px/i);
const maxH = query.match(/max-(?:device-)?height:\s*(\d+)px/i);
const colorMatch = query.match(/\(color:\s*(\d+)\)/i);
const minColor = query.match(/min-color:\s*(\d+)/i);
if (minW || maxW || minH || maxH || colorMatch || minColor) {
spoofedMatches = true;
if (minW && sw < parseInt(minW[1])) spoofedMatches = false;
if (maxW && sw > parseInt(maxW[1])) spoofedMatches = false;
if (minH && sh < parseInt(minH[1])) spoofedMatches = false;
if (maxH && sh > parseInt(maxH[1])) spoofedMatches = false;
if (colorMatch && cd !== parseInt(colorMatch[1])) spoofedMatches = false;
if (minColor && cd < parseInt(minColor[1])) spoofedMatches = false;
}
if (spoofedMatches !== origMatches) {
// Return a spoofed MediaQueryList
try {
Object.defineProperty(result, "matches", {
get: function() { return spoofedMatches; },
configurable: true
});
} catch(e) {}
}
return result;
} catch(e) {
return origMatchMedia.call(this, query);
}
}, pageWindow, { defineAs: "matchMedia" });
}
// =========================================================================
// WEBGL EXTENDED FINGERPRINT PROTECTION
// =========================================================================
// Beyond vendor/renderer, WebGL exposes max parameters and extensions
// that vary per GPU and can be used for fingerprinting.
if (vectorEnabled("webgl")) {
function patchWebGLExtended(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",
"EXT_float_blend", "EXT_frag_depth", "EXT_shader_texture_lod",
"EXT_texture_filter_anisotropic", "OES_element_index_uint",
"OES_standard_derivatives", "OES_texture_float", "OES_texture_float_linear",
"OES_texture_half_float", "OES_texture_half_float_linear",
"OES_vertex_array_object", "WEBGL_color_buffer_float",
"WEBGL_compressed_texture_s3tc", "WEBGL_debug_renderer_info",
"WEBGL_depth_texture", "WEBGL_draw_buffers", "WEBGL_lose_context"
];
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");
}
// =========================================================================
// PERFORMANCE TIMING PROTECTION
// =========================================================================
// Reduce performance.now() precision to limit timing-based fingerprinting
if (vectorEnabled("navigator")) {
const origPerfNow = window.Performance.prototype.now;
try {
exportFunction(function() {
// Round to 100μs precision (0.1ms) — enough for general use,
// prevents sub-millisecond timing fingerprints
const t = origPerfNow.call(this);
return Math.round(t * 10) / 10;
}, pageWindow.Performance.prototype, { defineAs: "now" });
} catch(e) {}
}
// =========================================================================
// STORAGE ESTIMATE PROTECTION
// =========================================================================
// navigator.storage.estimate() reveals disk usage patterns
if (vectorEnabled("navigator") && pageWindow.navigator.storage) {
try {
const origEstimate = window.StorageManager.prototype.estimate;
exportFunction(function() {
// Return a generic estimate that doesn't reveal actual storage
return new pageWindow.Promise(exportFunction(function(resolve) {
resolve(cloneInto({
quota: 2147483648, // 2GB — common default
usage: 0
}, pageWindow));
}, pageWindow));
}, pageWindow.StorageManager.prototype, { defineAs: "estimate" });
} catch(e) {}
}
})();

935
test/fingerprint-test.html Normal file
View File

@@ -0,0 +1,935 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ContainSite Fingerprint Test</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5;
}
h1 { color: #58a6ff; margin-bottom: 4px; font-size: 22px; }
.subtitle { color: #8b949e; margin-bottom: 20px; font-size: 13px; }
.hash-banner {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 16px; margin-bottom: 20px; text-align: center;
}
.hash-banner .label { color: #8b949e; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
.hash-banner .hash { color: #f0883e; font-size: 20px; font-family: monospace; margin-top: 4px; word-break: break-all; }
.hash-banner .hint { color: #8b949e; font-size: 11px; margin-top: 8px; }
.section {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
margin-bottom: 16px; overflow: hidden;
}
.section-header {
padding: 12px 16px; border-bottom: 1px solid #30363d;
display: flex; align-items: center; gap: 10px;
}
.section-header h2 { font-size: 14px; color: #c9d1d9; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.pass { background: #3fb950; }
.status-dot.fail { background: #f85149; }
.status-dot.warn { background: #d29922; }
.status-dot.pending { background: #484f58; }
.section-body { padding: 12px 16px; }
.row {
display: flex; justify-content: space-between; align-items: baseline;
padding: 4px 0; border-bottom: 1px solid #21262d; font-size: 13px;
}
.row:last-child { border-bottom: none; }
.row .key { color: #8b949e; }
.row .val { color: #c9d1d9; font-family: monospace; text-align: right; max-width: 60%; word-break: break-all; }
.row .val.spoofed { color: #3fb950; }
.row .val.real { color: #f85149; }
canvas { display: none; }
.canvas-preview { display: flex; gap: 12px; margin-top: 8px; }
.canvas-preview canvas { display: block; border: 1px solid #30363d; border-radius: 4px; }
.summary-bar {
display: flex; gap: 16px; padding: 12px 16px; background: #161b22;
border: 1px solid #30363d; border-radius: 8px; margin-bottom: 20px;
font-size: 13px;
}
.summary-bar .item { display: flex; align-items: center; gap: 6px; }
.copy-btn {
background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px;
margin-top: 12px;
}
.copy-btn:hover { background: #30363d; }
#webrtc-results .val { font-size: 12px; }
</style>
</head>
<body>
<h1>ContainSite Fingerprint Test</h1>
<p class="subtitle">Open this page in multiple container tabs and compare the composite hash below.</p>
<div class="hash-banner">
<div class="label">Composite Fingerprint Hash</div>
<div class="hash" id="composite-hash">Computing...</div>
<div class="hint">This hash should be DIFFERENT in each container tab. If it's the same, spoofing is not working.</div>
</div>
<div class="summary-bar" id="summary-bar">
<div class="item"><span class="status-dot pending" id="sum-pass"></span> <span id="sum-pass-n">-</span> Spoofed</div>
<div class="item"><span class="status-dot pending" id="sum-fail"></span> <span id="sum-fail-n">-</span> Not Spoofed</div>
<div class="item"><span class="status-dot pending" id="sum-warn"></span> <span id="sum-warn-n">-</span> Warning</div>
</div>
<!-- Navigator -->
<div class="section" id="sec-navigator">
<div class="section-header"><span class="status-dot pending" id="dot-navigator"></span><h2>Navigator</h2></div>
<div class="section-body" id="navigator-results"></div>
</div>
<!-- Screen -->
<div class="section" id="sec-screen">
<div class="section-header"><span class="status-dot pending" id="dot-screen"></span><h2>Screen</h2></div>
<div class="section-body" id="screen-results"></div>
</div>
<!-- Canvas -->
<div class="section" id="sec-canvas">
<div class="section-header"><span class="status-dot pending" id="dot-canvas"></span><h2>Canvas</h2></div>
<div class="section-body" id="canvas-results"></div>
</div>
<!-- WebGL -->
<div class="section" id="sec-webgl">
<div class="section-header"><span class="status-dot pending" id="dot-webgl"></span><h2>WebGL</h2></div>
<div class="section-body" id="webgl-results"></div>
</div>
<!-- Audio -->
<div class="section" id="sec-audio">
<div class="section-header"><span class="status-dot pending" id="dot-audio"></span><h2>Audio</h2></div>
<div class="section-body" id="audio-results"></div>
</div>
<!-- Timezone -->
<div class="section" id="sec-timezone">
<div class="section-header"><span class="status-dot pending" id="dot-timezone"></span><h2>Timezone</h2></div>
<div class="section-body" id="timezone-results"></div>
</div>
<!-- Fonts -->
<div class="section" id="sec-fonts">
<div class="section-header"><span class="status-dot pending" id="dot-fonts"></span><h2>Fonts (measureText)</h2></div>
<div class="section-body" id="fonts-results"></div>
</div>
<!-- ClientRects -->
<div class="section" id="sec-rects">
<div class="section-header"><span class="status-dot pending" id="dot-rects"></span><h2>ClientRects</h2></div>
<div class="section-body" id="rects-results"></div>
</div>
<!-- Plugins -->
<div class="section" id="sec-plugins">
<div class="section-header"><span class="status-dot pending" id="dot-plugins"></span><h2>Plugins</h2></div>
<div class="section-body" id="plugins-results"></div>
</div>
<!-- Battery -->
<div class="section" id="sec-battery">
<div class="section-header"><span class="status-dot pending" id="dot-battery"></span><h2>Battery</h2></div>
<div class="section-body" id="battery-results"></div>
</div>
<!-- Connection -->
<div class="section" id="sec-connection">
<div class="section-header"><span class="status-dot pending" id="dot-connection"></span><h2>Connection</h2></div>
<div class="section-body" id="connection-results"></div>
</div>
<!-- WebRTC -->
<div class="section" id="sec-webrtc">
<div class="section-header"><span class="status-dot pending" id="dot-webrtc"></span><h2>WebRTC Leak Test</h2></div>
<div class="section-body" id="webrtc-results"></div>
</div>
<!-- HTTP Headers -->
<div class="section" id="sec-headers">
<div class="section-header"><span class="status-dot pending" id="dot-headers"></span><h2>HTTP Headers (UA + Accept-Language)</h2></div>
<div class="section-body" id="headers-results"></div>
</div>
<!-- Speech Synthesis -->
<div class="section" id="sec-speech">
<div class="section-header"><span class="status-dot pending" id="dot-speech"></span><h2>Speech Synthesis</h2></div>
<div class="section-body" id="speech-results"></div>
</div>
<!-- matchMedia -->
<div class="section" id="sec-matchmedia">
<div class="section-header"><span class="status-dot pending" id="dot-matchmedia"></span><h2>matchMedia Screen Queries</h2></div>
<div class="section-body" id="matchmedia-results"></div>
</div>
<!-- Performance -->
<div class="section" id="sec-perf">
<div class="section-header"><span class="status-dot pending" id="dot-perf"></span><h2>Performance Timing</h2></div>
<div class="section-body" id="perf-results"></div>
</div>
<!-- Storage Estimate -->
<div class="section" id="sec-storage">
<div class="section-header"><span class="status-dot pending" id="dot-storage"></span><h2>Storage Estimate</h2></div>
<div class="section-body" id="storage-results"></div>
</div>
<button class="copy-btn" onclick="copyReport()">Copy Full Report to Clipboard</button>
<canvas id="test-canvas" width="300" height="60"></canvas>
<span id="rect-probe" style="position:absolute;visibility:hidden;font-size:16px;">ABCDEFghijklmnop</span>
<script>
// Utility: simple hash for display (works on HTTP — no crypto.subtle needed)
function simpleHash(str) {
let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (h2 >>> 0).toString(16).padStart(8, "0") + (h1 >>> 0).toString(16).padStart(8, "0");
}
async function sha256Short(str) {
// Use crypto.subtle if available (HTTPS), otherwise fall back to simpleHash
if (crypto.subtle) {
try {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
const arr = Array.from(new Uint8Array(buf));
return arr.map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
} catch(e) {}
}
return simpleHash(str);
}
// Utility: wrap async test with a timeout
function withTimeout(fn, ms) {
return Promise.race([
fn(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out after " + ms + "ms")), ms))
]);
}
function row(key, val, cls) {
return `<div class="row"><span class="key">${key}</span><span class="val ${cls || ''}">${val}</span></div>`;
}
function setDot(id, status) {
const el = document.getElementById(id);
el.className = "status-dot " + status;
}
const report = {};
const sectionStatus = {};
// ── Navigator ──
function testNavigator() {
const el = document.getElementById("navigator-results");
const n = navigator;
const vals = {
"userAgent": n.userAgent,
"platform": n.platform,
"oscpu": n.oscpu || "(undefined)",
"appVersion": n.appVersion,
"hardwareConcurrency": n.hardwareConcurrency,
"deviceMemory": n.deviceMemory || "(undefined)",
"maxTouchPoints": n.maxTouchPoints,
"language": n.language,
"languages": JSON.stringify(n.languages),
};
report.navigator = vals;
let html = "";
for (const [k, v] of Object.entries(vals)) {
html += row(k, v);
}
el.innerHTML = html;
// If platform matches real system, likely not spoofed
const spoofed = n.platform !== "Linux x86_64" || n.hardwareConcurrency !== 4;
sectionStatus.navigator = spoofed ? "pass" : "fail";
setDot("dot-navigator", sectionStatus.navigator);
}
// ── Screen ──
function testScreen() {
const el = document.getElementById("screen-results");
const vals = {
"screen.width": screen.width,
"screen.height": screen.height,
"screen.availWidth": screen.availWidth,
"screen.availHeight": screen.availHeight,
"screen.colorDepth": screen.colorDepth,
"screen.pixelDepth": screen.pixelDepth,
"window.outerWidth": window.outerWidth,
"window.outerHeight": window.outerHeight,
"window.innerWidth": window.innerWidth,
"window.innerHeight": window.innerHeight,
};
report.screen = vals;
let html = "";
for (const [k, v] of Object.entries(vals)) {
html += row(k, v);
}
el.innerHTML = html;
const spoofed = screen.width !== 1920 || screen.height !== 1080;
sectionStatus.screen = spoofed ? "pass" : "fail";
setDot("dot-screen", sectionStatus.screen);
}
// ── Canvas ──
async function testCanvas() {
const el = document.getElementById("canvas-results");
const c = document.getElementById("test-canvas");
const ctx = c.getContext("2d");
// Draw a standard test pattern
ctx.fillStyle = "#f06d06";
ctx.fillRect(0, 0, 300, 60);
ctx.fillStyle = "#1a1a2e";
ctx.font = "18px Arial";
ctx.fillText("ContainSite Canvas Test 🎨", 10, 35);
ctx.strokeStyle = "#16213e";
ctx.beginPath();
ctx.arc(250, 30, 20, 0, Math.PI * 2);
ctx.stroke();
const dataUrl = c.toDataURL();
const hash = await sha256Short(dataUrl);
report.canvas = { hash, dataUrl_tail: dataUrl.slice(-40) };
el.innerHTML = row("Canvas hash", hash) +
`<div class="canvas-preview"><canvas id="vis-canvas" width="300" height="60" style="display:block"></canvas></div>`;
// Redraw on visible canvas
const vc = document.getElementById("vis-canvas");
const vctx = vc.getContext("2d");
vctx.fillStyle = "#f06d06";
vctx.fillRect(0, 0, 300, 60);
vctx.fillStyle = "#1a1a2e";
vctx.font = "18px Arial";
vctx.fillText("ContainSite Canvas Test", 10, 35);
vctx.strokeStyle = "#16213e";
vctx.beginPath();
vctx.arc(250, 30, 20, 0, Math.PI * 2);
vctx.stroke();
// We can't definitively know if it's spoofed without a baseline,
// but we report the hash for cross-container comparison
sectionStatus.canvas = "pass"; // hash present = vector active (user compares manually)
setDot("dot-canvas", "pass");
}
// ── WebGL ──
function testWebGL() {
const el = document.getElementById("webgl-results");
const c = document.createElement("canvas");
const gl = c.getContext("webgl") || c.getContext("experimental-webgl");
if (!gl) {
el.innerHTML = row("Status", "WebGL not available", "fail");
sectionStatus.webgl = "warn";
setDot("dot-webgl", "warn");
return;
}
const dbgExt = gl.getExtension("WEBGL_debug_renderer_info");
const vendor = dbgExt ? gl.getParameter(dbgExt.UNMASKED_VENDOR_WEBGL) : "(ext not available)";
const renderer = dbgExt ? gl.getParameter(dbgExt.UNMASKED_RENDERER_WEBGL) : "(ext not available)";
const vals = { vendor, renderer };
report.webgl = vals;
el.innerHTML = row("Unmasked Vendor", vendor) + row("Unmasked Renderer", renderer);
// Check if it matches known spoofed patterns from the extension
const knownSpoofed = /ANGLE|Mesa|Apple M/i.test(renderer) && !/llvmpipe/i.test(renderer);
sectionStatus.webgl = knownSpoofed ? "pass" : "warn";
setDot("dot-webgl", sectionStatus.webgl);
}
// ── Audio ──
async function testAudio() {
const el = document.getElementById("audio-results");
try {
await withTimeout(async () => {
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 44100, 44100);
const osc = ctx.createOscillator();
osc.type = "triangle";
osc.frequency.setValueAtTime(10000, ctx.currentTime);
const comp = ctx.createDynamicsCompressor();
comp.threshold.setValueAtTime(-50, ctx.currentTime);
comp.knee.setValueAtTime(40, ctx.currentTime);
comp.ratio.setValueAtTime(12, ctx.currentTime);
comp.attack.setValueAtTime(0, ctx.currentTime);
comp.release.setValueAtTime(0.25, ctx.currentTime);
osc.connect(comp);
comp.connect(ctx.destination);
osc.start(0);
const rendered = await ctx.startRendering();
const data = rendered.getChannelData(0);
let sum = 0;
for (let i = 4500; i < 5000; i++) sum += Math.abs(data[i]);
const audioHash = await sha256Short(sum.toString());
report.audio = { sampleSum: sum, hash: audioHash };
el.innerHTML = row("Audio fingerprint hash", audioHash) + row("Sample sum (4500-5000)", sum.toFixed(10));
sectionStatus.audio = "pass";
setDot("dot-audio", "pass");
}, 3000);
} catch (e) {
report.audio = { error: e.message };
el.innerHTML = row("Error", e.message, "fail");
sectionStatus.audio = "warn";
setDot("dot-audio", "warn");
}
}
// ── Timezone ──
function testTimezone() {
const el = document.getElementById("timezone-results");
const offset = new Date().getTimezoneOffset();
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone;
const dateStr = new Date().toString();
const vals = {
"getTimezoneOffset()": offset,
"Intl timeZone": resolved,
"Date.toString()": dateStr,
};
report.timezone = vals;
let html = "";
for (const [k, v] of Object.entries(vals)) html += row(k, v);
el.innerHTML = html;
// Real system timezone is likely the local one — just report it
sectionStatus.timezone = "pass";
setDot("dot-timezone", "pass");
}
// ── Fonts ──
function testFonts() {
const el = document.getElementById("fonts-results");
const c = document.createElement("canvas");
const ctx = c.getContext("2d");
const testFonts = ["monospace", "sans-serif", "serif", "Arial", "Courier New", "Georgia"];
const results = {};
for (const font of testFonts) {
ctx.font = `16px ${font}`;
const m = ctx.measureText("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789");
results[font] = m.width;
}
report.fonts = results;
let html = "";
for (const [font, w] of Object.entries(results)) {
// Sub-pixel values (non-integer) suggest noise was added
const hasFraction = w % 1 !== 0;
html += row(font, w.toFixed(6), hasFraction ? "spoofed" : "");
}
el.innerHTML = html;
const anyFractional = Object.values(results).some(w => w % 1 !== 0);
sectionStatus.fonts = anyFractional ? "pass" : "warn";
setDot("dot-fonts", sectionStatus.fonts);
}
// ── ClientRects ──
function testRects() {
const el = document.getElementById("rects-results");
const probe = document.getElementById("rect-probe");
const rect = probe.getBoundingClientRect();
const vals = {
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"top": rect.top,
"left": rect.left,
};
report.rects = vals;
let html = "";
for (const [k, v] of Object.entries(vals)) {
const hasFraction = v % 1 !== 0;
html += row(k, v.toFixed(6), hasFraction ? "spoofed" : "");
}
el.innerHTML = html;
// If noise is applied, values will have non-trivial fractional parts
const anyNoisy = Object.values(vals).some(v => {
const frac = Math.abs(v % 1);
return frac > 0.001 && frac < 0.999;
});
sectionStatus.rects = anyNoisy ? "pass" : "warn";
setDot("dot-rects", sectionStatus.rects);
}
// ── Plugins ──
function testPlugins() {
const el = document.getElementById("plugins-results");
const count = navigator.plugins.length;
const mimeCount = navigator.mimeTypes.length;
report.plugins = { pluginCount: count, mimeTypeCount: mimeCount };
el.innerHTML = row("navigator.plugins.length", count, count === 0 ? "spoofed" : "") +
row("navigator.mimeTypes.length", mimeCount, mimeCount === 0 ? "spoofed" : "");
sectionStatus.plugins = (count === 0 && mimeCount === 0) ? "pass" : "warn";
setDot("dot-plugins", sectionStatus.plugins);
}
// ── Battery ──
async function testBattery() {
const el = document.getElementById("battery-results");
if (!navigator.getBattery) {
report.battery = { status: "API not available" };
el.innerHTML = row("Status", "getBattery not available (good — hidden)", "spoofed");
sectionStatus.battery = "pass";
setDot("dot-battery", "pass");
return;
}
try {
await withTimeout(async () => {
const bat = await navigator.getBattery();
const vals = {
"charging": bat.charging,
"chargingTime": bat.chargingTime,
"dischargingTime": bat.dischargingTime,
"level": bat.level,
};
report.battery = vals;
let html = "";
for (const [k, v] of Object.entries(vals)) html += row(k, String(v));
el.innerHTML = html;
const spoofed = bat.charging === true && bat.level === 1.0 && bat.chargingTime === 0;
sectionStatus.battery = spoofed ? "pass" : "fail";
setDot("dot-battery", sectionStatus.battery);
}, 3000);
} catch (e) {
report.battery = { error: e.message };
el.innerHTML = row("Error", e.message);
sectionStatus.battery = "warn";
setDot("dot-battery", "warn");
}
}
// ── Connection ──
function testConnection() {
const el = document.getElementById("connection-results");
const conn = navigator.connection;
if (!conn) {
el.innerHTML = row("Status", "navigator.connection not available", "");
sectionStatus.connection = "warn";
setDot("dot-connection", "warn");
return;
}
const vals = {
"effectiveType": conn.effectiveType,
"downlink": conn.downlink,
"rtt": conn.rtt,
"saveData": conn.saveData,
};
report.connection = vals;
let html = "";
for (const [k, v] of Object.entries(vals)) html += row(k, String(v));
el.innerHTML = html;
const spoofed = conn.effectiveType === "4g" && conn.downlink === 10 && conn.rtt === 50;
sectionStatus.connection = spoofed ? "pass" : "warn";
setDot("dot-connection", sectionStatus.connection);
}
// ── WebRTC ──
async function testWebRTC() {
const el = document.getElementById("webrtc-results");
if (!window.RTCPeerConnection) {
report.webrtc = { status: "API blocked" };
el.innerHTML = row("Status", "RTCPeerConnection not available (good — blocked)", "spoofed");
sectionStatus.webrtc = "pass";
setDot("dot-webrtc", "pass");
return;
}
let pc;
try {
await withTimeout(async () => {
const candidates = [];
pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
});
pc.createDataChannel("");
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
pc.onicecandidate = (e) => {
if (e.candidate) {
candidates.push(e.candidate.candidate);
} else {
clearTimeout(timeout);
resolve();
}
};
});
pc.close();
pc = null;
report.webrtc = { candidates };
if (candidates.length === 0) {
el.innerHTML = row("ICE candidates", "None gathered (relay-only mode active)", "spoofed");
sectionStatus.webrtc = "pass";
} else {
const localIPs = [];
const publicIPs = [];
const mdnsAddrs = [];
let hasHost = false, hasSrflx = false, hasRelay = false;
for (const c of candidates) {
if (/typ host/.test(c)) hasHost = true;
if (/typ srflx/.test(c)) hasSrflx = true;
if (/typ relay/.test(c)) hasRelay = true;
// Check for mDNS .local addresses (obfuscated local IPs)
const mdns = c.match(/([a-f0-9-]+\.local)/);
if (mdns) mdnsAddrs.push(mdns[1]);
const match = c.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
if (match) {
const ip = match[1];
if (/^(0\.0\.0\.0)$/.test(ip)) continue; // raddr placeholder
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|127\.)/.test(ip)) {
localIPs.push(ip);
} else {
publicIPs.push(ip);
}
}
// Check for IPv6 addresses
const ipv6 = c.match(/([0-9a-f]{4}:[0-9a-f:]+)/i);
if (ipv6) publicIPs.push(ipv6[1]);
}
let html = "";
if (publicIPs.length > 0) {
html += row("PUBLIC IP LEAKED", publicIPs.join(", "), "real");
}
if (localIPs.length > 0) {
html += row("LOCAL IP LEAKED", localIPs.join(", "), "real");
}
if (mdnsAddrs.length > 0) {
html += row("mDNS addresses", mdnsAddrs.join(", "));
}
html += row("Candidate types",
(hasHost ? "host " : "") + (hasSrflx ? "srflx " : "") + (hasRelay ? "relay " : ""),
hasHost || hasSrflx ? "real" : "spoofed");
html += row("Total candidates", candidates.length);
for (const c of candidates) {
html += row("", c);
}
el.innerHTML = html;
// FAIL if any non-relay candidates or any public IPs leaked
sectionStatus.webrtc = (publicIPs.length > 0 || localIPs.length > 0 || hasHost || hasSrflx) ? "fail" : "pass";
}
setDot("dot-webrtc", sectionStatus.webrtc);
}, 5000);
} catch (e) {
if (pc) try { pc.close(); } catch(_) {}
report.webrtc = { error: e.message };
el.innerHTML = row("Status", e.message);
sectionStatus.webrtc = "warn";
setDot("dot-webrtc", "warn");
}
}
// ── HTTP Headers ──
async function testHeaders() {
const el = document.getElementById("headers-results");
try {
// Fetch a resource and check what headers the server sees
// We use a self-request to inspect our own headers via the server
// Since we can't see outgoing headers directly, we check for coherence:
// The JS navigator.userAgent should match what's sent in HTTP
const jsUA = navigator.userAgent;
const jsLang = navigator.languages ? navigator.languages.join(",") : navigator.language;
// We can detect the HTTP Accept-Language by examining what the browser sends
// For now, just report the JS values and note that HTTP header spoofing
// requires checking server-side (e.g., httpbin.org/headers)
const vals = {
"JS navigator.userAgent": jsUA,
"JS navigator.languages": jsLang,
"HTTP header check": "Visit httpbin.org/headers to verify match"
};
report.headers = vals;
let html = row("JS navigator.userAgent", jsUA);
html += row("JS navigator.languages", jsLang);
// Try fetching our own page to see if headers are modified
try {
const resp = await fetch(window.location.href, { method: "HEAD" });
html += row("Fetch completed", "Headers sent with spoofed values (verify server-side)", "spoofed");
} catch(e) {
html += row("Fetch test", "Could not verify: " + e.message);
}
el.innerHTML = html;
// If the JS UA doesn't match the real browser, headers are likely spoofed too
const isSpoofed = !/rv:148\.0/.test(jsUA);
sectionStatus.headers = isSpoofed ? "pass" : "fail";
setDot("dot-headers", sectionStatus.headers);
} catch(e) {
report.headers = { error: e.message };
el.innerHTML = row("Error", e.message);
sectionStatus.headers = "warn";
setDot("dot-headers", "warn");
}
}
// ── Speech Synthesis ──
function testSpeech() {
const el = document.getElementById("speech-results");
if (!window.speechSynthesis) {
report.speech = { status: "API not available" };
el.innerHTML = row("Status", "speechSynthesis not available", "");
sectionStatus.speech = "warn";
setDot("dot-speech", "warn");
return;
}
const voices = speechSynthesis.getVoices();
const count = voices.length;
report.speech = { voiceCount: count };
el.innerHTML = row("speechSynthesis.getVoices().length", count, count === 0 ? "spoofed" : "real");
sectionStatus.speech = count === 0 ? "pass" : "fail";
setDot("dot-speech", sectionStatus.speech);
}
// ── matchMedia ──
function testMatchMedia() {
const el = document.getElementById("matchmedia-results");
const screenW = screen.width;
const screenH = screen.height;
// Test if matchMedia agrees with our spoofed screen dimensions
const wideEnough = matchMedia(`(min-width: ${screenW}px)`).matches;
const notTooWide = matchMedia(`(max-width: ${screenW}px)`).matches;
const tallEnough = matchMedia(`(min-height: ${screenH}px)`).matches;
// Test against real dimensions — should NOT match if spoofed
const realWCheck = matchMedia("(min-width: 1920px)").matches;
const realHCheck = matchMedia("(min-height: 1080px)").matches;
const vals = {
[`(min-width: ${screenW}px)`]: wideEnough,
[`(max-width: ${screenW}px)`]: notTooWide,
[`(min-height: ${screenH}px)`]: tallEnough,
"(min-width: 1920px) [real]": realWCheck,
"(min-height: 1080px) [real]": realHCheck,
};
report.matchmedia = vals;
let html = "";
html += row(`min-width: ${screenW}px`, wideEnough ? "matches" : "no match",
wideEnough ? "spoofed" : "real");
html += row(`max-width: ${screenW}px`, notTooWide ? "matches" : "no match");
html += row(`min-height: ${screenH}px`, tallEnough ? "matches" : "no match",
tallEnough ? "spoofed" : "real");
html += row("min-width: 1920px [real check]", realWCheck ? "matches (real leak)" : "no match (good)",
realWCheck && screenW !== 1920 ? "real" : "spoofed");
el.innerHTML = html;
// Pass if spoofed dimensions are consistent with matchMedia
sectionStatus.matchmedia = wideEnough ? "pass" : "warn";
setDot("dot-matchmedia", sectionStatus.matchmedia);
}
// ── Performance Timing ──
function testPerf() {
const el = document.getElementById("perf-results");
// Take multiple samples and check precision
const samples = [];
for (let i = 0; i < 20; i++) samples.push(performance.now());
// Check the decimal precision of the samples
const precisions = samples.map(s => {
const str = s.toString();
const dot = str.indexOf(".");
return dot === -1 ? 0 : str.length - dot - 1;
});
const maxPrecision = Math.max(...precisions);
// Check if values are rounded (reduced precision)
const allRounded = samples.every(s => {
const frac = s % 0.1;
return Math.abs(frac) < 0.001 || Math.abs(frac - 0.1) < 0.001;
});
report.perf = {
sampleCount: samples.length,
maxDecimalPrecision: maxPrecision,
appearsRounded: allRounded,
sampleRange: `${samples[0].toFixed(3)} - ${samples[samples.length-1].toFixed(3)}`
};
let html = row("Max decimal precision", maxPrecision + " digits");
html += row("Values rounded to 0.1ms", allRounded ? "yes" : "no", allRounded ? "spoofed" : "");
html += row("Sample range", `${samples[0].toFixed(3)} - ${samples[samples.length-1].toFixed(3)}`);
el.innerHTML = html;
sectionStatus.perf = allRounded ? "pass" : "warn";
setDot("dot-perf", sectionStatus.perf);
}
// ── Storage Estimate ──
async function testStorage() {
const el = document.getElementById("storage-results");
if (!navigator.storage || !navigator.storage.estimate) {
report.storage = { status: "API not available" };
el.innerHTML = row("Status", "storage.estimate not available", "");
sectionStatus.storage = "warn";
setDot("dot-storage", "warn");
return;
}
try {
await withTimeout(async () => {
const est = await navigator.storage.estimate();
report.storage = { quota: est.quota, usage: est.usage };
const isSpoofed = est.quota === 2147483648 && est.usage === 0;
el.innerHTML = row("quota", est.quota.toLocaleString() + " bytes", isSpoofed ? "spoofed" : "") +
row("usage", est.usage.toLocaleString() + " bytes", isSpoofed ? "spoofed" : "");
sectionStatus.storage = isSpoofed ? "pass" : "warn";
setDot("dot-storage", sectionStatus.storage);
}, 3000);
} catch(e) {
report.storage = { error: e.message };
el.innerHTML = row("Error", e.message);
sectionStatus.storage = "warn";
setDot("dot-storage", "warn");
}
}
// ── Summary ──
function updateSummary() {
let pass = 0, fail = 0, warn = 0;
for (const s of Object.values(sectionStatus)) {
if (s === "pass") pass++;
else if (s === "fail") fail++;
else warn++;
}
document.getElementById("sum-pass-n").textContent = pass;
document.getElementById("sum-fail-n").textContent = fail;
document.getElementById("sum-warn-n").textContent = warn;
document.getElementById("sum-pass").className = "status-dot " + (pass > 0 ? "pass" : "pending");
document.getElementById("sum-fail").className = "status-dot " + (fail > 0 ? "fail" : "pending");
document.getElementById("sum-warn").className = "status-dot " + (warn > 0 ? "warn" : "pending");
}
// ── Composite hash ──
async function computeComposite() {
const composite = JSON.stringify(report);
const hash = await sha256Short(composite);
document.getElementById("composite-hash").textContent = hash;
}
// ── Build report text ──
function buildReportText() {
const lines = [`ContainSite Fingerprint Report — ${new Date().toISOString()}`, ""];
for (const [section, data] of Object.entries(report)) {
const status = sectionStatus[section] || "?";
lines.push(`[${status.toUpperCase()}] ${section}`);
if (typeof data === "object") {
for (const [k, v] of Object.entries(data)) {
lines.push(` ${k}: ${v}`);
}
}
lines.push("");
}
lines.push("Composite hash: " + document.getElementById("composite-hash").textContent);
return lines.join("\n");
}
// ── Copy report (textarea fallback for HTTP) ──
function copyReport() {
const text = buildReportText();
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
const btn = document.querySelector(".copy-btn");
btn.textContent = "Copied!";
setTimeout(() => btn.textContent = "Copy Full Report to Clipboard", 2000);
} catch(e) {
// If copy also fails, show the report in a prompt
prompt("Copy this report:", text);
}
document.body.removeChild(ta);
}
// ── Run all ──
async function runAll() {
// Sync tests first
try { testNavigator(); } catch(e) { console.error("navigator test:", e); }
try { testScreen(); } catch(e) { console.error("screen test:", e); }
try { testWebGL(); } catch(e) { console.error("webgl test:", e); }
try { testTimezone(); } catch(e) { console.error("timezone test:", e); }
try { testFonts(); } catch(e) { console.error("fonts test:", e); }
try { testRects(); } catch(e) { console.error("rects test:", e); }
try { testPlugins(); } catch(e) { console.error("plugins test:", e); }
try { testConnection(); } catch(e) { console.error("connection test:", e); }
try { testSpeech(); } catch(e) { console.error("speech test:", e); }
try { testMatchMedia(); } catch(e) { console.error("matchmedia test:", e); }
try { testPerf(); } catch(e) { console.error("perf test:", e); }
// Async tests — run in parallel with individual error handling
await Promise.allSettled([
testCanvas(),
testAudio(),
testBattery(),
testWebRTC(),
testHeaders(),
testStorage(),
]);
updateSummary();
await computeComposite();
}
runAll();
</script>
</body>
</html>