La sincronizzazione SSO lato server rifiuta payload validi con 422 "Errore di accesso" quando il base64 contiene un `+` (scatenato da `~` nei campi sincronizzati)

Ciao a tutti~

Ho trovato un bug piuttosto oscuro relativo all’encoding base64 con valori ‘+’ che causa problemi esclusivamente con l’endpoint sync_sso. Sono convinto che non si tratti di un problema di integrazione. Ho utilizzato Claude Fable per diagnosticare il bug e generare il rapporto, spero sia accettabile. Ho revisionato tutto, riprodotto il problema personalmente e concordo con la causa radice e la soluzione proposta. Fate sapere se avete bisogno di altro. Non è più un problema critico per me dato che ho trovato una soluzione alternativa, ma è una stranezza che altri potrebbero incontrare.

Grazie per tutto il vostro duro lavoro!
Brandon

Riepilogo

POST /admin/users/ssync_sso decodifica due volte il parametro sso. Se il payload codificato in base64 contiene un carattere +, l’endpoint lo corrompe trasformandolo in uno spazio e rifiuta la richiesta con il generico errore 422 Login Error — anche se il payload e la firma sono corretti.

Per i contenuti del payload ASCII (codificati in URL), base64 produce un + solo quando un carattere ~ (0x7E) si trova in un allineamento di byte specifico — e solo per i client la cui codifica URL lascia ~ non escapato secondo RFC 3986 (ad es. Python ≥ 3.7, encodeURIComponent di JavaScript).
Quindi, nella pratica, questo si manifesta come: la sincronizzazione funziona per tutti, tranne che per gli utenti con un ~ nella loro biografia/nome utente/etc., e solo a volte, a seconda di come il resto del loro profilo sposta l’allineamento. Sembra un mistero HMAC specifico per utente ed è quasi impossibile da diagnosticare dall’esterno. Questo probabilmente impatta i forum del mio sito più di altri, poiché coreani e studenti di coreano usano frequentemente ‘~’ per terminare le biografie.

Per gli accessi e le sincronizzazioni dal browser, questo problema non si manifesta (vedi sotto). È un problema solo per le chiamate server-side sync_sso.

Causa radice

In Admin::UsersController#sync_sso (users_controller.rb#L500):

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

params[:sso] è già stato decodificato come form da Rack/Rails, ma viene interpolato nuovamente nella sintassi della query string senza essere ri-escapato. DiscourseConnect.parse esegue quindi Rack::Utils.parse_query su di esso (discourse_connect_base.rb#L93) —
una seconda decodifica di un valore che era stato codificato una sola volta. parse_query converte il + letterale in uno spazio, e il controllo dei caratteri base64 qui sotto solleva PayloadParseError, che si manifesta come 422 “Login Error”. (Prima che questo controllo fosse aggiunto in #26140, la stessa corruzione falliva al confronto della firma — stesso risultato.)

+ è l’unico carattere dell’alfabeto base64 che viene mutato dalla decodifica form, quindi la doppia decodifica è un’operazione silente no-op per la maggior parte dei payload — ecco perché questo è sopravvissuto inosservato. Il flusso di accesso dal browser (session/sso_login) non è interessato: analizza la query string grezza, ovvero decodifica esattamente una volta.

L’interpolazione risale a fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69 (ottobre 2014,
“aggiornamento banale per consentire endpoint API per sync_sso”), che ha sostituito il originale —
e corretto — DiscourseSingleSignOn.parse(request.query_string).

Questo non è un errore di codifica dell’integratore: la guida ufficiale sync_sso
mostra il valore sso inviato con una singola codifica form standard, e il gem ufficiale
discourse_api fa lo stesso — quindi un client letterale incontra questo problema ogni volta che il base64 contiene casualmente un +.

Passaggi per riprodurre (deterministici)

  1. Costruisci un payload sync_sso firmato valido per qualsiasi utente, includendo un campo il cui valore
    contiene ~~~ (tre tilde consecutive garantiscono che una cada in posizione di byte
    ≡ 2 mod 3, forzando un + nel base64).

    Nota per la riproduzione da Ruby: i payload costruiti con la classe SingleSignOn di Discourse
    (o il gem discourse_api) non possono mai contenere un + nel loro base64 — Rack::Utils.build_query escape ~ come %7E, e nessun byte nel suo alfabeto di output mappa a un + base64. Per riprodurre, costruisci la query string interna a mano
    con un ~~~ letterale in un valore di campo (le chiavi sconosciute vengono ignorate), ad es.
    Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"),
    firmalo e fai POST. Questo è anche il motivo per cui i test esistenti non hanno mai catturato il bug:
    gli strumenti di Discourse non possono strutturalmente generare un payload che scateni il problema.

  2. POST /admin/users/sync_sso con sso/sig come parametri form correttamente codificati
    (+ inviato come %2B sulla rete).

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

  4. Stesso payload con le tilde sostituite da xxx (base64 ora privo di +): 200.

Verificato contro un’istanza Discourse di produzione: struttura del payload identica, un +
nel base64 → 422; zero → 200; e il payload 422 ha successo se il + è pre-escapato
come %2B (cioè pre-compensando la decodifica extra).

Il test di regressione esistente fa anche il round-trip del payload attraverso
Rack::Utils.parse_query(sso.payload) prima del POST — decodificandolo in anticipo e annullando
la decodifica extra del controller — quindi anche un payload contenente + passerebbe inosservato.

Soluzione suggerita

Ri-escapa il parametro prima di ricostruire la query string, specchiando ciò che il lato provider SSO fa già con CGI.escape(payload) nella stessa classe
(discourse_connect_base.rb#L168):

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

Soluzione alternativa per gli integratori (finché non sarà corretto)

Pre-escapa + come %2B nel valore sso per le chiamate sync_sso solo (non per il
redirect di accesso dal browser). Nota che questa soluzione alternativa deve essere rimossa se/quando l’endpoint
sarà corretto, poiché altrimenti codificherebbe eccessivamente.