Серверная синхронизация SSO отклоняет действительные запросы с ошибкой 422 "Ошибка входа", если base64 содержит символ `+` (вызывается символом `~` в синхронизируемых полях)

Всем привет~

Я обнаружил довольно редкий баг, связанный с 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 случайно содержит +.

Шаги для воспроизведения (детерминированные)

  1. Создайте действительную подписанную полезную нагрузку sync_sso для любого пользователя, включая поле, значение которого
    содержит ~~~ (три последовательных тильды гарантируют, что одна из них окажется в байтовой позиции
    ≡ 2 mod 3, что принудительно вставит + в base64).

    Примечание для воспроизведения из Ruby: полезные нагрузки, созданные с помощью собственного
    класса SingleSignOn Discourse (или гема 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 структурно не могут генерировать триггерящую полезную нагрузку.

  2. POST /admin/users/sync_sso с sso/sig как правильно закодированными параметрами формы
    (+ отправляется как %2B по сети).

  3. Ответ: 422 {"failed":"FAILED","message":"Login Error"}.

  4. Та же полезная нагрузка с заменой тильд на 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 (не для
перенаправления входа через браузер). Обратите внимание, что это обходное решение необходимо удалить, если/когда эндпоинт
будет исправлен, так как тогда оно приведет к избыточному кодированию.