Sincronização SSO no lado do servidor rejeita cargas válidas com 422 "Erro de Login" quando o base64 contém um `+` (acionado por `~` em campos sincronizados)

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)

  1. 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 SingleSignOn do Discourse (ou a gem discourse_api) nunca podem conter um + em seu base64 — Rack::Utils.build_query escapa ~ 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.

  2. POST /admin/users/sync_sso com sso/sig como parâmetros de formulário corretamente codificados (+ enviado como %2B na rede).

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

  4. 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.