Измерение, а не перечисление
Веб-API, возвращающего список установленных шрифтов, не существовало никогда. И не требовалось: измерение работает почти так же хорошо и не спрашивает никаких разрешений.
Приём тот же, что используется примерно с 2007 года. Отрисуйте строку известным запасным шрифтом и запомните ширину. Затем отрисуйте ту же строку, запросив сначала проверяемый шрифт, а тот же запасной оставив позади. Если ширина изменилась — шрифт есть и был использован. Если нет — шрифта нет, и снова рисовал запасной.
const span = document.createElement('span');
span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
span.textContent = 'mmmmmmmmmmlli';
span.style.fontFamily = 'monospace';
document.body.appendChild(span);
const base = span.offsetWidth;
span.style.fontFamily = '"Segoe UI", monospace';
const installed = span.offsetWidth !== base;
Тестовая строка не случайна. mmmmmmmmmmlli смешивает самые широкие и самые узкие глифы большинства латинских гарнитур, максимизируя разницу ширины между любыми двумя шрифтами. При 72 пикселях разницу невозможно не заметить.
Прогоните это по списку из двух-трёх сотен кандидатов — и у вас есть шрифтовой профиль. Стоит это несколько десятков миллисекунд и не оставляет следа ни в одном запросе разрешений.
Почему шрифты выдают операционную систему
Наборы шрифтов не случайны. Они приходят вместе с ОС, и у каждой платформы набор свой, узнаваемый.
| Шрифт | Поставляется с | Что означает его наличие |
|---|---|---|
Segoe UI | Windows Vista и новее | Windows, почти наверняка |
Segoe UI Variable | Windows 11 | Отличает 11 от 10 |
Helvetica Neue | macOS | Платформа Apple |
Ubuntu, Liberation Sans | Распространённые дистрибутивы Linux | Linux |
Calibri, Cambria | Microsoft Office | Office установлен; слабый признак Windows |
Arial, Times New Roman | Почти везде | Ничего |
Это та проверка, которая ловит подменённый user agent даром. Профиль, заявляющий Windows 11 и не способный отрисовать Segoe UI Variable, противоречит сам себе в одном сравнении offsetWidth — без внешних данных, без базы репутации, без машинного обучения.
Обратная ошибка так же часта и заметна хуже: профиль, заявляющий macOS на хосте с Windows, где Segoe UI хоста никуда не делся и прекрасно измеряется. User agent говорит Mac; шрифты говорят Windows; побеждают шрифты.
Сколько это стоит
Работа EFF Panopticlick оценила списки шрифтов примерно в 13,9 бита опознавательной информации — этого одного хватает, чтобы сузить браузер до одного примерно из пятнадцати тысяч. Это один из самых высоких показателей для отдельного сигнала во всей литературе о фингерпринтинге: выше разрешения экрана и намного выше строки user agent.
Две оговорки, чтобы всё было честно. Во-первых, эта цифра получена на самоотобранной выборке пользователей, интересующихся приватностью, и завышает энтропию для веба в целом. Во-вторых, она снижается: Windows 11, свежие версии macOS и массовые дистрибутивы Linux поставляют всё более похожие наборы по умолчанию, а веб-шрифты убрали почти всякий повод ставить что-то дополнительно.
Что переживает это снижение — как раз та часть, которая здесь важна. Даже если список шрифтов больше не опознаёт машину однозначно, он по-прежнему опознаёт платформу, а именно определение платформы и ломает несогласованный профиль.
Другие способы спросить
Измерение ширины — классический метод, но не единственный. Реализация, перехватывающая только offsetWidth, эти способы пропустит.
measureText у canvas
ctx.measureText() возвращает объект TextMetrics с субпиксельной шириной продвижения и, в современных браузерах, реальными верхним и нижним выносами ограничивающего прямоугольника. Это точнее, чем измерение по элементам, и живёт в canvas-API, а значит, может выполняться внутри OffscreenCanvas в worker, куда перехваты на уровне страницы не дотягиваются.
Чистый CSS
Определять шрифты можно вообще без JavaScript — через @font-face с unicode-range и запрос фонового изображения, который срабатывает, только если использован конкретный запасной шрифт. Ни скрипта для перехвата, ни API для подмены.
Local Font Access
В Chrome 103 появился window.queryLocalFonts(), возвращающий полный список локальных шрифтов напрямую — но за запросом разрешения у пользователя. На практике скрипты фингерпринтинга им не пользуются как раз потому, что он спрашивает. Знать о нём стоит главным образом потому, что у браузера, заявляющего Chrome 147, этот API должен присутствовать, а профиль, который его убрал, создал новое несоответствие.
Как сделать правильно
Работают два подхода, а один очень распространённый — нет.
Что не работает: отдавать пустой или минимальный список. Браузер с пятью шрифтами приметнее, чем браузер с двумя сотнями. Это та же ошибка, что и рандомизация canvas: вы заменили распространённое значение редким и назвали это приватностью.
Подход первый: поставлять правильный набор. Профиль заявляет Windows 11, значит, список шрифтов — набор Windows 11 по умолчанию, присутствующий и измеримый. Полностью это достижимо, только когда под контролем сам шрифтовой стек: браузер должен разрешать запросы шрифтов по объявленному набору, а не по тому, что установлено на хосте. Это задача уровня движка, и потому одна из самых трудных для доработки задним числом.
Подход второй: не заявляйте платформу, на которой вы не находитесь. Профили Windows запускайте на Windows, профили Mac — на Mac. Без блеска, полностью действенно и бесплатно. Если у вас есть выбор хоста, это самый дешёвый правильный ответ.
Каким бы путём вы ни пошли, действует ограничение из статьи о согласованности: набор шрифтов должен согласовываться с user agent, с рендерером WebGL и со строкой платформы. Шрифты просто самые дешёвые из четырёх для проверки, поэтому их и проверяют первыми.
Проверить свои
Самый быстрый тест — одна проба против заявленной вами ОС. Вставьте это в консоль профиля и прочитайте ответ:
function has(f) {
const s = document.createElement('span');
s.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
s.textContent = 'mmmmmmmmmmlli';
document.body.appendChild(s);
s.style.fontFamily = 'monospace'; const base = s.offsetWidth;
s.style.fontFamily = `"${f}", monospace`; const w = s.offsetWidth;
s.remove();
return w !== base;
}
console.log({
windows: has('Segoe UI'),
win11: has('Segoe UI Variable'),
macos: has('Helvetica Neue'),
linux: has('Ubuntu')
});
Истинным должно оказаться ровно одно из них — то, что соответствует вашему user agent. Два истинных значения означают, что ваш профиль носит сразу две операционные системы. Для более полной картины browserleaks.com/fonts и CreepJS перечисляют весь набор, а CreepJS вдобавок прямо скажет, когда список не согласуется с заявленной платформой.