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)
-
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
SingleSignOndi Discourse
(o il gemdiscourse_api) non possono mai contenere un+nel loro base64 —Rack::Utils.build_queryescape~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. -
POST /admin/users/sync_ssoconsso/sigcome parametri form correttamente codificati
(+inviato come%2Bsulla rete). -
Risposta:
422 {"failed":"FAILED","message":"Login Error"}. -
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.