Olá a todos~
Encontrei um bug bem obscuro relacionado ao b64encoded com valores ‘+’ causando problemas apenas no endpoint sync_sso. Estou confiante de que não é um problema de integração. Usei o Claude Fable para diagnosticar o bug e gerar o relatório, espero que esteja tudo bem. Revisei tudo, reproduzi o problema eu mesmo e concordo com a causa raiz e a correção. Me avise se precisarem de mais alguma coisa. Não é super crítico para mim agora, já que encontrei uma solução alternativa, mas é um problema estranho que outros podem encontrar.
Obrigado por todo o trabalho duro!
Brandon
Resumo
POST /admin/users/sync_sso decodifica duas vezes o parâmetro sso. Se a carga útil codificada em base64 contiver um caractere +, o endpoint o corrói para um espaço e rejeita a solicitação com o erro genérico 422 Login Error — mesmo que a carga útil e a assinatura estejam corretas.
Para conteúdo de carga útil ASCII (codificado em URL), o base64 só produz um + quando um caractere ~ (0x7E) está no alinhamento de byte correto — e apenas para clientes cuja codificação de URL deixa ~ sem escape conforme RFC 3986 (por exemplo, Python ≥ 3.7, encodeURIComponent do JavaScript).
Na prática, isso se manifesta como: a sincronização funciona para todos, exceto para usuários com um ~ em sua bio/nome de usuário/etc., e apenas às vezes, dependendo de como o restante do perfil deles altera o alinhamento. Parece um mistério de HMAC por usuário e é quase impossível de diagnosticar de fora. Isso provavelmente impacta mais os fóruns do meu site do que outros, pois coreanos e aprendizes de coreano usam ‘~’ frequentemente para terminar bios.
Para logins e sincronizações de navegador, esse problema não se manifesta (veja abaixo). É apenas um problema para chamadas sync_sso do lado do servidor.
Causa raiz
Em Admin::UsersController#sync_sso (users_controller.rb#L500):
sso = DiscourseConnect.parse("sso=#{params[:sso]}&sig=#{params[:sig]}", server_session:)
params[:sso] já foi decodificado de formulário pelo Rack/Rails, mas é interpolado de volta na sintaxe de string de consulta sem re-escape. DiscourseConnect.parse então executa Rack::Utils.parse_query sobre ele (discourse_connect_base.rb#L93) — uma segunda decodificação de um valor que foi codificado apenas uma vez. parse_query converte o + literal em um espaço, e a verificação de caractere base64 abaixo levanta PayloadParseError, manifestado como 422 “Login Error”. (Antes que essa verificação fosse adicionada em #26140, a mesma corrupção falhava na comparação de assinatura — mesmo resultado.)
+ é o único caractere do alfabeto base64 que a decodificação de formulário muta, então a decodificação dupla é um no-op silencioso para a maioria das cargas úteis — é por isso que isso sobreviveu sem ser notado. O fluxo de login do navegador (session/sso_login) não é afetado: ele analisa a string de consulta bruta, ou seja, decodifica exatamente uma vez.
A interpolação data de fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69 (Outubro de 2014, “atualização trivial para permitir endpoint de api para sync_sso”), que substituiu o original — e correto — DiscourseSingleSignOn.parse(request.query_string).
Isso não é um erro de codificação do integrador: o guia oficial sync_sso mostra o valor sso enviado com uma única codificação de formulário padrão, e a gem oficial discourse_api faz o mesmo — então um cliente que segue à risca encontra isso sempre que o base64 acontece de conter um +.
Passos para reproduzir (determinístico)
-
Construa uma carga útil sync_sso assinada válida para qualquer usuário, incluindo um campo cujo valor contenha
~~~(três tilde consecutivos garantem que um caia na posição de byte ≡ 2 mod 3, forçando um+no base64).Nota para reprodução em Ruby: cargas úteis construídas com a própria classe
SingleSignOndo Discourse (ou a gemdiscourse_api) nunca podem conter um+em seu base64 —Rack::Utils.build_queryescapa~como%7E, e nenhum byte em seu alfabeto de saída mapeia para um+base64. Para reproduzir, construa a string de consulta interna manualmente com um~~~literal em um valor de campo (chaves desconhecidas são ignoradas), por exemplo,
Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"), assine-o e faça POST. É também por isso que os specs existentes nunca pegaram o bug: as próprias ferramentas do Discourse estruturalmente não podem gerar uma carga útil que dispare o problema. -
POST /admin/users/sync_ssocomsso/sigcomo parâmetros de formulário corretamente codificados (+enviado como%2Bna rede). -
Resposta:
422 {"failed":"FAILED","message":"Login Error"}. -
Mesma carga útil com as tildes substituídas por
xxx(base64 agora sem+): 200.
Verificado contra uma instância de produção do Discourse: estrutura de carga útil idêntica, um + no base64 → 422; zero → 200; e a carga útil 422 tem sucesso se o + for pré-escapado como %2B (ou seja, pré-compensando a decodificação extra).
O spec de regressão existente também faz round-trip da carga útil através de Rack::Utils.parse_query(sso.payload) antes de postar — pré-decodificando-o e cancelando a decodificação extra do controlador — então mesmo uma carga útil com + passaria despercebida.
Correção sugerida
Re-escape o parâmetro antes de reconstruir a string de consulta, espelhando o que o lado do provedor SSO já faz com CGI.escape(payload) na mesma classe (discourse_connect_base.rb#L168):
sso = DiscourseConnect.parse(
"sso=#{CGI.escape(params[:sso])}&sig=#{params[:sig]}", server_session:
)
Solução alternativa para integradores (até ser corrigido)
Pré-escape + como %2B no valor sso apenas para chamadas sync_sso (não para o redirecionamento de login do navegador). Note que essa solução alternativa deve ser removida se/quando o endpoint for corrigido, pois então causaria super-codificação.