Почему user agent заморозили
Строка user agent превратилась в палеонтологическую летопись. Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 утверждает, что это Mozilla, Safari и KHTML, — ничего из этого не верно, и всё ради совместимости, тянущейся из девяностых.
Кроме того, это была поверхность пассивного фингерпринтинга: каждый запрос сообщал версию ОС, архитектуру процессора и точную сборку браузера всем сторонним скриптам на странице — нужно это кому-то или нет.
Ответом Google стали User-Agent Client Hints. Старая строка ещё существует, но постепенно замораживается: обратите внимание, что Windows NT 10.0 теперь сообщает и Windows 11, а поля младшей версии закреплены как 0.0.0. Настоящие подробности переехали на новую поверхность, разделённую на два уровня по степени опознаваемости.
Низкая и высокая энтропия
Подсказки с низкой энтропией отправляются с каждым запросом без всякого запроса, потому что раскрывают немного:
Sec-CH-UA— список брендов, например"Chromium";v="147", "Not-A.Brand";v="8", "Google Chrome";v="147"Sec-CH-UA-Mobile—?0либо?1Sec-CH-UA-Platform—"Windows","macOS","Linux"
Подсказки с высокой энтропией придерживаются, пока сервер не запросит их заголовком ответа Accept-CH или пока их не попросит скрипт:
architecture—x86,armbitness—64model— модель устройства, осмысленна в основном на AndroidplatformVersion— версия ОС и то самое поле, что отличает Windows 10 от 11fullVersionList— полные версии брендов, например147.0.7727.56wow64— запущена ли 32-битная сборка под 64-битной Windows
Это разграничение задумывалось как средство защиты приватности. На практике в контексте первой стороны Chrome отдаёт всё запрошенное без запроса подтверждения и без видимого сигнала, так что скрипту фингерпринтинга достаточно вызвать getHighEntropyValues() со всеми именами полей — и он получит всё.
Где профили противоречат сами себе
Три ошибки встречаются постоянно, и все три выявляются одной строкой.
Строку поменяли, а объект — нет
Самая частая с большим отрывом. Инструмент переписывает navigator.userAgent, заявляя Chrome 147 на macOS, и оставляет navigator.userAgentData сообщать Chrome 131 под Windows. Проверка:
const ua = navigator.userAgent;
const d = await navigator.userAgentData.getHighEntropyValues(
['platform','platformVersion','architecture','fullVersionList']);
// ua says "Macintosh; Intel Mac OS X 10_15_7"
// d.platform === "Windows" → contradiction, no ambiguity
Windows 10 или Windows 11
Поскольку строка UA сообщает Windows NT 10.0 для обеих, различить их можно только по platformVersion: значения ниже 13 означают Windows 10, 13 и выше — Windows 11. Профиль, который во всех витринных смыслах заявляет Windows 11, но возвращает platformVersion: "10.0.0", заявляет Windows 10 там, где это важно, — а следом у его набора шрифтов спросят про Segoe UI Variable.
Список брендов неверный
Запись GREASE — намеренно фальшивый бренд вроде "Not-A.Brand" — существует, чтобы серверы не зашивали список жёстко. Её точный текст и позиция меняются от версии Chrome к версии, а пунктуация со временем менялась: Not:A-Brand, Not.A/Brand, Not-A.Brand, с версией 8, 24 или 99 в зависимости от релиза. Список брендов, скопированный из старой сборки, — это несоответствие версий на самом виду рядом с более новым user agent.
Снова проблема worker
navigator.userAgentData существует внутри Web Worker и Service Worker. Эти контексты движок строит в собственных потоках, и, как разбиралось в статье про CDP, переопределение на уровне страницы до них не доходит.
// main thread: brands say 147
// worker: brands say whatever the binary really is
new Worker(URL.createObjectURL(new Blob([`
navigator.userAgentData.getHighEntropyValues(['fullVersionList','platform'])
.then(v => postMessage(v))
`], { type: 'text/javascript' })));
Любое расхождение здесь не просто подозрительно — оно окончательно. Не существует конфигурации браузера, в которой окно и worker законно сообщали бы разные списки брендов, потому что оба читают один источник.
Это тот же структурный вывод, который повторяется во всех этих статьях, и его стоит сказать ещё раз прямо: значение, которое определяет движок, убедительно поменять можно только в движке.
Заголовки и JavaScript тоже должны совпадать
UA-CH существует сразу в двух местах — в HTTP-заголовках и в объекте JavaScript, — и создают их разные части браузера. Инструмент, который правит одно и не правит другое, создаёт противоречие, видимое в логе сервера вообще без клиентских скриптов.
Пары, которые обязаны совпадать:
| Шапка | JavaScript |
|---|---|
Sec-CH-UA | userAgentData.brands |
Sec-CH-UA-Platform | userAgentData.platform |
Sec-CH-UA-Mobile | userAgentData.mobile |
Sec-CH-UA-Platform-Version | platformVersion |
Sec-CH-UA-Full-Version-List | fullVersionList |
User-Agent | navigator.userAgent |
Шесть пар, и последнюю все делают правильно. Полезная дисциплина — выводить их все из одной записи профиля, а не настраивать как отдельные поля; тот же довод, что в статье о согласованности, применённый к конкретной поверхности.
Как проверить профиль
Выполните это в профиле, который хотите проверить, и сверяйте каждую строку с user agent над ней:
(async () => {
const d = await navigator.userAgentData.getHighEntropyValues(
['architecture','bitness','model','platformVersion','fullVersionList','wow64']);
console.log(navigator.userAgent);
console.log(navigator.userAgentData.platform, navigator.userAgentData.mobile);
console.log(navigator.userAgentData.brands);
console.log(d);
})();
Четыре вопроса к выводу:
- Совпадает ли платформа в объекте с платформой в строке?
- Совпадает ли старшая версия в
fullVersionListсChrome/NNNиз строки? - Согласуется ли
platformVersionс ОС, которую вы собираетесь заявлять, — 13 или выше для Windows 11? - Даёт ли worker те же ответы?
browserleaks.com/client-hints показывает сторону заголовков и сторону JavaScript вместе, так что шесть пар выше легко сравнить одним взглядом. Это самый быстрый способ найти наполовину применённую подмену.