Организация работы с токенами в клиентской части веб‑приложений — тема, которая на слуху давно и многократно обсуждалась, однако далеко не для всех современных веб‑приложений все еще оцениваются риски в этой части, что приводит к уязвимым реализациям.
В статье рассмотрим причины необходимости работы с токеном на клиенте веб‑приложений, узнаем,что лучше для хранения токена: localStorage, sessionStorage или cookie без флага HttpOnly (спойлер, ничего из этого), а также посмотрим на меры воздействия, которые можно использовать для снижения риска утечки токена посредством различных уязвимостей.
Надеюсь, что данная статья будет полезна разработчикам, тестировщикам, аналитикам и архитекторам, в ней постарался рассмотреть вопрос с разных точек зрения и собрать широкую картину.
Введение
Аутентификация и авторизация в веб‑приложениях и их правильное приготовления не перестают быть обсуждаемыми и дискутируемыми, даже несмотря на, казалось бы, популяризацию многих типовых подходов.
Тема статьи на самом деле далеко не нова и известна. Различные вариации работы с токенами используются давно. Однако в повседневной жизни я до сих пор встречаю веб‑приложения, имеющие риски реализации, которые могут привести к увеличению критичности уязвимостей.
Некорректная работа с токенами в клиентской части веб‑приложений или непонимание
их [веб‑приложений] особенностей и необходимости с ними считаться ведет к созданию уязвимых продуктов, когда, казалось бы, как никогда должна быть актуальна тенденция повышения информационной безопасности. Статьей хочу привлечь внимание к проблеме, пояснить ее значимость, а также разобрать механизмы повышения безопасности при работе с токенами на клиенте.
Access token (токеном доступа) здесь и далее я буду называть некую строку, используемую для доступа к защищенному ресурсу (protected resource). Сам токен может иметь совершенно разные форматы и имплементации.
Также в статье будем оперировать понятиями XSS и CSRF.
Проблематика
Неподходящая для конкретного веб-приложения реализация аутентификации и работы с токенами на клиенте приводит к повышению уровня критичностей уязвимостей в нем. Самый простой пример: если у нас access token для доступа к ресурсам доступен в клиентской части, то любая XSS автоматически дает кражу учетной записи пользователя, поскольку внедряя и выполняя код на клиенте в контексте страницы мы можем получить доступ к тем же данным, к которым обращаются легитимные скрипты.
Такая проблема актуальна с начала прошлого десятилетия, когда были распространены случаи хранения сессий в не-HttpOnly cookies, но, кажется, мы стали снова про нее забывать.
Кто-то скажет здесь:
Ну так это пример в вакууме, мы делаем наши приложения без XSS-уязвимостей, у нас есть %framework_name% или %library_name%, там это все учтено. Зачем нам что-то додумывать, если мы сфокусируемся на том, чтобы не допускать такие уязвимости вообще?
Звучит резонно, не правда ли? Однако реальность, к сожалению, не так проста. Практика и подходы показывают нам, что гарантию такую дать в большинстве случаев почти нереально. К тому же запросто может быть случай, где угрозы безопасности нашим веб‑приложениям приходят совсем из других мест: например, токен мы положили в какую‑нибудь wildcard и не‑HttpOnly‑cookie, обезопасили свое приложение от XSS до зубов, но вот незадача, на одном из поддоменов обнаружился старый дырявый проект, который свел на нет все наши старания.
Чем опасен украденный access token? С ним злоумышленник сможет выполнять запросы к ресурсам API от нашего лица в отсутствие пользователя (while user is not presented) или же попросту имперсонализироваться под нашей учетной записью у себя в браузере.
Примеры (или набор вредных советов)
Далее как раз рассмотрим проблему на конкретных примерах, специфичных для веб-приложений, поскольку они, в отличие от других видов приложений, не предоставляют такого однозначного места для конфиденциального хранения информации. Примеры эти реальны, их я собрал, посещая различные сайты, однако все они здесь обезличены.
Access token в localStorage
В данном приложении access token после получения с бэкенда помещается в локальное хранилище localStorage.
В таком случае при наличии XSS-уязвимости токен может быть получен злоумышленником. Однако у токена указан срок жизни - 15 минут. Допустим ли риск того, что в течение N (N<=15) минут токеном пользователя может воспользоваться злоумышленник, - вопрос над которым при применении такого решения следует подумать.
Пара access и refresh token в localStorage
В этом примере приложение помещает в localStorage пару: access token и refresh token. Access token имеет срок жизни - 60 минут. Refresh token - при исследовании был валиден и спустя более чем 12 часов.
Здесь проблема из первого примера расширяется. Access token имеет уже куда больший срок жизни - целый час. Но также мы имеем и долгоживущий refresh token. Refresh token - строка, используемая для получения access token. В то время как access token используется для доступа к защищенному ресурсу, refresh token позволяет обратиться за получением нового (или дополнительного) access token.
Таким образом, здесь, как и в примере ранее, в случае XSS злоумышленник может получить доступ к access token (который уже живет дольше), а также и к совсем долгоживущему refresh token.
Отмечу, что для повышения безопасности использования refresh-токенов существует набор мер, таких как ротация токенов, защита от переиспользования, грамотный подбор времени жизни и другие. Однако данными мерами следует скорее дополнять, но не заменять защиту токена от утечки при XSS.
Access token в wildcard не-HttpOnly cookie
Напоследок рассмотрим самый интересный вариант.
В примере мы видим, что токен помещается в сookie со сроком жизни — 1 год. При этом значение cookie — это токен в формате JWT, со значением claim «exp», равном «iat» + 1 год, то есть год живет не только cookie, но и сам токен. Cookie при этом не имеет установленного значения флагов HttpOnly, Secure, SameSite. Кроме этого, видно, что домен начинается с точки: .%sitename%.com
. Значит, данная cookie является wildcard, то есть будет доступна на всех поддоменах, удовлетворяющих такому условию.
Во-первых, сам по себе access token, живущий год и при этом доступный на клиенте, является серьезной угрозой. При краже злоумышленником токена посредством XSS он будет действовать еще очень долго.
Во-вторых, здесь токен помещен в cookie, которая доступна на всех поддоменах сайта. Это означает, что даже если в самом основном приложении XSS злоумышленник и не найдет, то среди содержимого наших поддоменов запросто может оказаться уязвимый сервис. Поиск поддоменов - один из типовых приемов, используемых при пентесте. На них может найтись много интересного: например, какой-то старый сайт или тестовый проект - потенциально уязвимый сервис. Также встречаются и атаки вида subdomain takeover (захват поддомена). В этом случае тоже злоумышленник сможет произвести кражу токена аналогичным способом.
Примечательно также, что подобную реализацию я встречал не единожды и, как видно, она несет наибольшие риски из всех перечисленных. Возможно, такое использование wildcard сookie где‑то предполагалось для «бесшовной» аутентификации между поддоменами, однако хочется предостеречь от бездумного использования такого подхода.
Почему так, зачем нужно работать с токеном на клиенте?
Логичный вопрос: а зачем вообще нам тогда делать токен доступным на клиенте и работать с ним там? Почему бы не положить его просто в сессионную hardened-cookie? Кроме допущенных недостатков проектирования, я вижу для этого несколько предпосылок.
1. Использование stateless-токенов
Такой подход еще иногда называют «token‑based authentication» (что на мой взгляд не совсем корректно, ведь токен у нас может быть и вполне stateful). Когда мы используем слово «сессия», мы скорее всего подразумеваем stateful‑вариант. То есть на сервере хранится какая‑то информация о нашей сессии, которая проверяется при обращении ресурсу.
В случае со stateless-токенами картина иная. Данный токен самодостаточен сам по себе и предполагает содержание в себе всей информации, необходимой для авторизации. Обычно для этого используют JWT-токены, которые имеют стандартную структуру.
За счет наличия блока signature токен может подписывать свое содержимое, и валидность подписи может быть проверена при получении запроса с данным токеном: приватным ключом при симметричном или публичным ключом при ассиметричном шифровании. Так валидация JWT может происходить без участия выдавшего его сервера и, соответственно, без необходимости обращения в БД для каждой проверки.
Подход позволяет как раз уйти от хранения токенов на сервере (по крайней мере, в его «ванильной» имплементации), отдавать такой JWT‑токен после аутентификации и хранить его уже на том, что для нас является клиентом.
2. Использование OAuth 2.0/OIDC для получения access token в своем же приложении
Довольно популярная вещь - использование, например, Authorization code grant flow в OAuth2.0 или OpenID Connect (OIDC).
Не будем путать OAuth 2.0 с OpenID Connect. OAuth 2.0 является протоколом для делегирования пользователем доступа к определенным ресурсам конкретному приложению. По своей спецификации он не предполагает стандартного способа получения identity пользователя, то есть пользователь дал нам доступ на выполнение ряда действий, а вот кто он – не сказал. OIDC расширяет его возможности и предоставляет такую возможность через получение ID Token или использование userinfo-эндпоинта. Отличие в том, что в случае OIDC Authorization Server играет роль также и Resource server, но только для identity пользователя.
Причину я вижу в том, что by default реализации эндпоинта получения токена (token endpoint) для того же Authorization code flow (который продвигается как более безопасная реализация, нежели чем Implicit flow) в OAuth 2.0 или OIDC возвращают токен в application/json теле ответа с типом Bearer. И если в качестве Client используется public, а не confidential client (про них будет упоминаться ниже), то мы часто может встретить отправку запроса к token endpoint прямиком с клиента. Пример ответа вызова такого метода какой-нибудь типовой имплементации:
{
"access_token": "<token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid operations"
}
Тип токена Bearer предполагается к использованию и самой спецификацией OIDC. Следствие кроется в самом названии типа. Bearer-токен - токен на предъявителя.
Соответственно, кто его предъявит, тот и является авторизованным пользователем. Поскольку токен доступен на клиенте, это и используется: обычно клиент отправляет к серверу запросы с заголовком Authorization: Bearer <token>.
Ну и поскольку мы получаем токен в JSON-ответе от сервера, то на клиенте у нас не так много опций, куда этот токен можно безопасно положить.
Пару слов про OIDC и PKCE
Отдельное внимание хочу уделить использованию механизма PKCE в authorization code flow. PKCE рекомендован к использованию для public clients - клиентов, которые не имеют возможности конфиденциального хранения client secret, таким как SPA , однако применяется также и для confidential clients. Важно понимать, что PKCE не предоставляет абсолютно никакой защиты для токена на клиенте, он вообще не про это. Корректно реализованный PKCE дает возможность убедиться, что за получением токена обращается тот же субъект, который обращался за получением authorization code и все. Помните про это и не смешивайте эти понятия.
3. Использование Single-page applications (SPA) без бэкенда
Также это касается и использования Single-page applications (SPA) без бэкенда. Не используя бэкенд, разработчики вынуждены искать способы работы с токеном на клиенте.
Что можно сделать, варианты мер и решений
В интернете раньше были популярны споры, где лучше хранить токен для работы с ним: в localStorage, sessionStorage или сookies (естественно, без HttpOnly). На самом деле, с точки зрения безопасности разницы практически нет. И вот почему:
localStorage | sessionStorage | Cookies без флага HttpOnly | |
Доступно с клиента | Да | Да | Да |
Привязка к конкретному домену | Да | Да | Да (и шире, см. wildcard-случаи) |
Контекст | Синхронизируется между вкладками | Ограничен пределами вкладки | Синхронизируется между вкладками |
Персистентность | Сохраняет состояние после закрытия браузера | Сохраняет состояние при обновлении вкладки, теряет при ее закрытии | Сохраняет состояние после закрытия браузера |
А как же IndexedDB?
С рассматриваемых в сравнении точек зрения IndexedDB не отличается от localStorage, за исключением одного нюанса: доступ к ней есть также и у service workers.
Как видно из сравнения выше, все три приведенных способа одинаково уязвимы при возможности выполнения вредоносного кода на клиенте. Что доступно из кода разработчику, доступно из кода и злоумышленнику.
Что же тогда делать? Как и везде, серебряной пули не существует, разные подходы имеют свои особенности, свои pros и cons - достоинства и недостатки. И здесь мы попробуем рассмотреть такие подходы независимо, тогда как выбор конкретного - индивидуальная задача, которую необходимо решить при проектировании.
Подход 1. Не работать с токеном на клиенте вообще
Именно так, первый и самый простой способ избежать рисков - отказаться от работы с токеном на клиенте. Использовать stateful-подход к аутентификации вместо stateless. Тогда мы можем использовать сессионную cookie для хранения нашего токена. Важно помнить, что для такой cookie потребуется корректная установка атрибутов HttpOnly
, Secure
, SameSite
, Path
. Также подчеркну, что не рекомендуется бездумно использовать wildcard cookies (причины были рассмотрены выше), поэтому внимание стоить уделить и атрибуту Domain
.
Однако есть нюанс. В таком случае, поскольку данная cookie будет отправляться на сервер в запросах к указанным Domain
и Path
, необходимо предусмотреть защиту от CSRF-атаки. Сделать это можно, например, согласно рекомендациям OWASP.
Такой подход лишает нас возможностей, предоставляемых stateless-токенами, однако он делает наши риски более прозрачными и может быть проще в реализации.
Подход 2. Проксирующий бэкенд
А что если мы хотим оставить возможность работы со stateless-токенами? Здесь возможно использование middleware-слоя, который будет обеспечивать безопасность хранения токена.
Наглядно это можно представить на схеме. Сначала посмотрим, как у нас выглядит упрощенный процесс аутентификации и обращения к ресурсу без проксирующего бэкенда:
Пару слов про Authorization server
По RFC 6749 под Authorization server понимается сервер, выпускающий access-токены для клиента после успешной аутентификации. Поэтому в данном случае Authorization server выполняет аутентификацию.
Authorization server и Resource server могут быть различными сервисами или одним и тем же, их реализация зависит от выбранной архитектуры приложения.
Имеем все те проблемы, о которых упоминали выше. Теперь добавим наш промежуточный слой:
Выглядит сложнее, давайте разбираться. Здесь Backend proxy уже выступает не public-, а confidential-клиентом (RFC 6749, п. 2.1), и должен выполнять обращение за получением токена со своими client id и client secret.
В процессе аутентификации и получения токена мы обращаемся не напрямую к Authorization server, а через Backend proxy, что показано в [1]. Соответственно и access token также будет получать он ([4]). Затем сервер генерирует некую cookie (подробнее рассмотрим ниже) c флагом HttpOnly, которую и отправляет на клиент ([6]). Клиент далее в [7] обращается к API ресурса, но делает это также не напрямую (поскольку он не владеет токеном доступа), а через наш Backend proxy. Здесь происходит аутентификация клиента и «размен» значения из cookie на легитимный access_token, с которым идет обращение к Resource server ([9]), а полученный ответ проксируется обратно на клиент ([12]).
Встает вопрос: какую cookie может выдавать наш Backend proxy? Здесь вижу несколько подходов.
Обыкновенная сессионная cookie, которая идет в пару полученному токену. В таком случае в нашем компоненте нам придется реализовать работу с сессиями и хранить их. Однако так мы заодно получим способ управления инвалидацией доступа.
Шифрование полученных от Authorization server значений. В таком случае мы избегаем необходимости хранить сессию у себя. При получении запроса на обращение к ресурсу мы производим дешифрование значения из cookie (ключ для этого у нас есть) и проксируем запрос далее.
Использование самого значения токена в качестве значения cookie «как есть».
Сроком действия данных cookie также необходимо управлять самостоятельно, также необходимо помнить про установку значений других их атрибутов. Отдельно отмечу, что такая реализация несет еще ряд тонкостей, таких как подбор времени жизни access_token, организация его кэширования в Backend proxy, механизм обновления access_token и т.д. Вследствие наличия и так большого объема информации, оставим эти вопросы за рамками данной статьи.
Подобный подход может быть применим и к SPA, поскольку, делая Single-page application, совсем не обязательно полностью отказываться от бэкенда, такая тонкая прослойка может быть вполне используема. В микросервисной архитектуре роль Backend proxy может выполнять API gateway или Backend-for-frontend (BFF).
Итак, данный подход предоставляет нам возможность защититься от кражи токена через XSS, однако делает возможной CSRF-атаку, поэтому меры, по защите от нее также должны быть применены.
Подход 3. Добавление пользовательского контекста в токен
До этого мы использовали подходы только с HttpOnly cookies, но что если есть и иной путь? Добавление пользовательского контекста в токен предполагает использование вместе как HttpOnly cookie, так и части, доступной на клиенте. Метод проще всего объясним на примере JWT-токена.
Пользовательский контекст может состоять из следующей информации:
Случайная строка, сгенерированная сервисом в процессе аутентификации. Передается на клиент в HttpOnly cookie (помним и про другие атрибуты).
SHA256-хэш от случайной строки, помещенный в payload JWT-токена.
Очень упрощенно это можно изобразить так:
Таким образом для авторизации, помимо проверки JWT-токена, необходимо еще и сравнение хэша от случайной строки, полученной в hardened-cookie, с хэшированным значением в самом токене. Токен при этом может быть сохранен как в сookies, так и в localStorage или sessionStorage - сути это не меняет, поскольку токен сам по себе становится недостаточным для доступа к Resource server.
Существует также еще интересная вариация, которую я тоже отнесу к данному подходу из-за схожести исполнения - Two Cookie JWT Approach. Здесь мы аналогично используем HttpOnly и не-HttpOnly cookie, получаемые с сервера, но принцип разделения информации несколько отличается.
В HttpOnly cookie здесь мы помещаем подпись JWT-токена, в то время как header и payload находятся в доступной на клиенте cookie. Тогда наше обращение к API будет выглядеть так:
При этом важно не забыть о грамотном выставлении атрибута Max-Age
у cookies. Таким образом, авторизацию мы все еще выполняем на основе проверки JWT‑токена, однако полная его «версия» может быть получена только путем совмещения двух частей, что снижает последствия эксплуатации XSS‑уязвимости, поскольку подпись нам с клиента недоступна. При использовании одних только cookies здесь, как и ранее, тоже следует принять меры защиты от CSRF‑атак.
Подход 4. Использование service worker
Service worker - скрипт, который браузер запускает в фоновом режиме, выполняющий роль прокси-сервера для взаимодействия между веб-приложением, браузером и сетью. Service worker запускается в отдельном контексте, работает в отдельном потоке, не имеет доступа к DOM, и соответственно клиент также не имеет доступа к service worker и хранимым там данным. Этой его особенностью мы и воспользуемся, чтобы обезопасить access token от утечки при XSS.
В этом случае service worker отвечает за получение токена от Authorization server и выполнение запросов к Request server. Запросы с клиента в данном случае проксируются service worker, он как бы перехватывает их. Следовательно вызов метода получения токена и сам токен полностью изолированы, поскольку контекст service worker недоступен для прочих JavaScript-контекстов.
Напоминает рассмотренную ранее схему с проксирующим бэкендом, не правда ли? Однако здесь средний слой, выступающий в качестве прокси, мы реализуем не на сервере, а на клиенте.
У использования service worker существует еще одна важная деталь: его регистрация должна происходить в самом начале загрузки клиентской части приложения, в противном случае при эксплуатации XSS злоумышленник может инициировать новый flow аутентификации (до регистрации service worker) и получить токен в обход него.
В данном случае CSRF-атака неприменима, поскольку токен доступен только для service worker, а также исключается возможность кражи токена посредством XSS, однако реализация и эксплуатация такой схемы будут сложнее. Технология поддерживается современными браузерами, но, если требуется поддержка Internet Explorer, то такой подход не подойдет.
Подход 5. Хранение токена в памяти
JavaScript предоставляет возможность хранения полученного значения токена в памяти. Для этого используется имитация приватного свойства класса через «closure variable» — локальную переменную внутри замыкания. Тогда мы можем создать некий token‑manager‑class, который будет хранить значение токена и выполнять самостоятельно все обращения к Resource server, не допуская доступности токена снаружи.
Такой подход возможен, однако имеет ряд особенностей. Во-первых, значение токена не будет сохраняться после перезагрузки страницы и быть доступным из других вкладок. Во-вторых, злоумышленник может перехватить запрос на клиенте уже после его формирования, например, использовав monkey patching для метода fetch.
Таким образом мы рассмотрели различные способы снижения рисков от утечки нашего токена с клиента. Все они могут подходить для использования в каких-то конкретных случаях, все имеют свои особенности. Также вижу возможной и комбинацию нескольких подходов: например, fallback с использования service worker при неудачной его регистрации на другой механизм.
Подчеркну, что мы говорили здесь только про кражу токена, в большинстве случая при наличии XSS злоумышленник все еще сможет отправить легитимные запросы к вашему API непосредственно из браузера жертвы, однако меры для предотвращения завладения токеном все равно важны.
Заключение
Тема безопасности аутентификации и авторизации достаточно обширна, и ее не охватить одной статьей. Здесь постарался рассмотреть вопрос хранения и работы с access token в клиентской части веб‑приложений, какие риски он может нести и какие меры для их снижения существуют. Я намеренно использую слово «риски» — поскольку степень угрозы для каждого приложения может быть своя, но понимать ее важно.
Также для интересующихся привожу список статей по теме.
Список релевантного чтива
Помните о безопасности, проверяйте свои приложения и оценивайте риски.
Больше информации про аутентификацию и безопасность - в канале про Identity & Access Management: t.me/unauthz