Всем привет~
Я обнаружил довольно редкий баг, связанный с b64-кодированием, когда значения с символом + вызывают проблемы исключительно на эндпоинте sync_sso. Я уверен, что это не проблема интеграции. Для диагностики бага и генерации отчета я использовал Claude Fable, надеюсь, это допустимо. Я проверил всё, воспроизвел ошибку самостоятельно и согласен с указанием корневой причины и способом исправления. Дайте знать, если понадобится что-то еще. Для меня это сейчас не критично, так как я нашел обходное решение, но оно довольно странное, и другие тоже могут на него наткнуться.
Спасибо за вашу тяжелую работу!
Брэндон
Резюме
POST /admin/users/sync_sso выполняет двойное декодирование параметра sso. Если base64-кодированная полезная нагрузка содержит символ +, эндпоинт искажает его, превращая в пробел, и отклоняет запрос с общей ошибкой 422 Login Error (Ошибка входа) — даже если полезная нагрузка и подпись верны.
Для ASCII-контента (URL-кодированного) base64 генерирует символ + только тогда, когда символ ~ (0x7E) находится в правильном байтовом выравнивании — и только для клиентов, URL-кодирование которых оставляет ~ без экранирования в соответствии с RFC 3986 (например, Python ≥ 3.7, encodeURIComponent в JavaScript).
Так что на практике это проявляется так: синхронизация работает для всех, кроме пользователей, у которых в био/имени пользователя и т.д. есть ~, и то не всегда, в зависимости от того, как остальная часть их профиля смещает выравнивание. Это выглядит как загадка HMAC для конкретного пользователя и практически невозможно диагностировать извне. Скорее всего, это сильнее затрагивает форумы моего сайта, чем другие, так как корейцы и изучающие корейский язык часто используют ~ для завершения био.
При входе и синхронизации через браузер эта проблема не проявляется (см. ниже). Это проблема только для серверных вызовов sync_sso.
Корневая причина
В Admin::UsersController#sync_sso (users_controller.rb#L500):
sso = DiscourseConnect.parse("sso=#{params[:sso]}&sig=#{params[:sig]}", server_session:)
params[:sso] уже был декодирован из формы Rack/Rails, но он интерполируется обратно в синтаксис query-string без повторного экранирования. Затем DiscourseConnect.parse запускает Rack::Utils.parse_query над ним (discourse_connect_base.rb#L93) —
второе декодирование значения, которое было закодировано только один раз. parse_query преобразует буквальный + в пробел, и проверка base64-символов ниже вызывает PayloadParseError,
который отображается как 422 “Login Error” (Ошибка входа). (До добавления этой проверки в #26140 та же порча приводила к сбою при сравнении подписей — результат был тот же.)
+ — единственный символ алфавита base64, который мутируется при декодировании формы, поэтому двойное декодирование является тихим no-op (операцией без эффекта) для большинства полезных нагрузок — вот почему это оставалось незамеченным.
Поток входа через браузер (session/sso_login) не затрагивается: он парсит сырую query-string, то есть декодирует ровно один раз.
Интерполяция восходит к fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69 (октябрь 2014 г.,
“trivial update to allow api endpoint for sync_sso” (незначительное обновление для разрешения api-эндпоинта для sync_sso)), которое заменило оригинальный —
и правильный — DiscourseSingleSignOn.parse(request.query_string).
Это не ошибка кодирования со стороны интегратора: официальное руководство по sync_sso
показывает, что значение sso отправляется с одним стандартным кодированием формы, и официальный
гем discourse_api делает то же самое — поэтому клиент, следующий инструкции буквально, сталкивается с этим, когда base64 случайно содержит +.
Шаги для воспроизведения (детерминированные)
-
Создайте действительную подписанную полезную нагрузку sync_sso для любого пользователя, включая поле, значение которого
содержит~~~(три последовательных тильды гарантируют, что одна из них окажется в байтовой позиции
≡ 2 mod 3, что принудительно вставит+в base64).Примечание для воспроизведения из Ruby: полезные нагрузки, созданные с помощью собственного
классаSingleSignOnDiscourse (или гемаdiscourse_api), никогда не могут содержать+в своем
base64 —Rack::Utils.build_queryэкранирует~как%7E, и ни один байт в его выходном
алфавите не отображается на base64+. Чтобы воспроизвести, создайте внутреннюю query-string вручную
с буквальным~~~в значении поля (неизвестные ключи игнорируются), например,
Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"),
подпишите его и выполните POST. Именно поэтому существующие спецификации никогда не ловили этот баг:
собственные инструменты Discourse структурно не могут генерировать триггерящую полезную нагрузку. -
POST /admin/users/sync_ssoсsso/sigкак правильно закодированными параметрами формы
(+отправляется как%2Bпо сети). -
Ответ:
422 {"failed":"FAILED","message":"Login Error"}. -
Та же полезная нагрузка с заменой тильд на
xxx(base64 теперь без+): 200.
Проверено на production-экземпляре Discourse: идентичная структура полезной нагрузки, один +
в base64 → 422; ноль → 200; и полезная нагрузка 422 успешно проходит, если + предварительно экранирован
как %2B (т.е. предварительно компенсирован для дополнительного декодирования).
Существующая регрессионная спецификация также прогоняет полезную нагрузку через
Rack::Utils.parse_query(sso.payload) перед отправкой — предварительно декодируя ее и отменяя
дополнительное декодирование контроллера — поэтому даже полезная нагрузка с + могла бы проскользнуть мимо нее.
Предлагаемое исправление
Повторно экранировать параметр перед перестроением query-string, копируя то, что уже делает
сторона SSO-провайдера с CGI.escape(payload) в том же классе
(discourse_connect_base.rb#L168):
sso = DiscourseConnect.parse(
"sso=#{CGI.escape(params[:sso])}&sig=#{params[:sig]}", server_session:
)
Обходное решение для интеграторов (до исправления)
Предварительно экранируйте + как %2B в значении sso только для вызовов sync_sso (не для
перенаправления входа через браузер). Обратите внимание, что это обходное решение необходимо удалить, если/когда эндпоинт
будет исправлен, так как тогда оно приведет к избыточному кодированию.