Как значения и порядок Accept, Accept-Language и Accept-Encoding выдают браузер ещё до запуска JavaScript — и почему отключение скриптов не спасает.
Заблокируйте все скрипты, отключите куки и заприте браузер через NoScript — сервер всё равно сможет снять с вас отпечаток. Каждый HTTP-запрос несёт набор заголовков, описывающих, что принимает ваш браузер, на каком языке и с каким кодированием, и эти заголовки приходят раньше, чем выполнится хоть одна строка JavaScript. Их значения, порядок и сам набор различаются от браузера к браузеру: различия устойчивы, их трудно убедительно подделать, и любой сервер читает их уже при самом первом запросе. Это фингерпринтинг, которому не нужен ни canvas, ни WebGL, ни объект navigator — достаточно самой строки запроса.
Ключевые выводы
- Accept, Accept-Language и Accept-Encoding несут реальную энтропию. Их значения отражают установленные языковые предпочтения и поддержку кодеков, и вместе они сужают круг посетителя точно так же, как canvas или WebGL — просто без единого запущенного скрипта.
- Сам порядок заголовков — отдельный сигнал, независимый от значений. Сетевой стек каждого браузера выдаёт заголовки в фиксированной последовательности; эта последовательность различается между Chrome, Firefox и Safari и почти не меняется от запроса к запросу в рамках одной установки.
- Ничто из этого не требует JavaScript. Сервер видит полный набор заголовков уже в самом первом запросе — до того, как HTML вообще разобран, не говоря уже о выполнении тега скрипта, — так что блокировка JS и NoScript этого не касаются.
- Это слой ниже Client Hints, а не вместо них. User-Agent Client Hints добавляют поверх всегда отправляемых заголовков семейства Accept ещё один HTTP-сигнал: несколько низкоэнтропийных подсказок уходят по умолчанию, а подробные — только если сервер запросит их через
Accept-CH. И то и другое срабатывает раньше JavaScript. - Это накладывается на слои TLS и TCP под ним. TLS-фингерпринтинг считывает зашифрованное рукопожатие под этими заголовками, так что сервер, проверяющий оба слоя, получает подпись уровня запроса, вообще не зависящую от того, загрузится ли страница.
Строка запроса — уже сама по себе отпечаток
Прежде чем браузер что-либо отрисует, он уже отправил HTTP-запрос с несколькими заголовками, вся работа которых — согласование содержимого: сообщить серверу, что браузер способен показать, на каком языке и как предпочитает сжатие ответа. По HTTPS этот запрос шифруется в канале, но сервер, терминирующий соединение, читает его открытым текстом. Типичный запрос Chrome выглядит примерно так:
GET / HTTP/1.1
Host: example.com
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br, zstd
В HTTP/2 и HTTP/3 та же информация передаётся бинарными сжатыми полями с именами в нижнем регистре, а вместо Host используется псевдозаголовок :authority — семантика согласования содержимого при этом не меняется, как не меняется и то, что все эти поля приходят раньше любого скрипта. (Сам слой фреймирования добавляет собственный сигнал — см. фингерпринтинг HTTP/2.)
Ничто здесь не требует выполнения скрипта — это тот самый запрос, который получает сам HTML-документ. Сервер, логирующий эти три заголовка, уже располагает большим, чем принято думать: семейство Accept — Accept, Accept-Language, Accept-Encoding — не шаблонная формальность, оно согласовывается индивидуально по браузеру и конфигурации, а RFC 9110 — актуальный стандарт семантики HTTP — определяет синтаксис значений качества (q=), который позволяет каждому браузеру выразить собственный порядок предпочтений, и именно отсюда берётся вариативность.
Что раскрывает каждый заголовок
Accept-Language — самый прямой сигнал. Он перечисляет настроенные пользователем языки в порядке предпочтения, с весами q. Многоязычные пользователи — скажем, с en-US,en;q=0.9,fr;q=0.8,de;q=0.7 — раскрывают конкретную, сравнительно редкую комбинацию; при этом сама схема весов q также немного различается в зависимости от браузера и языковых настроек ОС, добавляя ещё один слой — не только «какие языки», но и «как именно этот конкретный стек форматирует список языков».
Стоит уточнить, сколько это стоит само по себе: посетитель, отправляющий обычное en-US,en;q=0.9, делит это значение с огромной толпой, так что сам по себе заголовок почти не добавляет идентифицирующей информации. Энтропия живёт в нетипичных случаях — список из трёх-четырёх языков, редкий региональный вариант, язык, не совпадающий со страной выходного IP — и в сочетании со всем остальным содержимым запроса. Это та же математика энтропии и множества анонимности, что действует и для сигналов на стороне JavaScript.
Accept-Encoding перечисляет алгоритмы сжатия, которые клиент способен декодировать: gzip, deflate, br (Brotli) и всё чаще zstd. Поддержка новых алгоритмов внедряется браузер за браузером, версия за версией, поэтому конкретный набор — и порядок его перечисления — сужает круг не просто до «браузера на Chromium», а примерно до диапазона версий, из которого пришёл запрос.
Accept описывает, какие типы и подтипы содержимого браузер готов отрендерить, с весами предпочтения — и напрямую раскрывает, например, поддержку форматов AVIF или WebP, что тесно коррелирует с семейством и версией браузера.
По отдельности каждый заголовок лишь немного сужает круг. Но вместе они работают точно так же, как энтропийная математика за отпечатками canvas и WebGL: независимые сигналы, чьи биты идентифицирующей информации складываются.
Порядок и наличие заголовков: подпись под значениями
Меняются не только значения — сам набор и порядок отправляемых браузером заголовков почти представляет собой фиксированную подпись. HTTP-клиентская библиотека каждого браузера собирает заголовки в том порядке, в котором их так исторически выстраивает её сетевой код, и эта последовательность стабильна между запросами одного и того же браузера и версии, но различается от браузера к браузеру. Запрос, выдающий себя за Chrome, но приходящий с порядком заголовков, совпадающим с библиотекой Python requests или с curl, выдаёт себя сразу же: та же логика, что делает порядок в TLS ClientHello отпечатком, применяется на слой выше — на уровне HTTP-фреймирования — к обычному порядку заголовков. А поскольку HTTP/2 и HTTP/3 накладывают поверх собственные соглашения о псевдозаголовках, сам слой фреймирования несёт дополнительный сигнал, в котором обычный прокси HTTP/1.1 или скриптовая библиотека часто допускают едва заметные ошибки.
Именно поэтому набор заголовков важен не меньше любого отдельного значения: реальный браузер отправляет предсказуемый, полный кластер заголовков при каждой навигации; а скриптовый клиент, который выставляет только User-Agent и забывает про Accept-Language или Accept-Encoding — или отправляет их в порядке, которого не использует ни один реальный браузер, — выделяется именно тем, чего не хватает или что стоит не на своём месте, а не тем, что какое-то отдельное значение выглядит подозрительно.
Почему это переживает отключённый JavaScript
Сигналы фингерпринтинга уровня DOM, рассмотренные в других материалах этого сайта, — canvas, WebGL, аудио, шрифты, разрешения — требуют выполнения скрипта, вызывающего какой-либо API. HTTP-заголовки не требуют ничего подобного. Их прикрепляет сетевой стек браузера к самому первому запросу за самым первым байтом HTML, а это значит:
- Полное отключение JavaScript (NoScript, текстовый браузер) не даёт никакого эффекта — заголовки всё равно уходят.
- Блокировка сторонних скриптов или трекеров тоже не помогает: сервер самого сайта, отдающий страницу, видит заголовки напрямую.
- Даже запрос, который никогда не приводит к отрисовке страницы, — HEAD-запрос или редирект, за которым так и не последовали, — всё равно несёт полный набор заголовков.
Именно это свойство делает TLS- и TCP/IP-фингерпринтинг устойчивым к блокировке скриптов: любой сигнал, живущий на сетевом или протокольном уровне, ниже DOM и ниже движка JavaScript, просто недосягаем для инструментов приватности, работающих через отключение скриптов.
Как это сочетается с Client Hints и TLS
Фингерпринтинг только по HTTP не заменяет остальные сетевые сигналы — это слой, на котором они все держатся, или слой рядом с ними:
| Слой | Сигнал | Нужен JS? | Нужен HTTPS? |
|---|---|---|---|
| TCP/IP | Размер окна, TTL, порядок опций | Нет | Нет |
| TLS-рукопожатие | JA3/JA4 из ClientHello | Нет | Да |
| Обычные HTTP-заголовки | Значения + порядок Accept/Accept-Language/Accept-Encoding | Нет | Нет |
| Client Hints | Низкоэнтропийные значения Sec-CH-UA-* по умолчанию | Нет | Да |
| Client Hints (высокая энтропия) | Полная версия, архитектура, модель | Нет (но сервер должен запросить их через Accept-CH) | Да |
| API браузера | Canvas, WebGL, шрифты, аудио | Да | Нет |
Сервер, читающий весь этот стек, получает подпись, полностью собранную до — и независимо от — любых сигналов на основе JavaScript, а затем накладывает поверх сигналы уровня DOM для тех посетителей, у кого скрипты действительно выполняются. Заголовки семейства Accept и Client Hints оба относятся к HTTP-слою и оба не зависят от JS; разница в том, что Client Hints поддерживаются пока только браузерами на Chromium и что для более богатых значений сервер должен запросить их сам, тогда как обычные заголовки Accept приходят от любого браузера, при каждом запросе, без какого-либо согласования.
Проверьте свои собственные заголовки
Обе стороны этой границы можно посмотреть самому. Проверка отпечатка BrowserInsight показывает список языков, который ваш браузер отдаёт скриптам (navigator.languages), рядом с остальными сигналами со стороны JavaScript — а также единственный сетевой сигнал, который страница не в состоянии вычислить сама: TLS-рукопожатие вашего соединения в том виде, в каком его увидел сервер, включая JA3/JA4, версию TLS и размер ClientHello. Эта карточка рукопожатия — ровно тот слой, что лежит непосредственно под описанными здесь заголовками: то же свойство «раньше любого скрипта», только читает его сервер, а не страница.
Чтобы сопоставить это именно со слоем заголовков, запомните список языков из инструмента, а затем посмотрите, что браузер отправляет в Accept-Language, — через любой сервис, отражающий сырой запрос, или прямо в DevTools самого браузера (вкладка Network, клик по запросу документа, раздел Request Headers). В деталях эти два списка часто расходятся — navigator.languages и Accept-Language выводятся из одних и тех же настроек, но форматируются независимо, — и именно такие расхождения ищет система детекции. Измените языковые настройки или повторите проверку в другом браузере: обе стороны сдвинутся у вас на глазах.
Часто задаваемые вопросы
Меняет ли VPN эти заголовки?
Нет. VPN меняет ваш IP-адрес, а не сетевой стек браузера — Accept, Accept-Language и Accept-Encoding генерируются самим браузером и проходят через VPN-туннель без изменений. Если Accept-Language по-прежнему показывает en-US, а выходной IP VPN геолоцируется в другой стране, это несоответствие само по себе становится сигналом — похожим на расхождение часового пояса и IP, которое проверяют другие инструменты.
Можно ли подделать эти заголовки, чтобы выглядеть как другой браузер?
Значения — да; любая HTTP-клиентская библиотека позволяет выставить произвольные заголовки. А вот стабильно, при каждом запросе, воспроизвести точный набор и порядок заголовков реального браузера, включая то, как их фреймирует HTTP/2, — гораздо сложнее, поэтому несовпадающий или неполный набор заголовков часто выдаёт скриптовый трафик даже без учёта самих значений.
Это то же самое, что TLS-фингерпринтинг?
Связано, но не то же самое. TLS-фингерпринтинг считывает рукопожатие, которое происходит ещё до отправки какого-либо HTTP-запроса; фингерпринтинг по HTTP-заголовкам считывает запрос, следующий сразу после завершения рукопожатия, — зашифрованный в канале, но полностью читаемый сервером, который терминирует соединение. Оба не зависят от JavaScript, и серверы всё чаще проверяют оба слоя вместе, поскольку запрос вполне может пройти одну проверку и провалить другую.
Останавливает ли отключение куки этот вид фингерпринтинга?
Нет — куки здесь ни при чём. Фингерпринтинг на основе заголовков ничего не хранит на вашем устройстве; он считывает то, что браузер по умолчанию отправляет с каждым запросом, — точно так же, как фингерпринтинг браузера в целом тоже не полагается на хранимые данные.
Заключение
Большинство обсуждений фингерпринтинга сосредоточены на сигналах, требующих JavaScript, — хешах canvas, строках рендерера WebGL, перечислении шрифтов, — потому что это самые богатые, высокоэнтропийные источники, доступные трекеру. Но на слой ниже существует значимая подпись: обычные HTTP-заголовки, которые каждый браузер отправляет ещё до того, как у любого скрипта появится шанс выполниться, — что принимается, на каком языке, с каким кодированием и в каком порядке. Отключение JavaScript закрывает сигналы уровня DOM; но никак не влияет на саму строку запроса. В сочетании с TLS- и TCP-отпечатками ниже и Client Hints рядом этот слой, существующий только за счёт заголовков, означает: не бывает запроса без сигналов вообще — бывают только запросы, в которых никто не потрудился заглянуть глубже страницы.
Рекомендуем прочитать:


