Cases / #52 · 2026-10-16 · Medium
Installed fonts as a fingerprint: what a width probe sees on each Camoufox persona
The classic font probe — 46 families measured against the generic fonts — on Camoufox's Windows, macOS and Linux personas, with extra `fonts` and with `custom_fonts_only`. The list follows the OS and is identical every launch; `fonts` can only add what the server has; and one face is always invisible.
Run on production on 2026-10-16: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Which fonts a machine has is one of the oldest fingerprinting signals: a script sets a test string in each candidate family and measures it. A Windows user agent with macOS fonts, or a machine with three fonts in total, stands out. Camoufox builds the font list from its OS persona and lets you add to it or replace it. What does a probe actually see, is it the same every launch, and what do fonts and custom_fonts_only change?
What we used, and why
| What | Why |
|---|---|
| A width probe over 46 families | Each family is set with monospace, serif and sans-serif as fallback; if any measurement differs from the generic family alone, the family counts as installed. This is how common fingerprinting libraries do it. |
os: "windows" | "macos" | "linux" | The persona decides which families the page can see. |
fonts: [...] | Families exposed in addition to the persona's list. |
custom_fonts_only: true | Expose only the families in fonts. |
| A hash of the detected list | One value per launch that a site could store and compare. |
How it works
- Launch Camoufox six times one after another: Windows twice, macOS, Linux, Windows with three extra fonts, and Windows with a custom list only.
- In each, run the width probe over the same 46 candidates and record the detected families, their hash, and whether the three generic families measure the same.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { createHash } from 'node:crypto';
import { firefox } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
// The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
const CANDIDATES = [
'Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara', 'Comic Sans MS', 'Consolas', 'Constantia', 'Corbel', 'Courier New',
'Georgia', 'Impact', 'Lucida Console', 'Lucida Sans Unicode', 'Microsoft Sans Serif', 'Palatino Linotype', 'Segoe UI', 'Tahoma',
'Times New Roman', 'Trebuchet MS', 'Verdana', 'American Typewriter', 'Avenir', 'Avenir Next', 'Futura', 'Geneva', 'Gill Sans',
'Helvetica', 'Helvetica Neue', 'Lucida Grande', 'Menlo', 'Monaco', 'Optima', 'SF Pro', 'DejaVu Sans', 'DejaVu Serif',
'Liberation Sans', 'Liberation Mono', 'Noto Sans', 'Ubuntu', 'Cantarell', 'Roboto', 'Open Sans', 'Fira Code', 'JetBrains Mono',
'Source Code Pro',
];
// The classic width probe: a family is "installed" if text set in it, with a generic fallback,
// measures differently from the generic family alone.
const PROBE = `(fonts) => {
const span = document.createElement('span');
span.textContent = 'mmmmmmmmmmlli10OWQ@#';
span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
document.body.appendChild(span);
const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
const generics = ['monospace', 'serif', 'sans-serif'];
const base = {};
for (const g of generics) base[g] = size(g);
const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
span.remove();
return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}`;
const SETUPS = [
{ setup: 'os: windows (launch 1)', body: { os: 'windows' } },
{ setup: 'os: windows (launch 2)', body: { os: 'windows' } },
{ setup: 'os: macos', body: { os: 'macos' } },
{ setup: 'os: linux', body: { os: 'linux' } },
{ setup: 'windows + fonts', body: { os: 'windows', fonts: ['Menlo', 'Helvetica Neue', 'Fira Code'] } },
{ setup: 'windows + fonts + custom_fonts_only', body: { os: 'windows', fonts: ['Calibri', 'Consolas', 'Segoe UI', 'Fira Code'], custom_fonts_only: true } },
];
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(body) {
for (let attempt = 1; ; attempt++) {
const res = await fetch('https://starter.cdpfleet.com/camoufox/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status !== 503 || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
// Proxies drop a tunnel now and then: up to two retries.
async function go(page, url) {
for (let attempt = 1; ; attempt++) {
try {
return await page.goto(url, { timeout: 60000 });
} catch (err) {
if (attempt === 3) throw err;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
const results = [];
for (const s of SETUPS) {
const session = await launch({ ...s.body, proxy: process.env.PROXY_URL, headless: true, record: false });
const browser = await firefox.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await (await browser.newContext()).newPage();
await go(page, 'https://httpbin.org/html');
const r = await page.evaluate(`(${PROBE})(${JSON.stringify(CANDIDATES)})`);
results.push({
setup: s.setup,
platform: r.platform,
detected: r.found.length,
font_hash: createHash('sha256').update(r.found.join(',')).digest('hex').slice(0, 10),
generics_identical: new Set(r.generic_sizes).size === 1,
fonts: r.found,
});
} finally {
await browser.close();
}
}
console.log(JSON.stringify(results, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import hashlib
import json
import os
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
# The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
CANDIDATES = [
"Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
"Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
"Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
"Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
"Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
"Source Code Pro",
]
# The classic width probe: a family is "installed" if text set in it, with a generic fallback,
# measures differently from the generic family alone.
PROBE = """(fonts) => {
const span = document.createElement('span');
span.textContent = 'mmmmmmmmmmlli10OWQ@#';
span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
document.body.appendChild(span);
const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
const generics = ['monospace', 'serif', 'sans-serif'];
const base = {};
for (const g of generics) base[g] = size(g);
const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
span.remove();
return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}"""
SETUPS = [
("os: windows (launch 1)", {"os": "windows"}),
("os: windows (launch 2)", {"os": "windows"}),
("os: macos", {"os": "macos"}),
("os: linux", {"os": "linux"}),
("windows + fonts", {"os": "windows", "fonts": ["Menlo", "Helvetica Neue", "Fira Code"]}),
("windows + fonts + custom_fonts_only", {"os": "windows", "fonts": ["Calibri", "Consolas", "Segoe UI", "Fira Code"], "custom_fonts_only": True}),
]
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(body):
for attempt in range(1, 6):
r = requests.post("https://starter.cdpfleet.com/camoufox/session", headers={"x-api-key": KEY}, json=body, timeout=120)
if r.ok:
return r.json()
if r.status_code != 503 or attempt == 5:
raise RuntimeError(f"launch {r.status_code} {r.text}")
time.sleep(int(r.headers.get("retry-after") or 5))
# Proxies drop a tunnel now and then: up to two retries.
def go(page, url):
for attempt in range(1, 4):
try:
return page.goto(url, timeout=60000)
except Exception:
if attempt == 3:
raise
time.sleep(2)
results = []
with sync_playwright() as p:
for name, body in SETUPS:
session = launch({**body, "proxy": os.environ["PROXY_URL"], "headless": True, "record": False})
browser = p.firefox.connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_context().new_page()
go(page, "https://httpbin.org/html")
r = page.evaluate(PROBE, CANDIDATES)
results.append({
"setup": name,
"platform": r["platform"],
"detected": len(r["found"]),
"font_hash": hashlib.sha256(",".join(r["found"]).encode()).hexdigest()[:10],
"generics_identical": len(set(r["generic_sizes"])) == 1,
"fonts": r["found"],
})
finally:
browser.close()
print(json.dumps(results, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
static final HttpClient HTTP = HttpClient.newHttpClient();
// The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
static final List<String> CANDIDATES = List.of(
"Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
"Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
"Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
"Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
"Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
"Source Code Pro");
// The classic width probe: a family is "installed" if text set in it, with a generic fallback,
// measures differently from the generic family alone.
static final String PROBE = """
(fonts) => {
const span = document.createElement('span');
span.textContent = 'mmmmmmmmmmlli10OWQ@#';
span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
document.body.appendChild(span);
const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
const generics = ['monospace', 'serif', 'sans-serif'];
const base = {};
for (const g of generics) base[g] = size(g);
const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
span.remove();
return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}""";
static final String[][] SETUPS = {
{"os: windows (launch 1)", "{\"os\":\"windows\"}"},
{"os: windows (launch 2)", "{\"os\":\"windows\"}"},
{"os: macos", "{\"os\":\"macos\"}"},
{"os: linux", "{\"os\":\"linux\"}"},
{"windows + fonts", "{\"os\":\"windows\",\"fonts\":[\"Menlo\",\"Helvetica Neue\",\"Fira Code\"]}"},
{"windows + fonts + custom_fonts_only", "{\"os\":\"windows\",\"fonts\":[\"Calibri\",\"Consolas\",\"Segoe UI\",\"Fira Code\"],\"custom_fonts_only\":true}"},
};
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static JsonObject launch(JsonObject body) throws Exception {
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/camoufox/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
// Proxies drop a tunnel now and then: up to two retries.
static void go(Page page, String url) throws Exception {
for (int attempt = 1; ; attempt++) {
try {
page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
return;
} catch (PlaywrightException e) {
if (attempt == 3) throw e;
Thread.sleep(2000);
}
}
}
public static void main(String[] args) throws Exception {
JsonArray results = new JsonArray();
try (Playwright pw = Playwright.create()) {
for (String[] s : SETUPS) {
JsonObject body = JsonParser.parseString(s[1]).getAsJsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.addProperty("headless", true);
body.addProperty("record", false);
JsonObject session = launch(body);
Browser browser = pw.firefox().connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newContext().newPage();
go(page, "https://httpbin.org/html");
JsonObject r = GSON.toJsonTree(page.evaluate(PROBE, CANDIDATES)).getAsJsonObject();
List<String> found = new ArrayList<>();
r.getAsJsonArray("found").forEach((f) -> found.add(f.getAsString()));
Set<String> sizes = new HashSet<>();
r.getAsJsonArray("generic_sizes").forEach((g) -> sizes.add(g.getAsString()));
JsonObject out = new JsonObject();
out.addProperty("setup", s[0]);
out.addProperty("platform", r.get("platform").getAsString());
out.addProperty("detected", found.size());
out.addProperty("font_hash", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(String.join(",", found).getBytes(StandardCharsets.UTF_8))).substring(0, 10));
out.addProperty("generics_identical", sizes.size() == 1);
JsonArray fonts = new JsonArray();
found.forEach(fonts::add);
out.add("fonts", fonts);
results.add(out);
} finally {
browser.close();
}
}
}
System.out.println(GSON.toJson(results));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
// The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
string[] candidates =
[
"Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
"Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
"Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
"Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
"Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
"Source Code Pro",
];
// The classic width probe: a family is "installed" if text set in it, with a generic fallback,
// measures differently from the generic family alone.
const string Probe = @"(fonts) => {
const span = document.createElement('span');
span.textContent = 'mmmmmmmmmmlli10OWQ@#';
span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
document.body.appendChild(span);
const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
const generics = ['monospace', 'serif', 'sans-serif'];
const base = {};
for (const g of generics) base[g] = size(g);
const found = fonts.filter((f) => generics.some((g) => size('""' + f + '"", ' + g) !== base[g]));
span.remove();
return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}";
var setups = new (string Name, JsonObject Body)[]
{
("os: windows (launch 1)", new JsonObject { ["os"] = "windows" }),
("os: windows (launch 2)", new JsonObject { ["os"] = "windows" }),
("os: macos", new JsonObject { ["os"] = "macos" }),
("os: linux", new JsonObject { ["os"] = "linux" }),
("windows + fonts", new JsonObject { ["os"] = "windows", ["fonts"] = new JsonArray("Menlo", "Helvetica Neue", "Fira Code") }),
("windows + fonts + custom_fonts_only", new JsonObject { ["os"] = "windows", ["fonts"] = new JsonArray("Calibri", "Consolas", "Segoe UI", "Fira Code"), ["custom_fonts_only"] = true }),
};
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(JsonObject body)
{
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsync("https://starter.cdpfleet.com/camoufox/session", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
}
}
// Proxies drop a tunnel now and then: up to two retries.
async Task Go(IPage page, string url)
{
for (var attempt = 1; ; attempt++)
{
try { await page.GotoAsync(url, new() { Timeout = 60000 }); return; }
catch (PlaywrightException) when (attempt < 3) { await Task.Delay(2000); }
}
}
var results = new JsonArray();
foreach (var (name, setupBody) in setups)
{
var body = (JsonObject)setupBody.DeepClone();
body["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL");
body["headless"] = true;
body["record"] = false;
var session = await Launch(body);
var browser = await playwright.Firefox.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await (await browser.NewContextAsync()).NewPageAsync();
await Go(page, "https://httpbin.org/html");
var r = await page.EvaluateAsync<JsonElement>(Probe, candidates);
var found = r.GetProperty("found").EnumerateArray().Select((f) => f.GetString()!).ToList();
results.Add(new JsonObject
{
["setup"] = name,
["platform"] = r.GetProperty("platform").GetString(),
["detected"] = found.Count,
["font_hash"] = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(",", found)))).ToLower()[..10],
["generics_identical"] = r.GetProperty("generic_sizes").EnumerateArray().Select((g) => g.GetString()).Distinct().Count() == 1,
["fonts"] = new JsonArray(found.Select((f) => (JsonNode)JsonValue.Create(f)!).ToArray()),
});
}
finally
{
await browser.CloseAsync();
}
}
Console.WriteLine(results.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
// The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
var candidates = []string{
"Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
"Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
"Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
"Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
"Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
"Source Code Pro",
}
// The classic width probe: a family is "installed" if text set in it, with a generic fallback,
// measures differently from the generic family alone.
const probe = `(fonts) => {
const span = document.createElement('span');
span.textContent = 'mmmmmmmmmmlli10OWQ@#';
span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
document.body.appendChild(span);
const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
const generics = ['monospace', 'serif', 'sans-serif'];
const base = {};
for (const g of generics) base[g] = size(g);
const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
span.remove();
return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}`
type setup struct {
Name string
Body map[string]any
}
var setups = []setup{
{"os: windows (launch 1)", map[string]any{"os": "windows"}},
{"os: windows (launch 2)", map[string]any{"os": "windows"}},
{"os: macos", map[string]any{"os": "macos"}},
{"os: linux", map[string]any{"os": "linux"}},
{"windows + fonts", map[string]any{"os": "windows", "fonts": []string{"Menlo", "Helvetica Neue", "Fira Code"}}},
{"windows + fonts + custom_fonts_only", map[string]any{"os": "windows", "fonts": []string{"Calibri", "Consolas", "Segoe UI", "Fira Code"}, "custom_fonts_only": true}},
}
type result struct {
Setup string `json:"setup"`
Platform string `json:"platform"`
Detected int `json:"detected"`
FontHash string `json:"font_hash"`
GenericsIdentical bool `json:"generics_identical"`
Fonts []string `json:"fonts"`
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(opts map[string]any) (map[string]any, error) {
body, _ := json.Marshal(opts)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/camoufox/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session map[string]any
return session, json.Unmarshal(b, &session)
}
if res.StatusCode != 503 || attempt == 5 {
return nil, fmt.Errorf("launch %d %s", res.StatusCode, strings.TrimSpace(string(b)))
}
wait, err := strconv.Atoi(res.Header.Get("retry-after"))
if err != nil {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
// Proxies drop a tunnel now and then: up to two retries.
func goTo(page playwright.Page, url string) error {
for attempt := 1; ; attempt++ {
_, err := page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err == nil || attempt == 3 {
return err
}
time.Sleep(2 * time.Second)
}
}
func one(pw *playwright.Playwright, s setup) (result, error) {
out := result{Setup: s.Name, Fonts: []string{}}
opts := map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true, "record": false}
for k, v := range s.Body {
opts[k] = v
}
session, err := launch(opts)
if err != nil {
return out, err
}
browser, err := pw.Firefox.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return out, err
}
defer browser.Close()
context, err := browser.NewContext()
if err != nil {
return out, err
}
page, err := context.NewPage()
if err != nil {
return out, err
}
if err := goTo(page, "https://httpbin.org/html"); err != nil {
return out, err
}
raw, err := page.Evaluate(probe, candidates)
if err != nil {
return out, err
}
r := raw.(map[string]any)
out.Platform = r["platform"].(string)
for _, f := range r["found"].([]any) {
out.Fonts = append(out.Fonts, f.(string))
}
out.Detected = len(out.Fonts)
h := sha256.Sum256([]byte(strings.Join(out.Fonts, ",")))
out.FontHash = hex.EncodeToString(h[:])[:10]
sizes := map[string]bool{}
for _, v := range r["generic_sizes"].([]any) {
sizes[v.(string)] = true
}
out.GenericsIdentical = len(sizes) == 1
return out, nil
}
func run() error {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
results := []result{}
for _, s := range setups {
r, err := one(pw, s)
if err != nil {
return fmt.Errorf("%s: %w", s.Name, err)
}
results = append(results, r)
}
b, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(b))
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
What we got
| Setup | navigator.platform | Detected (of 46) | List hash | Generics identical | Families found |
|---|---|---|---|---|---|
| os: windows (launch 1) | Win32 | 20 | 8ae0b1fd1d | yes | Arial Black, Calibri, Cambria, Candara, Comic Sans MS, Consolas, Constantia, Corbel, Courier New, Georgia, Impact, Lucida Console, Lucida Sans Unicode, Microsoft Sans Serif, Palatino Linotype, Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana |
| os: windows (launch 2) | Win32 | 20 | 8ae0b1fd1d | yes | Arial Black, Calibri, Cambria, Candara, Comic Sans MS, Consolas, Constantia, Corbel, Courier New, Georgia, Impact, Lucida Console, Lucida Sans Unicode, Microsoft Sans Serif, Palatino Linotype, Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana |
| os: macos | MacIntel | 23 | 23e513dd7a | yes | Arial, Arial Black, Comic Sans MS, Courier New, Georgia, Impact, Microsoft Sans Serif, Tahoma, Times New Roman, Trebuchet MS, Verdana, American Typewriter, Avenir, Avenir Next, Futura, Geneva, Gill Sans, Helvetica, Helvetica Neue, Lucida Grande, Menlo, Monaco, Optima |
| os: linux | Linux x86_64 | 3 | 5bebd342f7 | yes | Arial, Courier New, Times New Roman |
| windows + fonts | Win32 | 22 | 049bd59a6d | yes | Arial Black, Calibri, Cambria, Candara, Comic Sans MS, Consolas, Constantia, Corbel, Courier New, Georgia, Impact, Lucida Console, Lucida Sans Unicode, Microsoft Sans Serif, Palatino Linotype, Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana, Helvetica Neue, Menlo |
| windows + fonts + custom_fonts_only | Win32 | 2 | 0fc1fd0a15 | yes | Consolas, Segoe UI |
From the Node.js run on 2026-10-16. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
[
{
"setup": "os: windows (launch 1)",
"platform": "Win32",
"detected": 20,
"font_hash": "8ae0b1fd1d",
"generics_identical": true,
"fonts": [
"Arial Black",
"Calibri",
"Cambria",
"Candara",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Courier New",
"Georgia",
"Impact",
"Lucida Console",
"Lucida Sans Unicode",
"Microsoft Sans Serif",
"Palatino Linotype",
"Segoe UI",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana"
]
},
{
"setup": "os: windows (launch 2)",
"platform": "Win32",
"detected": 20,
"font_hash": "8ae0b1fd1d",
"generics_identical": true,
"fonts": [
"Arial Black",
"Calibri",
"Cambria",
"Candara",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Courier New",
"Georgia",
"Impact",
"Lucida Console",
"Lucida Sans Unicode",
"Microsoft Sans Serif",
"Palatino Linotype",
"Segoe UI",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana"
]
},
{
"setup": "os: macos",
"platform": "MacIntel",
"detected": 23,
"font_hash": "23e513dd7a",
"generics_identical": true,
"fonts": [
"Arial",
"Arial Black",
"Comic Sans MS",
"Courier New",
"Georgia",
"Impact",
"Microsoft Sans Serif",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana",
"American Typewriter",
"Avenir",
"Avenir Next",
"Futura",
"Geneva",
"Gill Sans",
"Helvetica",
"Helvetica Neue",
"Lucida Grande",
"Menlo",
"Monaco",
"Optima"
]
},
{
"setup": "os: linux",
"platform": "Linux x86_64",
"detected": 3,
"font_hash": "5bebd342f7",
"generics_identical": true,
"fonts": [
"Arial",
"Courier New",
"Times New Roman"
]
},
{
"setup": "windows + fonts",
"platform": "Win32",
"detected": 22,
"font_hash": "049bd59a6d",
"generics_identical": true,
"fonts": [
"Arial Black",
"Calibri",
"Cambria",
"Candara",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Courier New",
"Georgia",
"Impact",
"Lucida Console",
"Lucida Sans Unicode",
"Microsoft Sans Serif",
"Palatino Linotype",
"Segoe UI",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana",
"Helvetica Neue",
"Menlo"
]
},
{
"setup": "windows + fonts + custom_fonts_only",
"platform": "Win32",
"detected": 2,
"font_hash": "0fc1fd0a15",
"generics_identical": true,
"fonts": [
"Consolas",
"Segoe UI"
]
}
]Takeaways
- The font list follows the persona and doesn't change between launches: two Windows launches produced the same 20 families and the same hash. Unlike the canvas, which Camoufox re-seeds every launch (case 49), the font list is a stable trait of the persona.
- Each OS shows its own families: Windows had Segoe UI, Calibri and Consolas; macOS had Helvetica Neue, Menlo, Monaco and Avenir (23 found); Linux only Arial, Courier New and Times New Roman. That Linux list is short, and a short list is itself unusual.
fontsadds only what the server has: Menlo and Helvetica Neue appeared on top of the Windows list. Fira Code, which we also listed, never did. Listing a family that isn't installed changes nothing.custom_fonts_onlyreplaces the list, and one listed font disappears. Of Calibri, Consolas, Segoe UI and Fira Code, the probe found only Consolas and Segoe UI. Fira Code isn't installed, and Calibri became the face every generic family falls back to.- A width probe can't see the fallback face. In every setup
serif,sans-serifandmonospacemeasured exactly the same, because all three render in one face. A family that matches that face is invisible to the probe. On the Windows persona that face is Arial: the probe reports no Arial, which every real Windows machine has. A script that comparesserifwithmonospacecan see this, since on a normal desktop they are different faces.