大家好~
我发现了一个相当隐蔽的 Bug,当 b64 编码中包含 ‘+’ 字符时,会导致 sync_sso 端点出现问题。我确信这并非集成配置的问题。我使用了 Claude Fable 来诊断该 Bug 并生成报告,希望这没问题。我已经审查了所有内容,亲自复现了该问题,并认同其根本原因和修复方案。如果您需要其他任何信息,请告诉我。由于我已经找到了变通方法,这个问题对我而言不再紧急,但这确实是一个其他人可能会遇到的奇怪问题。
感谢大家的辛勤工作!
Brandon
摘要
POST /admin/users/sync_sso 对 sso 参数进行了双重解码。如果 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 恰好包含 +,严格按字面意思执行的客户端就会遇到此问题。
复现步骤(确定性)
-
为任何用户构建有效的已签名 sync_sso 负载,包括一个值中包含
~~~(三个连续的波浪号保证其中一个落在字节位置
≡ 2 mod 3,从而在 base64 中强制生成+)的字段。复现时的 Ruby 注意事项: 使用 Discourse 自身的
SingleSignOn类(或discourse_apigem)构建的负载在其
base64 中永远不可能包含+——Rack::Utils.build_query将~转义为%7E,且其输出
字母表中的任何字节都不映射到 base64+。要复现,请手动构建内部查询字符串
并在字段值中使用字面量~~~(未知键会被忽略),例如
Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"),
对其进行签名并 POST。这也是为什么现有规范从未捕获到该 Bug 的原因:
Discourse 自身的工具在结构上无法生成触发负载。 -
使用
sso/sig作为正确编码的表单参数进行POST /admin/users/sync_sso
(在网络上传输时+作为%2B发送)。 -
响应:
422 {"failed":"FAILED","message":"Login Error"}。 -
将波浪号替换为
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。请注意,如果/当端点
被修复时,必须移除此变通方法,否则会导致过度编码。