Привет, Хабр. Меня зовут Алексей, я занимаюсь разработкой в сервисе бронирования отелей МТС Travel. Нам примерно два года, но мы быстро растем, так что наши данные стали регулярно парсить, из-за чего пришлось искать методы защиты.
В итоге мы решили использовать fingerprint для фильтрации трафика. Дальше расскажу о трудностях при его внедрении, а еще о том, как мы старались минимизировать неудобства для пользователя.
Fingerprint в двух словах
Fingerprint — это технология, позволяющая идентифицировать клиентов по внутренним параметрам их браузеров. Она учитывает различные данные: информацию о CPU, настройки локализации, аудио и так далее.
Наша команда использует fingerprint, чтобы фильтровать трафик и отсекать ботов. Точность определения можете оценить в демо.
Как назначается fingerprint-токен
Мы используем сторонний сервис с функциональностью fingerprint. Там наш трафик подвергается фильтрации и дальше перенаправляется к нам.
Схема процесса назначения токена примерно такая:
Первоначальный запрос на наш сервис из браузера.
Запрос идет на входной шлюз fingerprint, который определяет, нужно ли пропустить трафик дальше.
На входном шлюзе проверяется наличие специального ключа в cookie, а после принимается решение о выдаче страницы.
const keyValid = validateKey(request.cookies.my_key)
if (keyValid) {
return proxyToRealFrontend()
} else {
return proxyToFingerprintDumpHtml()
}
Пользователь получает специальный HTML, в который встроен скрипт, собирающий данные. После этого формируется токен и записывается в cookies
const uniqKey = getUniqKey({
cpu: navigator.hardwareConcurrency,
// ...
})
document.cookie += `token=${uniqKey}`
Для упрощения понимания опущена стадия назначения HTTP-only cookies и возможного сохранения в таблице в облаке.
Повторный запрос страницы. В результате пользователь получает настоящую HTML-страницу с контентом.
Какая возникла проблема
В нашем проекте используются специальные меры для защиты /api веб-сервиса. Принцип работы прост: все входящие запросы к /api проверяются на наличие специального токена в cookie. Если токена нет, доступ к данным запрещен.
Рассмотрим два сценария работы с нашей API:
Случай с отсутствующим токеном. В этом сценарии попробуйте открыть ссылку в режиме инкогнито. Токена в cookie нет, поэтому сервер вернет ошибку 403: доступ к API запрещен.
Случай с присутствующим токеном. Чтобы получить токен, откройте главную страницу МТС Travel. Потом перейдите к API. Выданный токен сохраняется в ваших cookie и позволяет получить доступ к API. Вернется ответ 200 с ожидаемыми данными.
Меры безопасности также предусматривают ограниченный срок действия токена — 1 час. Если мы оставим все как есть, по истечению часа токен станет невалидным. Тогда клиент вместо очередного превосходного отеля в Анапе получит некрасивую страницу ошибки и предложение обновиться.
Как решить проблему
Перечислим стадии, которые мы преодолели:
Отрицание, гнев. Их мы прошли довольно быстро.
Торг. По словам поставщика fingerprint, решением этой проблемы могут быть периодические запросы, которые должны обновить токен. Это рождает банальное решение, которое мы и внедряем в код.
setInterval(() => {
fetch('/api/ping')
}, 1000 * 60 * 20)
Депрессия. После включения fingerprint мы начинаем замечать рост ошибок пользователей. Распространенный кейс — длительное бездействие на странице. Это значит, что наше банальное решение почему-то не работает, и нам нужно копать дальше.
Что мы узнаем:
На самом деле запросы по общему правилу токен не обновляют. Он обновляется только в определенном случае, если запрос пришел в особенный промежуток незадолго до истечения жизни прошлого токена.
Решение не учитывает случай, когда устройство выключено или не имеет доступа к сети. Например,пользователь заходит к нам на сайт и приступает к выбору отеля. Потеряв сознание от красоты отелей и размера кешбэка для авторизованных пользователей, клиент случайно закрывает крышку ноутбука. Очнувшись через несколько часов, он видит неработающий сайт и сильно расстраивается. Решением этой проблемы могут быть либо принудительная перезагрузка страницы, либо всплывающая модалка с просьбой перезагрузить страницу. В любом случае это выглядит некрасиво.
Принятие. Проблемы теперь очевидны и понятны. После того, как мы избавимся от слез, надо придумать, как элегантно их решить. Ход мыслей такой:
Перезагрузка. Ее невозможно избежать из-за случая с выключенным устройством. Давайте запустим ее, хотя бы контролируемо. Мы вполне можем сделать невидимый iframe, который загружает нашу страницу, и перезагружать именно его. Тогда токен обновится, и пользователь не заметит негативного эффекта.
<iframe id="my-iframe" hidden src="https://travel.mts.ru"/>
// ...
getElementById('my-iframe').reload()
Когда обновлять наш iframe? Сначала идея была в том, чтобы перезагружать страницу периодически — так же, как мы собирались делать банальный запрос. Но на практике обнаруживаем, что это тоже не имеет эффекта, как обычный запрос к API. Более того, это не учитывает кейс с выключенным устройством. Тут у нас возникает искушение отлавливать ошибки на уровне fetch. То есть мы хотим сделать примерно такой код функции запроса и использовать его повсеместно вместо обычного fetch.
const customFetch_USE_IT_OR_WILL_BE_FIRED = (...args) => {
// оригинальны запрос за данными
let fetchResult = await fetch.apply(this, args)
// проверка статуса ответа
if (fetchResult.status === 403) {
// обновление страницы в iframe, а вместе с ней и токена
await reloadIframe()
// повторный запрос
fetchResult = await fetch.apply(this, args)
}
return fetchResult
}
В этом коде есть не самая очевидная логика с повторным запросом с помощью fetch. Это связано с тем, что после обновления iframe у нас уже есть валидный токен, но данных все еще нет. Если мы вернем первый результат запроса, падение приложения неизбежно. Мы учитываем это и ошибку 403: в нашем случае она означает, что api не приступил к обработке данных. Поэтому делаем повторный запрос с теми же данными.
Сделав все это, мы обнаруживаем, что использование nextjs в качестве фреймворка накладывает дополнительные ограничения. Так, nextjs при переходе на другую страницу запрашивает данные уже на клиенте. А значит, эти запросы тоже столкнутся с вероятными 403. Мы не можем указать nextjs, какой fetch использовать. Тогда возникнет такая проблема: пользователь открывает устройство после длительного простоя, переходит на страницу отеля, а это инициирует запрос новых данных клиента. Итог: мы снова сталкиваемся с 403.
Похоже, нам не остается другого варианта, кроме самого неприятного, — пропатчить fetch. Это выглядит примерно так:
// сохранение оригинального fetch
const originalFetch = window.fetch;
function patchedFetch = (...args) {
// оригинальны запрос за данными
let fetchResult = await originalFetch.apply(this, args)
// проверка статуса ответа
if (fetchResult.status === 403) {
// обновление страницы в iframe, а вместе с ней и токена
await reloadIframe()
// повторный запрос
fetchResult = await fetch.apply(this, args)
}
return fetchResult
}
// проверка среды исполнение, пропатчить fetch мы должны именно на клиенте
if (typeof window !== 'undefined') {
window.fetch = patchedFetch
}
Что можно улучшить
Решение выше работает, но его, конечно, можно оптимизировать. Одной из таких оптимизаций может быть исключение одновременного обновления iframe из разных источников. К примеру, после активации устройства страница может отправить сразу несколько запросов. Все они завершатся ошибкой 403, поэтому будет произведено несколько повторяющихся обновлений iframe.
Чтобы этого избежать, мы можем ввести глобальный флаг обновления iframe. И в том случае, если обновление уже запущено, просто дождаться, а не запускать новое.
let currentPromise = Promise.resolve();
let runningNow = false;
// ...
if (fetchResult.status === 403) {
if (runningNow === false) {
// инициализируем обновление iframe
currentPromise = // начать обновление iframe и подписаться на его обновление внутри promise
runningNow = true;
await currentPromise;
runningNow = false;
} else {
// если обновление уже запущено просто дожидаемся его
await currentPromise
}
}
Итог
В такой реализации токен обновляется без явных проблем и негативного эффекта для пользователя. Конечно, ради этого пришлось пропатчить fetch. Но пока это кажется единственным разумным решением, чтобы решить эту нетривиальную задачу.
Вот такой получился кейс. Поделитесь в комментах, внедряли ли вы у себя fingerprint, сталкивались ли с такой же проблемой, как ее решали?
Комментарии (7)
gun_dose
13.06.2024 10:39Можно было не патчить fetch, а написать просто свой агент на его основе или на основе каких-нибудь axios или superagent.
И раз уж запрос шлётся через свой агент (в вашем случае, кстати, тоже), то не нужно вслепую делать первый запрос. Можно же хранить время получения токена, и если время жизни токена истекло, то сперва запросить новый токен, а потом уже идти за данными.
utftufutukgyftryidytftuv Автор
13.06.2024 10:39Если я правильно понял идею - то она примерно такая же как в примере с кастомным fetch с названием
customFetch_USE_IT_OR_WILL_BE_FIRED
. Основная проблема что мы не можем заставить nextjs использовать наш собственный агент, а nextjs при этом также получает 403 ошибки уже при навигации между страницами
savostin
13.06.2024 10:39Т.е. сначала подключили сторонний сервис, а потом с ним боролись? Не проще было fingerprint самим использовать?
utftufutukgyftryidytftuv Автор
13.06.2024 10:39Вполне возможно что в каких-то моментах было проще в плане настройки под себя, но это получается лишняя точка отказа на нашей стороне и также необходимо тратить ресурсы на поддержку. Плюс собрать у себя хороший детектор ботов кажется довольно непростой задачей, а учитывая что боты они могут постоянно совершенствоваться придется самим постоянно допиливать этот сервис. И самое важное, пока кажется что кейс со спящим устройствам в любом случае пришлось бы решать на стороне фронта
savostin
13.06.2024 10:39Не знаю точно но детектор ботов имхо в данном случае сводится к проверке наличия fingerprint
Dimit_001
Спасибо!