サーバー側のsync_ssoが、同期フィールドに`~`が含まれるとbase64に`+`が含まれ、有効なペイロードを422 "Login Error"で拒否する

みなさん、こんにちは~

b64エンコードされた値に + が含まれている場合に、sync_sso エンドポイントのみで問題を引き起こすかなり obscure(見つけにくい)なバグを発見しました。これは統合側の問題ではないと確信しています。バグの診断とレポートの生成には Claude Fable を使用しましたが、これで問題ないでしょうか。私はすべての内容をレビューし、自身でも再現を確認し、根本原因と修正方法に同意しています。他に必要であればお知らせください。現在は回避策を見つけたので私にとって非常に重要ではなくなりましたが、他の人も遭遇しうる奇妙なバグです。

皆さんのご尽力に感謝します!
Brandon

概要

POST /admin/users/sync_ssosso パラメータを二重にデコードしています。base64エンコードされたペイロードに + 文字が含まれている場合、エンドポイントはその文字をスペースに変換して破損させ、ペイロードと署名が正しいにもかかわらず、一般的な 422 Login Error でリクエストを拒否します。

ASCII(URLエンコードされた)ペイロードコンテンツの場合、base64は ~ (0x7E) 文字が特定のバイト位置に配置された場合にのみ + を生成します。これは、RFC 3986 に準拠して ~ をエスケープしない URLエンコーディングを行うクライアント(例:Python ≥ 3.7、JavaScript の encodeURIComponent)に限定されます。したがって、実際には以下のようになります:同期はすべてのユーザーで機能しますが、プロフィールの残りの部分が配置をシフトさせる方法によっては、自己紹介やユーザー名などに ~ を含むユーザーのみで、かつ場合によっては発生します。これはユーザー固有の 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 によってフォームデコードされていますが、再エスケープせずにクエリ文字列構文に補間されます。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)は影響を受けません:これは生クエリ文字列を解析し、つまり正確に一度だけデコードします。

この補間は fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69(2014年10月、「sync_sso の API エンドポイントを許可するためのトリビアルな更新」)に遡ります。これは元の — そして正しい — DiscourseSingleSignOn.parse(request.query_string) を置き換えました。

これはインテグレーターのエンコーディングミスではありません:公式 sync_sso ガイド は、sso 値を単一の標準フォームエンコーディングで送信する方法を示しており、公式の discourse_api ジェムも同じことをしています。したがって、文字通りクライアントが base64 に + を含むたびにこの問題に遭遇します。

再現手順(決定論的)

  1. 値に ~~~(3つの連続するチルダ)を含むフィールドを含む任意のユーザーの有効な署名付き sync_sso ペイロードを構築します。これにより、バイト位置 ≡ 2 mod 3 に 1つが配置され、base64 に + が強制されます。

    Ruby から再現するための注意: Discourse の独自の SingleSignOn クラス(または discourse_api ジェム)で構築されたペイロードは、base64 に + を含むことができません。Rack::Utils.build_query~%7E としてエスケープし、その出力アルファベットのどのバイトも base64 の + にマップされません。再現するには、フィールド値にリテラルの ~~~ を含む内部クエリ文字列を手動で構築し(不明なキーは無視されます)、例えば 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。

本番環境の Discourse インスタンスに対して検証済み:同じペイロード構造で、base64 に 1つの + → 422;0 → 200;そして 422 ペイロードは +%2B として事前にエスケープされている場合(つまり、余分なデコードを補償)成功します。

既存の回帰スペックも、POST する前にペイロードを Rack::Utils.parse_query(sso.payload) を通じてラウンドトリップします — つまり、事前にデコードし、コントローラーの余分なデコードを相殺します。したがって、+ を含むペイロードでもそれをすり抜けてしまいます。

提案される修正

クエリ文字列を再構築する前にパラメータを再エスケープし、同じクラスの SSO プロバイダー側がすでに CGI.escape(payload) で行っていることを反映します (discourse_connect_base.rb#L168):

sso = DiscourseConnect.parse(
  "sso=#{CGI.escape(params[:sso])}&sig=#{params[:sig]}", server_session:
)

インテグレーター向けの回避策(修正まで)

sync_sso 呼び出し(ブラウザのログインリダイレクトではない)の sso 値で +%2B として事前にエスケープします。この回避策は、エンドポイントが修正された場合/時に削除する必要があります。そうしないと、過剰なエンコーディングを引き起こすためです。