Add font enumeration hardening, document.fonts protection, Client Hints stripping, and WebGL parameter normalization

- Font enumeration: seeded noise on offsetWidth/Height, scrollWidth/Height, clientWidth/Height
- document.fonts: check() returns false, size returns 0, forEach is no-op
- Client Hints: strip Sec-CH-UA/Full-Version-List, override Platform/Mobile per container
- WebGL: merge PARAM_OVERRIDES into getParameter (MAX_TEXTURE_SIZE, attribs, etc.)
- Clean up dead code in WebGL extended section
- Test page: add Font DOM, document.fonts, and Client Hints test sections
- README: update vector table (18 vectors), add about:config and testing docs
This commit is contained in:
sal
2026-03-01 15:49:40 -06:00
parent 0c370240c2
commit b09a8248af
4 changed files with 268 additions and 38 deletions

View File

@@ -10,6 +10,7 @@ Every website you visit is automatically placed in its own isolated container wi
- **Unique fingerprints per container** — every container presents a completely different device to websites - **Unique fingerprints per container** — every container presents a completely different device to websites
- **Auth-aware** — login redirects (e.g. YouTube to Google) stay in the originating container so authentication works seamlessly - **Auth-aware** — login redirects (e.g. YouTube to Google) stay in the originating container so authentication works seamlessly
- **Cross-site navigation** — clicking a link to a different domain automatically switches to the correct container - **Cross-site navigation** — clicking a link to a different domain automatically switches to the correct container
- **HTTP header spoofing** — User-Agent, Accept-Language, and Client Hints headers match each container's identity
- **Configurable** — toggle individual fingerprint vectors, whitelist domains, manage containers from the options page - **Configurable** — toggle individual fingerprint vectors, whitelist domains, manage containers from the options page
- **Zero configuration** — install and browse, everything is automatic - **Zero configuration** — install and browse, everything is automatic
@@ -18,17 +19,23 @@ Every website you visit is automatically placed in its own isolated container wi
| Vector | Method | | Vector | Method |
|---|---| |---|---|
| Canvas | Deterministic pixel noise per container seed | | Canvas | Deterministic pixel noise per container seed |
| WebGL | Spoofed GPU vendor and renderer strings | | WebGL | Spoofed GPU vendor, renderer, max parameters, and normalized extensions |
| AudioContext | Seeded noise on frequency and channel data | | AudioContext | Seeded noise on frequency and channel data |
| Navigator | CPU cores, platform, languages, device memory | | Navigator | CPU cores, platform, languages, device memory, oscpu |
| Screen | Resolution, color depth, window dimensions | | Screen | Resolution, color depth, window dimensions |
| Timezone | getTimezoneOffset, Date.toString, Intl.DateTimeFormat | | Timezone | getTimezoneOffset, Date.toString, Intl.DateTimeFormat |
| WebRTC | Forced relay-only ICE policy (blocks local IP leak) | | WebRTC | Forced relay-only ICE policy (blocks local IP leak) |
| Fonts | Noise on measureText (prevents font enumeration) | | Fonts | Noise on measureText + DOM element dimensions (offsetWidth/Height etc.) |
| Font API | document.fonts.check() blocked, size reports 0 |
| ClientRects | Sub-pixel noise on getBoundingClientRect | | ClientRects | Sub-pixel noise on getBoundingClientRect |
| Plugins | Reports empty | | Plugins | Reports empty |
| Battery | Always reports full/charging | | Battery | Always reports full/charging |
| Connection | Fixed network profile | | Connection | Fixed network profile |
| HTTP Headers | User-Agent, Accept-Language spoofed per container; Client Hints stripped |
| Speech Synthesis | getVoices() returns empty, voiceschanged suppressed |
| matchMedia | Screen dimension queries return spoofed values |
| Performance | performance.now() precision reduced to 0.1ms |
| Storage | navigator.storage.estimate() returns generic values |
## How it works ## How it works
@@ -47,15 +54,19 @@ Background Script
├── Auto-creates containers per domain (contextualIdentities API) ├── Auto-creates containers per domain (contextualIdentities API)
├── Generates deterministic fingerprint from seed (Mulberry32 PRNG) ├── Generates deterministic fingerprint from seed (Mulberry32 PRNG)
├── Registers per-container content scripts (contentScripts.register + cookieStoreId) ├── Registers per-container content scripts (contentScripts.register + cookieStoreId)
── Intercepts navigation to assign tabs to containers ── Intercepts navigation to assign tabs to containers
└── Spoofs HTTP headers (User-Agent, Accept-Language, Client Hints) per container
Content Script (per container, ISOLATED world, document_start) Content Script (per container, ISOLATED world, document_start)
└── Uses exportFunction() + wrappedJSObject to override page APIs └── Uses exportFunction() + wrappedJSObject to override page APIs
├── Canvas, WebGL, AudioContext prototypes ├── Canvas, WebGL, AudioContext prototypes
├── Navigator, Screen properties ├── Navigator, Screen, Performance properties
├── Timezone (Date, Intl.DateTimeFormat) ├── Timezone (Date, Intl.DateTimeFormat)
├── WebRTC (RTCPeerConnection) ├── WebRTC (RTCPeerConnection)
── Font metrics, ClientRects, Battery, Connection ── Font metrics (measureText, DOM dimensions, document.fonts)
├── ClientRects, Battery, Connection, Storage
├── Speech synthesis, matchMedia
└── Plugins, mimeTypes
``` ```
Uses Firefox's `exportFunction()` API to inject overrides from the isolated content script world directly into the page context. This bypasses Content Security Policy restrictions that block inline script injection. Uses Firefox's `exportFunction()` API to inject overrides from the isolated content script world directly into the page context. This bypasses Content Security Policy restrictions that block inline script injection.
@@ -92,7 +103,7 @@ Right-click the toolbar icon → **Manage Extension** → **Preferences** to ope
### Fingerprint Vectors ### Fingerprint Vectors
Toggle individual spoofing vectors on or off globally. All 12 vectors can be independently controlled: Toggle individual spoofing vectors on or off globally. Vectors can be independently controlled:
Canvas, WebGL, Audio, Navigator, Screen, Timezone, WebRTC, Fonts, Client Rects, Plugins, Battery, Connection Canvas, WebGL, Audio, Navigator, Screen, Timezone, WebRTC, Fonts, Client Rects, Plugins, Battery, Connection
@@ -113,12 +124,34 @@ Full table of all managed containers with per-container controls:
- Firefox 100+ or LibreWolf - Firefox 100+ or LibreWolf
- Containers must be enabled (`privacy.userContext.enabled = true` in `about:config`) - Containers must be enabled (`privacy.userContext.enabled = true` in `about:config`)
### Recommended about:config settings
For maximum WebRTC leak protection, set these in `about:config`:
| Setting | Value | Purpose |
|---|---|---|
| `media.peerconnection.ice.default_address_only` | `true` | Only use default route for ICE |
| `media.peerconnection.ice.no_host` | `true` | Prevent host candidate gathering |
| `media.peerconnection.ice.proxy_only_if_behind_proxy` | `true` | Force proxy-only mode |
LibreWolf may already have some of these set by default.
## Testing
A built-in test page is included at `test/fingerprint-test.html`. To use it:
1. Load the extension via `about:debugging`
2. Add a hostname alias (e.g. `127.0.0.1 containsite-test.site` in `/etc/hosts`) — localhost is excluded from containerization
3. Start a local server: `python3 -m http.server 8888 --bind 0.0.0.0`
4. Open `http://containsite-test.site:8888/test/fingerprint-test.html` in a regular (non-private) window
5. Open the same URL in a different container tab and compare composite hashes
## File structure ## File structure
``` ```
manifest.json MV2 extension manifest manifest.json MV2 extension manifest
background.js Container management, navigation interception, script registration background.js Container management, navigation, HTTP header spoofing
inject.js Fingerprint overrides (exportFunction-based) inject.js Fingerprint overrides (exportFunction-based, 18 vectors)
lib/ lib/
prng.js Mulberry32 seeded PRNG prng.js Mulberry32 seeded PRNG
fingerprint-gen.js Deterministic seed → device profile generator fingerprint-gen.js Deterministic seed → device profile generator
@@ -130,6 +163,8 @@ options/
options.html Full options page (opens in tab) options.html Full options page (opens in tab)
options.css Styles options.css Styles
options.js Vector toggles, whitelist, container management options.js Vector toggles, whitelist, container management
test/
fingerprint-test.html Comprehensive fingerprint verification page
icons/ icons/
icon-48.png Toolbar icon icon-48.png Toolbar icon
icon-96.png Extension icon icon-96.png Extension icon

View File

@@ -91,7 +91,8 @@ async function buildProfileAndRegister(cookieStoreId, seed) {
// Cache profile for HTTP header spoofing // Cache profile for HTTP header spoofing
containerProfiles[cookieStoreId] = { containerProfiles[cookieStoreId] = {
userAgent: profile.nav.userAgent, userAgent: profile.nav.userAgent,
languages: profile.nav.languages languages: profile.nav.languages,
platform: profile.nav.platform
}; };
await registerForContainer(cookieStoreId, profile); await registerForContainer(cookieStoreId, profile);
@@ -450,12 +451,25 @@ browser.webRequest.onBeforeSendHeaders.addListener(
if (!profile) return {}; if (!profile) return {};
const headers = details.requestHeaders; const headers = details.requestHeaders;
for (let i = 0; i < headers.length; i++) { // Map platform to Client Hints platform name
const platformMap = {
"Win32": "Windows", "Linux x86_64": "Linux", "MacIntel": "macOS"
};
const chPlatform = platformMap[profile.platform] || "Unknown";
for (let i = headers.length - 1; i >= 0; i--) {
const name = headers[i].name.toLowerCase(); const name = headers[i].name.toLowerCase();
if (name === "user-agent") { if (name === "user-agent") {
headers[i].value = profile.userAgent; headers[i].value = profile.userAgent;
} else if (name === "accept-language") { } else if (name === "accept-language") {
headers[i].value = formatAcceptLanguage(profile.languages); headers[i].value = formatAcceptLanguage(profile.languages);
} else if (name === "sec-ch-ua" || name === "sec-ch-ua-full-version-list") {
// Firefox doesn't normally send these, but strip if present
headers.splice(i, 1);
} else if (name === "sec-ch-ua-platform") {
headers[i].value = `"${chPlatform}"`;
} else if (name === "sec-ch-ua-mobile") {
headers[i].value = "?0";
} }
} }
return { requestHeaders: headers }; return { requestHeaders: headers };

View File

@@ -92,6 +92,20 @@
const UNMASKED_VENDOR = 0x9245; const UNMASKED_VENDOR = 0x9245;
const UNMASKED_RENDERER = 0x9246; const UNMASKED_RENDERER = 0x9246;
// Normalize key max parameters to common values to prevent GPU fingerprinting
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
};
function patchWebGL(protoName) { function patchWebGL(protoName) {
const pageProto = pageWindow[protoName]; const pageProto = pageWindow[protoName];
if (!pageProto) return; if (!pageProto) return;
@@ -102,6 +116,11 @@
exportFunction(function(pname) { exportFunction(function(pname) {
if (pname === UNMASKED_VENDOR) return CONFIG.webgl.vendor; if (pname === UNMASKED_VENDOR) return CONFIG.webgl.vendor;
if (pname === UNMASKED_RENDERER) return CONFIG.webgl.renderer; if (pname === UNMASKED_RENDERER) return CONFIG.webgl.renderer;
if (PARAM_OVERRIDES[pname] !== undefined) {
// Return the normalized value, but never exceed the real GPU's capability
const real = origGetParam.call(this, pname);
return (typeof real === "number") ? Math.min(real, PARAM_OVERRIDES[pname]) : real;
}
return origGetParam.call(this, pname); return origGetParam.call(this, pname);
}, pageProto.prototype, { defineAs: "getParameter" }); }, pageProto.prototype, { defineAs: "getParameter" });
} }
@@ -443,6 +462,44 @@
return metrics; return metrics;
}, pageWindow.CanvasRenderingContext2D.prototype, { defineAs: "measureText" }); }, pageWindow.CanvasRenderingContext2D.prototype, { defineAs: "measureText" });
// --- DOM Element Dimension Noise ---
// Font enumeration measures offsetWidth/Height of test spans to detect installed fonts.
// Adding seeded noise prevents consistent dimension-based font fingerprinting.
const fontDimProps = ["offsetWidth", "offsetHeight", "scrollWidth", "scrollHeight", "clientWidth", "clientHeight"];
for (const prop of fontDimProps) {
const origDesc = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, prop);
if (origDesc && origDesc.get) {
const origGet = origDesc.get;
Object.defineProperty(pageWindow.HTMLElement.prototype, prop, {
get: exportFunction(function() {
const val = origGet.call(this);
return val + (fontRng() - 0.5) * 0.3;
}, pageWindow),
configurable: true,
enumerable: true
});
}
}
// --- document.fonts (FontFaceSet) API Protection ---
// document.fonts.check() directly reveals installed fonts; size/iterators expose count.
if (pageWindow.document.fonts) {
try {
Object.defineProperty(pageWindow.document.fonts, "check", {
value: exportFunction(function() { return false; }, pageWindow),
configurable: true, enumerable: true
});
Object.defineProperty(pageWindow.document.fonts, "size", {
get: exportFunction(function() { return 0; }, pageWindow),
configurable: true, enumerable: true
});
Object.defineProperty(pageWindow.document.fonts, "forEach", {
value: exportFunction(function() {}, pageWindow),
configurable: true, enumerable: true
});
} catch(e) {}
}
} }
// ========================================================================= // =========================================================================
@@ -580,17 +637,15 @@
// ========================================================================= // =========================================================================
// WEBGL EXTENDED FINGERPRINT PROTECTION // WEBGL EXTENDED FINGERPRINT PROTECTION
// ========================================================================= // =========================================================================
// Beyond vendor/renderer, WebGL exposes max parameters and extensions // Normalize getSupportedExtensions to a common baseline set
// that vary per GPU and can be used for fingerprinting.
if (vectorEnabled("webgl")) { if (vectorEnabled("webgl")) {
function patchWebGLExtended(protoName) { function patchWebGLExtensions(protoName) {
const pageProto = pageWindow[protoName]; const pageProto = pageWindow[protoName];
if (!pageProto) return; if (!pageProto) return;
const origProto = window[protoName]; const origProto = window[protoName];
if (!origProto) return; if (!origProto) return;
// Spoof getSupportedExtensions to return a consistent set
const origGetExtensions = origProto.prototype.getSupportedExtensions; const origGetExtensions = origProto.prototype.getSupportedExtensions;
const BASELINE_EXTENSIONS = [ const BASELINE_EXTENSIONS = [
"ANGLE_instanced_arrays", "EXT_blend_minmax", "EXT_color_buffer_half_float", "ANGLE_instanced_arrays", "EXT_blend_minmax", "EXT_color_buffer_half_float",
@@ -606,34 +661,13 @@
exportFunction(function() { exportFunction(function() {
const real = origGetExtensions.call(this); const real = origGetExtensions.call(this);
if (!real) return real; 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)); const filtered = BASELINE_EXTENSIONS.filter(e => real.includes(e));
return cloneInto(filtered, pageWindow); return cloneInto(filtered, pageWindow);
}, pageProto.prototype, { defineAs: "getSupportedExtensions" }); }, 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"); patchWebGLExtensions("WebGLRenderingContext");
patchWebGLExtended("WebGL2RenderingContext"); patchWebGLExtensions("WebGL2RenderingContext");
} }
// ========================================================================= // =========================================================================

View File

@@ -182,10 +182,29 @@
<div class="section-body" id="storage-results"></div> <div class="section-body" id="storage-results"></div>
</div> </div>
<!-- Font Enumeration (DOM) -->
<div class="section" id="sec-fontdom">
<div class="section-header"><span class="status-dot pending" id="dot-fontdom"></span><h2>Font Enumeration (DOM Dimensions)</h2></div>
<div class="section-body" id="fontdom-results"></div>
</div>
<!-- document.fonts -->
<div class="section" id="sec-docfonts">
<div class="section-header"><span class="status-dot pending" id="dot-docfonts"></span><h2>document.fonts API</h2></div>
<div class="section-body" id="docfonts-results"></div>
</div>
<!-- Client Hints -->
<div class="section" id="sec-clienthints">
<div class="section-header"><span class="status-dot pending" id="dot-clienthints"></span><h2>Client Hints Headers</h2></div>
<div class="section-body" id="clienthints-results"></div>
</div>
<button class="copy-btn" onclick="copyReport()">Copy Full Report to Clipboard</button> <button class="copy-btn" onclick="copyReport()">Copy Full Report to Clipboard</button>
<canvas id="test-canvas" width="300" height="60"></canvas> <canvas id="test-canvas" width="300" height="60"></canvas>
<span id="rect-probe" style="position:absolute;visibility:hidden;font-size:16px;">ABCDEFghijklmnop</span> <span id="rect-probe" style="position:absolute;visibility:hidden;font-size:16px;">ABCDEFghijklmnop</span>
<span id="fontdom-probe" style="position:absolute;visibility:hidden;font-size:72px;font-family:monospace;">mmmmmmmmmmmmmmm</span>
<script> <script>
// Utility: simple hash for display (works on HTTP — no crypto.subtle needed) // Utility: simple hash for display (works on HTTP — no crypto.subtle needed)
@@ -837,6 +856,131 @@ async function testStorage() {
} }
} }
// ── Font Enumeration (DOM) ──
function testFontDOM() {
const el = document.getElementById("fontdom-results");
const probe = document.getElementById("fontdom-probe");
// Measure element dimensions — should have noise if spoofed
const w = probe.offsetWidth;
const h = probe.offsetHeight;
const sw = probe.scrollWidth;
const sh = probe.scrollHeight;
const cw = probe.clientWidth;
const ch = probe.clientHeight;
const vals = {
"offsetWidth": w, "offsetHeight": h,
"scrollWidth": sw, "scrollHeight": sh,
"clientWidth": cw, "clientHeight": ch
};
report.fontdom = vals;
let html = "";
for (const [k, v] of Object.entries(vals)) {
const hasFrac = v % 1 !== 0;
html += row(k, v.toFixed(6), hasFrac ? "spoofed" : "");
}
el.innerHTML = html;
// If noise is applied, at least some values will have fractional parts
const anyFrac = Object.values(vals).some(v => v % 1 !== 0);
sectionStatus.fontdom = anyFrac ? "pass" : "warn";
setDot("dot-fontdom", sectionStatus.fontdom);
}
// ── document.fonts API ──
function testDocFonts() {
const el = document.getElementById("docfonts-results");
if (!document.fonts) {
report.docfonts = { status: "API not available" };
el.innerHTML = row("Status", "document.fonts not available", "");
sectionStatus.docfonts = "warn";
setDot("dot-docfonts", "warn");
return;
}
const size = document.fonts.size;
const checkArial = document.fonts.check("16px Arial");
const checkMono = document.fonts.check("16px monospace");
const vals = {
"document.fonts.size": size,
"check('16px Arial')": checkArial,
"check('16px monospace')": checkMono,
};
report.docfonts = vals;
let html = "";
html += row("document.fonts.size", size, size === 0 ? "spoofed" : "real");
html += row("check('16px Arial')", checkArial, !checkArial ? "spoofed" : "real");
html += row("check('16px monospace')", checkMono, !checkMono ? "spoofed" : "real");
el.innerHTML = html;
const spoofed = size === 0 && !checkArial && !checkMono;
sectionStatus.docfonts = spoofed ? "pass" : "warn";
setDot("dot-docfonts", sectionStatus.docfonts);
}
// ── Client Hints Headers ──
async function testClientHints() {
const el = document.getElementById("clienthints-results");
try {
await withTimeout(async () => {
// Try fetching httpbin.org to see what headers are actually sent
let html = "";
try {
const resp = await fetch("https://httpbin.org/headers", {
method: "GET",
mode: "cors"
});
const data = await resp.json();
const headers = data.headers || {};
const ua = headers["User-Agent"] || "(not sent)";
const al = headers["Accept-Language"] || "(not sent)";
const chUA = headers["Sec-Ch-Ua"] || "(not sent)";
const chPlatform = headers["Sec-Ch-Ua-Platform"] || "(not sent)";
const chMobile = headers["Sec-Ch-Ua-Mobile"] || "(not sent)";
report.clienthints = {
"HTTP User-Agent": ua,
"HTTP Accept-Language": al,
"Sec-CH-UA": chUA,
"Sec-CH-UA-Platform": chPlatform,
"Sec-CH-UA-Mobile": chMobile
};
html += row("HTTP User-Agent", ua);
html += row("HTTP Accept-Language", al);
html += row("Sec-CH-UA", chUA, chUA === "(not sent)" ? "spoofed" : "");
html += row("Sec-CH-UA-Platform", chPlatform, chPlatform === "(not sent)" ? "spoofed" : "");
html += row("Sec-CH-UA-Mobile", chMobile, chMobile === "(not sent)" ? "spoofed" : "");
// Check if HTTP UA matches JS UA
const jsUA = navigator.userAgent;
const uaMatch = ua.includes("Firefox") && jsUA.includes("Firefox");
html += row("JS/HTTP UA coherent", uaMatch ? "yes" : "MISMATCH", uaMatch ? "spoofed" : "real");
sectionStatus.clienthints = uaMatch ? "pass" : "fail";
} catch(e) {
report.clienthints = { note: "httpbin.org fetch failed (CORS/network)" };
html += row("httpbin.org fetch", "Failed: " + e.message);
html += row("Note", "Client Hints are stripped by the extension if present");
sectionStatus.clienthints = "warn";
}
el.innerHTML = html;
setDot("dot-clienthints", sectionStatus.clienthints);
}, 5000);
} catch(e) {
report.clienthints = { error: e.message };
el.innerHTML = row("Error", e.message);
sectionStatus.clienthints = "warn";
setDot("dot-clienthints", "warn");
}
}
// ── Summary ── // ── Summary ──
function updateSummary() { function updateSummary() {
let pass = 0, fail = 0, warn = 0; let pass = 0, fail = 0, warn = 0;
@@ -914,6 +1058,8 @@ async function runAll() {
try { testSpeech(); } catch(e) { console.error("speech test:", e); } try { testSpeech(); } catch(e) { console.error("speech test:", e); }
try { testMatchMedia(); } catch(e) { console.error("matchmedia test:", e); } try { testMatchMedia(); } catch(e) { console.error("matchmedia test:", e); }
try { testPerf(); } catch(e) { console.error("perf test:", e); } try { testPerf(); } catch(e) { console.error("perf test:", e); }
try { testFontDOM(); } catch(e) { console.error("fontdom test:", e); }
try { testDocFonts(); } catch(e) { console.error("docfonts test:", e); }
// Async tests — run in parallel with individual error handling // Async tests — run in parallel with individual error handling
await Promise.allSettled([ await Promise.allSettled([
@@ -923,6 +1069,7 @@ async function runAll() {
testWebRTC(), testWebRTC(),
testHeaders(), testHeaders(),
testStorage(), testStorage(),
testClientHints(),
]); ]);
updateSummary(); updateSummary();