Ошибки с JWT, которые становятся утечками
Последнее обновление:
JSON Web Token — это подписанное утверждение, а не запечатанный конверт. Это три сегмента в кодировке Base64url, соединённые точками: заголовок, называющий алгоритм подписи, payload с утверждениями (claims) о пользователе или сессии, и подпись поверх первых двух. Подпись доказывает, что токен выпустил владелец ключа и что с тех пор он не менялся. О секретности она не доказывает ничего: любой, у кого есть токен, может декодировать payload и прочитать каждое утверждение внутри, поэтому для декодирования нужна вставка, а не пароль. Когда JWT ломаются в продакшене, почти никогда не ломается сама криптография. Ломается проверка, которую кто-то пропустил: алгоритм, принятый на веру из самого токена, срок действия, который никто не проверил, общий секрет, который человек мог бы набрать по памяти. Вставьте любой токен в декодер JWT — и его заголовок и утверждения предстанут как на ладони. Эта статья проходит по инженерным ошибкам, которые превращают пропущенные проверки в утечки, и по защитному правилу, закрывающему каждую из них.
Подписано — не значит секретно
Payload — это Base64url, кодировка из того же семейства, что и обычный Base64, а не шифрование. Всё в нём читается любым, через кого проходит токен: адреса электронной почты, роли, идентификаторы арендаторов, внутренние номера счетов, а изредка и вещи куда хуже — например, флаги сброса пароля или фрагменты персональных данных, которые разработчик добавил для отладки и не удалил. Токены также путешествуют дальше, чем представляют разработчики: они лежат в хранилище браузера, проходят через журналы прокси и приложений, копируются в отчёты о сбоях и вставляются в заявки об ошибках и ветки чатов. Рабочее правило следует напрямую: никогда не помещайте в токен утверждение, которое вам было бы неприятно увидеть напечатанным в файле журнала, потому что рано или поздно оно там окажется.
Недоразумение понятно: компактный токен выглядит как шифротекст — сплошные непроизносимые символы и точки. Но подпись даёт лишь обнаружение подделки: измените утверждение — и проверка не пройдёт. Конфиденциальность — это другая конструкция того же семейства стандартов (зашифрованные токены, JWE), и обычный токен доступа ею бывает редко. Поэтому проектируйте payload как публичный текст с подписью поверх него: только идентификаторы и факты авторизации, чувствительные данные запрашиваются на сервере по ID, а сроки жизни держатся короткими, чтобы то, что всё же утечёт, быстро истекало.
Позволять токену выбирать собственный алгоритм
Заголовок объявляет alg, и старейший провал в книге JWT — верификатор, который ему верит. Одно из объявляемых значений — none: неподписанный токен, вообще без подписи. Библиотека или самодельная проверка, которая подчиняется алгоритму, объявленному самим токеном, может получить токен с пустой подписью и принять его как подлинный. RFC лучших практик прямолинеен на этот счёт: неподписанные токены приемлемы только там, где сам канал уже гарантирует целостность, а bearer-токен, приходящий через публичный интернет, таковым не является. Тот же дефект — доверие заголовку — порождает путаницу алгоритмов. Система, выпускающая токены RS256, хранит закрытый ключ и публикует соответствующий открытый ключ для проверки. Если верификатор удастся убедить обрабатывать токен как HS256, материалом проверки становится открытый ключ — публичный по замыслу, известный всем, — используемый как симметричный секрет, и подписи, которые может изготовить кто угодно, начинают проходить проверку. Ни в одном из двух провалов ключ не украден; верификатор просто последовал инструкциям, написанным отправителем.
В заголовке сидит и второй, более тихий риск: указатели на ключи. Параметры вроде kid (идентификатор ключа), jku и x5u (URL, где живёт ключ или сертификат) говорят верификатору, где найти ключ проверки, — и прибывают внутри токена, на который влияет атакующий. Если значение kid без строгой проверки утекает в путь к файлу, запрос к базе данных или получение ключа, сама операция поиска становится поверхностью атаки. Защитные правила имеют одну и ту же форму: жёстко закрепить ожидаемый алгоритм в серверной конфигурации и отклонять всё остальное; разрешать ключи из собственного реестра, сверяя идентификаторы ключей со списком разрешённых; никогда не получать ключевой материал по URL, который поставляет токен; и относиться к каждому полю заголовка как к недоверенному вводу, потому что оно им и является.
Подпись прошла; утверждения так и не проверили
Проверка подписи отвечает на один-единственный вопрос — выпустил ли этот токен владелец ключа и остался ли он нетронутым. Она ничего не говорит о том, следует ли принимать этот токен здесь и сейчас. Это работа утверждений, и валидация утверждений — то место, где продакшен-системы тихо пропускают шаги. Сначала exp: верификатор, который никогда не проверяет срок действия, превращает каждый токен в постоянное удостоверение, поэтому токен, утёкший в журнал три года назад, до сих пор проходит аутентификацию сегодня. Затем iss: принимайте токены только от эмитентов, которых вы настроили, а не от любого, кто случайно разделяет алгоритм. И aud — самое дорогое упущение: организации часто эксплуатируют несколько сервисов на одном ключе подписи, и токен маловажного сервиса — бесплатного уровня API, инструмента по соседству со стейджингом — становится тогда корректно подписанным токеном, который принимает важный сервис, потому что подпись подлинная, а никто не спросил, для какой аудитории токен был выпущен.
Ничто из этого не экзотика. Это контрольный список, и режим отказа в том, что библиотеки с энтузиазмом проверяют подпись, оставляя проверки утверждений необязательными, отключёнными или проблемой вызывающего. Защитная позиция — требовать exp, iss и aud при каждой проверке, закрыто отказывать при любом отсутствии или несоответствии и допускать расхождение часов в минутах, а не в часах: допуск существует для несовершенных часов, и, растянутый достаточно далеко, он тихо воскрешает истёкшие токены. Затем тестируйте негативные случаи так, как вы тестировали бы форму входа: истёкший токен, токен с неверной аудиторией и токен от неизвестного эмитента должны быть отклонены все, и эти отклонения стоит записывать в журнал как тот сигнал безопасности, которым они являются.
Секреты, которые человек может запомнить, — это секреты, которые атакующий может перебрать
HS256 симметричен: один общий секрет и подписывает, и проверяет. В реальных кодовых базах этот секрет слишком часто оказывается словом или короткой фразой — названием компании, значением по умолчанию из фреймворка, скопированным из учебника, чем-то, что разработчик мог бы набрать по памяти. Вот принцип, который делает это фатальным, и это свойство самого замысла, а не какого-то инструмента: перехваченный токен несёт всё необходимое, чтобы проверять угаданный секрет офлайн. Сообщение известно, подпись известна, поэтому догадки можно сверять локально, на полной скорости, при этом сервер не видит ни одной попытки, а никакое ограничение скорости не применяется. Безопасность всей схемы сводится, таким образом, к энтропии этой единственной строки — а у строк, выбранных человеком, её очень мало.
Исправления негламурны. Генерируйте секреты подписи машиной, надлежащей длины — не менее 256 бит энтропии для HMAC — и храните их как те производственные учётные данные, которыми они являются. Делайте их уникальными для каждой среды, чтобы секрет из утёкшей стейджинг-конфигурации ничего не покупал в продакшене, и никогда не позволяйте значению по умолчанию для разработки дожить до настоящего развёртывания. Там, где токены проверяют многие сервисы, предпочитайте асимметричные алгоритмы, такие как RS256 или ES256: тогда верификаторы держат только открытые ключи, поэтому скомпрометированный верификатор не утечёт ничего, чем можно было бы чеканить токены, а ключ подписи остаётся в одном месте. И готовьте историю ротации до того, как она понадобится, потому что при симметричном секрете тревоги при его утечке не звучит: первым признаком обычно становится токен, которого вы не выпускали, принятый как ваш.
Где браузер хранит токен, решает, кто может его прочитать
На стороне клиента выбор хранилища — это решение о безопасности, было ли оно принято сознательно или нет. Токен в localStorage читается каждым скриптом, который выполняется на странице: вашим собственным кодом, каждым сторонним тегом и фрагментом аналитики, которые вы допустили, — и любой нагрузкой межсайтового скриптинга, которой когда-либо удастся выполниться, из любого неэкранированного поля комментария в любом углу приложения. Bearer-токен — это копируемое удостоверение: кто его прочитал, может предъявить его откуда угодно и быть этим пользователем до истечения срока. Поэтому долгоживущие токены в доступном скриптам хранилище стоят ровно столько, сколько стоит ваша худшая ошибка XSS, а у большинства приложений она рано или поздно появляется.
Обычная альтернатива, cookie с флагом HttpOnly, полностью убирает доступ скриптов — нагрузка XSS больше не может прочитать и увести токен, — но браузер прилагает cookie автоматически, а значит, угрозой, против которой надо строить защиту, становится подделка межсайтовых запросов, с атрибутами SameSite и CSRF-токенами. Хранение токенов только в памяти уклоняется от обеих проблем и теряет сессию при каждой перезагрузке. Универсально правильного ответа не существует, и притворяться иначе — это способ, которым команды получают худшее из каждого варианта. В утечку хранение превращает комбинация, которую никто не выбирал намеренно: доступное скриптам хранилище, долгие сроки жизни, широкие области действия и отсутствие ротации refresh-токенов, которая уменьшала бы ценность копии. Выбирайте компромисс сознательно, держите токены доступа короткоживущими в любом случае и записывайте обоснование там, где его найдёт следующий разработчик.