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:
@@ -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
320
inject.js
@@ -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
935
test/fingerprint-test.html
Normal 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>
|
||||
Reference in New Issue
Block a user