当 base64 包含 `+`(由同步字段中的 `~` 触发)时,服务端 sync_sso 会拒绝有效负载并返回 422 “登录错误”

大家好~

我发现了一个相当隐蔽的 Bug,当 b64 编码中包含 ‘+’ 字符时,会导致 sync_sso 端点出现问题。我确信这并非集成配置的问题。我使用了 Claude Fable 来诊断该 Bug 并生成报告,希望这没问题。我已经审查了所有内容,亲自复现了该问题,并认同其根本原因和修复方案。如果您需要其他任何信息,请告诉我。由于我已经找到了变通方法,这个问题对我而言不再紧急,但这确实是一个其他人可能会遇到的奇怪问题。

感谢大家的辛勤工作!
Brandon

摘要

POST /admin/users/sync_ssosso 参数进行了双重解码。如果 base64 编码的负载中包含 + 字符,该端点会将其损坏为空格,并以通用的 422 登录错误 拒绝请求——即使负载和签名是正确的。

对于 ASCII(URL 编码)负载内容,仅当 ~ (0x7E) 字符处于正确的字节对齐位置时,base64 才会生成 +——且仅适用于那些按照 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 “登录错误”。(在 #26140 中添加该检查之前,相同的损坏会在签名比较时失败——结果相同。)

+ 是唯一会被表单解码改变的 base64 字母字符,因此双重解码对于大多数负载来说是静默的空操作——这就是为什么它一直未被察觉。
浏览器登录流程(session/sso_login)不受影响:它解析原始查询字符串,即仅解码一次。

该插值可追溯至 fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69(2014 年 10 月,
“允许 sync_sso 的 api 端点的简单更新”),它替换了原始的——
且正确的——DiscourseSingleSignOn.parse(request.query_string)

这不是集成者的编码错误:官方 sync_sso 指南
显示 sso 值使用单次标准表单编码发送,官方
discourse_api gem 也是如此——因此,只要 base64 恰好包含 +,严格按字面意思执行的客户端就会遇到此问题。

复现步骤(确定性)

  1. 为任何用户构建有效的已签名 sync_sso 负载,包括一个值中包含 ~~~(三个连续的波浪号保证其中一个落在字节位置
    ≡ 2 mod 3,从而在 base64 中强制生成 +)的字段。

    复现时的 Ruby 注意事项: 使用 Discourse 自身的
    SingleSignOn 类(或 discourse_api gem)构建的负载在其
    base64 中永远不可能包含 +——Rack::Utils.build_query~ 转义为 %7E,且其输出
    字母表中的任何字节都不映射到 base64 +。要复现,请手动构建内部查询字符串
    并在字段值中使用字面量 ~~~(未知键会被忽略),例如
    Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~")
    对其进行签名并 POST。这也是为什么现有规范从未捕获到该 Bug 的原因:
    Discourse 自身的工具在结构上无法生成触发负载。

  2. 使用 sso/sig 作为正确编码的表单参数进行 POST /admin/users/sync_sso
    (在网络上传输时 + 作为 %2B 发送)。

  3. 响应:422 {"failed":"FAILED","message":"Login Error"}

  4. 将波浪号替换为 xxx 的相同负载(base64 现在不含 +):200。

针对生产环境 Discourse 实例验证:相同的负载结构,base64 中有一个 + → 422;零个 → 200;如果将 + 预先转义为 %2B(即预先补偿额外的解码),422 负载则成功。

现有的回归规范也在 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。请注意,如果/当端点
被修复时,必须移除此变通方法,否则会导致过度编码。