CDP никогда и не задумывался тихим
Puppeteer, Playwright, Selenium 4 и любая «стелс»-обёртка поверх них стоят на одном фундаменте — Chrome DevTools Protocol. Это тот же канал, который браузер открывает по нажатию F12. Chrome предоставляет его, чтобы отладчик мог осматривать страницу и управлять ею, и проектировался он ровно с одной «нецелью» — скрывать факт подключения отладчика.
Отсюда и растёт проблема. Перечисленные ниже сигналы — не баги Puppeteer. Почти все они — корректное поведение CDP, просто такое, какого никогда не бывает у браузера под управлением человека. Поставщику систем обнаружения не нужно ничего ломать, чтобы их найти; достаточно прочитать значения, которые платформа отдаёт даром.
Дальше — список по состоянию на 2026 год, примерно в порядке того, насколько дёшево проверить каждый пункт.
navigator.webdriver и почему правка в JS хуже
Самый старый сигнал. navigator.webdriver возвращает true всякий раз, когда Chrome запущен с включённой автоматизацией. Каждое руководство советует его переопределить:
Object.defineProperty(navigator, 'webdriver', { get: () => false })
Это не исправление. Это второй, более громкий сигнал. В обычном браузере webdriver — это аксессор на Navigator.prototype, а не собственное свойство экземпляра. После приведённого фрагмента истинны три вещи, которые не истинны больше нигде:
Object.getOwnPropertyDescriptor(navigator, 'webdriver')возвращает дескриптор. В настоящем браузере он возвращаетundefined, потому что свойство живёт на прототипе.toString()геттера показывает() => falseвместоfunction get webdriver() { [native code] }.- Свойство теперь перекрывает прототип, поэтому
'webdriver' in navigatorи дескриптор прототипа расходятся.
Скрипты обнаружения обходят цепочку toString уже годами. Браузер, возвращающий честное true, — это браузер с автоматизацией. Браузер, возвращающий поддельное false, — это браузер с автоматизацией, который ещё и пытается это скрыть, а для большинства риск-движков это хуже.
Единственный вариант, выдерживающий проверку, — тот, где false возвращает сам геттер на C++: нет собственного свойства, нет стрелочной функции и ничего лишнего в цепочке прототипов.
Runtime.enable: утечка, переписавшая всем стек
Чтобы выполнить JavaScript на странице, большинство библиотек вызывают Runtime.enable. У одной этой команды есть побочные эффекты, доходящие до страницы: она начинает слать уведомления Runtime.executionContextCreated и меняет способ сериализации аргументов консоли.
Классический эксплойт умещается в твит. Создайте объект с геттером, передайте его в console.debug и посмотрите, сработает ли геттер. При подключённом отладчике и вызванном Runtime.enable протокол сериализует объект для клиента, и геттер выполняется. Без отладчика к нему никто не притрагивается.
let leaked = false;
const probe = { get id() { leaked = true; return 1; } };
console.debug(probe);
// leaked === true → something is listening on CDP
Именно это подтолкнуло экосистему к пропатченным форкам — rebrowser-puppeteer и подобным, — которые обходятся без Runtime.enable и используют изолированные миры или addBinding. Эти патчи работают, и их стоит применять. Но они закрывают ровно одну дыру из десяти.
Три изъяна синтетического ввода
Когда автоматизация двигает мышь, она не двигает мышь. Она вызывает Input.dispatchMouseEvent, и Chrome собирает объект события, почти — но не совсем — такой же, какой рождает настоящий конвейер ввода. Три отличия элементарно наблюдаются со страницы.
screenX и screenY неверны
Это баг Chromium 40280325. CDP выставляет screenX = clientX и screenY = clientY — координаты вьюпорта там, где должны быть экранные. При настоящем клике они различаются на положение окна плюс интерфейс браузера: полосу вкладок, адресную строку, закладки. В развёрнутом окне это примерно 90-130 пикселей по вертикали.
Поэтому у настоящего клика у верхнего края страницы screenY около 150. Клик через CDP в том же месте сообщает screenY = 20. Cloudflare Turnstile считает низкий screenY ботовским кликом — и справедливо: человек не может кликнуть выше интерфейса собственного браузера.
UIEvent.detail равен нулю
У настоящего клика event.detail хранит счётчик нажатий — 1 для одиночного, 2 для двойного. События клика, отправленные через CDP, приходят с click_count = 0, поэтому detail равен 0. Не существует пользовательского жеста, дающего клик с detail, равным нулю. Turnstile проверяет это внутри своего iframe с проверкой.
getCoalescedEvents() пуст
Современные браузеры группируют частые перемещения указателя и отдают исходные отсчёты через PointerEvent.getCoalescedEvents(). Настоящий pointermove всегда несёт хотя бы одно сгруппированное событие — самого себя. Отправленный через CDP pointermove несёт пустой массив, потому что аппаратного отсчёта, который можно было бы сгруппировать, попросту не было.
Ничего из этого нельзя починить из JavaScript. Объект события собирается в C++ раньше, чем его увидит любой скрипт страницы, а свойства доступны только для чтения.
Строки, которые автоматизация оставляет на виду
Два семейства строковых литералов оказываются там, где страница может их прочитать.
Маркеры ChromeDriver. ChromeDriver отслеживает результаты собственных вызовов, объявляя свойства документа с префиксами cdc_, $cdc_ или $chrome_. Они не скрыты. Трёхстрочный цикл по Object.getOwnPropertyNames(document) находит их, и скрипты обнаружения ищут именно это с 2018 года.
Исходные URL Puppeteer. Puppeteer дописывает //# sourceURL=pptr:... к скриптам, которые выполняет, и создаёт изолированный мир с именем __puppeteer_utility_world__. Оба всплывают в Error.stack, если исключение пересекает границу:
try { null.f() } catch (e) { /* e.stack may contain "pptr:" */ }
Это самые простые для устранения сигналы — и те, что чаще всего остаются на месте, потому что появляются лишь при условиях, которые разработчик редко проверяет: при неперехваченной ошибке внутри выполняемой функции.
Worker: контекст, который никто не закрывает
Именно этот пробел подводит самые аккуратные в остальном сборки.
У Web Worker, Service Worker и Shared Worker есть собственный глобальный контекст со своим navigator. Этот объект создаёт движок в потоке worker из настоящих значений браузера. Переопределения CDP на уровне страницы — Emulation.setUserAgentOverride, внедрённые скрипты, Page.addScriptToEvaluateOnNewDocument — до него не достают.
В результате браузер утверждает одно в главном потоке и другое внутри worker. Скрипту обнаружения достаточно спросить дважды:
// main thread says 8; worker says 24
new Worker(URL.createObjectURL(new Blob([
'postMessage([navigator.hardwareConcurrency, navigator.deviceMemory, navigator.platform])'
], { type: 'text/javascript' })));
Любое расхождение между ними — окончательный вывод. Настоящие браузеры его дать не могут, потому что оба контекста читают одни и те же исходные значения. Через этот путь утекают, в частности, hardwareConcurrency, deviceMemory, platform, полный список брендов из userAgentData и — через OffscreenCanvas — вендор и рендерер WebGL.
Полноценно закрыть это снаружи невозможно. Значения должны приходить из движка, а значит, navigator в worker обязан строиться из того же профиля, что и у окна.
Мелочи, которые всё равно складываются
Ещё три пункта, каждый дёшево проверяется и каждый сам по себе способен завершить сессию.
Внешние размеры при закреплённых DevTools
Разница между outerWidth и innerWidth — это интерфейс браузера: примерно 16 пикселей по горизонтали и 85-90 по вертикали в десктопном Chrome. Закрепите DevTools — и этот зазор вырастает на сотни пикселей. Окно, сообщающее outerHeight - innerHeight = 400, — это окно с открытым отладчиком.
Квота хранилища
navigator.storage.estimate() возвращает квоту, выведенную из свободного места на диске. Это уже сам по себе сильный аппаратный сигнал, и вдобавок он выдаёт контейнеры и режим инкогнито, которые сообщают характерные круглые числа, далёкие от любого реального диска.
Отсутствующие API для заявленной платформы
Если ваш user agent сообщает Chrome 147 под Windows, страница может проверить, какие API у Chrome 147 под Windows есть на самом деле. В headless-сборках исторически не было chrome.runtime, полного механизма разрешений Notification и части кодеков, имеющихся в headful-сборках. Заявлять платформу, которую вы не можете подкрепить, хуже, чем не заявлять ничего.
Почему исправлять нужно в исходниках
Посмотрите на список — и проступает закономерность. Почти каждый сигнал рождается ниже JavaScript:
- Свойства событий выставляются в C++ до того, как их может тронуть скрипт.
- navigator у worker строится в потоке, до которого страница не дотягивается.
- Геттер webdriver живёт на прототипе, сама форма которого является сигналом.
- Побочные эффекты Runtime.enable происходят в инспекторе V8, а не на странице.
Контент-скрипт выполняется после всего этого. Он может лишь перезаписать уже решённое, и каждая перезапись оставляет описанные выше следы: собственные свойства там, где место прототипам, стрелочные функции там, где место нативному коду, расхождения между контекстами.
Это честный аргумент в пользу изменённой сборки Chromium — и стоит назвать и её пределы. Правка движка решает слой ниже JavaScript. Она не решает отпечатки TLS и HTTP/2, которые лежат вообще ниже браузера, и ничего не делает с поведением: сессия, кликающая с нечеловеческой регулярностью, будет помечена, какими бы чистыми ни были её свойства.
Проверка собственной сборки
Не верьте на слово никакому поставщику, включая нас. Проверки открытые, и прогнать их можно за день:
- rebrowser bot detector — эталонный набор именно для утечек CDP, включая консольную пробу
Runtime.enableи проверки служебных миров. - CreepJS — самый агрессивный публичный аудитор отпечатков. Он выполняет описанное выше сравнение worker и окна и прямо сообщает о расхождении.
- Собственная страница — самый быстрый тест умещается в двадцать строк: выведите
screenYиdetailиз обработчика клика, выведитеgetCoalescedEvents().lengthиз pointermove и отправьте navigator из worker обратно в главный поток. Если хотя бы одно из трёх расходится с браузером, которым вы управляете руками, проблема найдена.
Прогоните их на том, чем пользуетесь сегодня, прежде чем что-то менять. Инструмент, проходящий девять таких проверок и заваливающий десятую, не находится в девяти десятых пути: одного однозначного расхождения достаточно.