Salut tout le monde~
J’ai trouvé un bug assez obscur concernant le b64encoded avec des valeurs ‘+’ causant des problèmes uniquement avec l’endpoint sync_sso. Je suis convaincu que ce n’est pas un problème d’intégration. J’ai utilisé Claude Fable pour diagnostiquer le bug et générer le rapport, j’espère que c’est ok. J’ai tout relu, je l’ai reproduit moi-même et je suis d’accord avec la cause racine et la correction. Dites-moi si vous avez besoin d’autre chose. Ce n’est plus super critique pour moi maintenant que j’ai trouvé une solution de contournement, mais c’est une bizarrerie que d’autres pourraient rencontrer.
Merci pour tout votre dur travail!
Brandon
Résumé
POST /admin/users/sync_sso décode deux fois le paramètre sso. Si la charge utile encodée en base64 contient un caractère +, l’endpoint le corrompt en un espace et rejette la requête avec l’erreur générique 422 Login Error — même si la charge utile et la signature sont correctes.
Pour le contenu de la charge utile (encodé en ASCII/URL), base64 ne produit un + que lorsqu’un caractère ~ (0x7E) est aligné correctement sur les octets — et uniquement pour les clients dont l’encodage URL laisse ~ non échappé selon la RFC 3986 (par ex. Python ≥ 3.7, encodeURIComponent de JavaScript).
Donc en pratique, cela se manifeste ainsi : la synchronisation fonctionne pour tout le monde, sauf pour les utilisateurs ayant un ~ dans leur bio/nom d’utilisateur/etc., et ce uniquement parfois, selon comment le reste de leur profil décale l’alignement. Cela ressemble à un mystère HMAC par utilisateur et est presque impossible à diagnostiquer de l’extérieur. Cela impacte probablement les forums de mon site plus que les autres, car les Coréens et les apprenants du coréen utilisent fréquemment ‘~’ pour terminer leurs bios.
Pour les connexions et synchronisations via navigateur, ce problème ne se manifeste pas (voir ci-dessous). C’est uniquement un problème pour les appels sync_sso côté serveur.
Cause racine
Dans Admin::UsersController#sync_sso (users_controller.rb#L500):
sso = DiscourseConnect.parse("sso=#{params[:sso]}&sig=#{params[:sig]}", server_session:)
params[:sso] a déjà été décodé par formulaire par Rack/Rails, mais il est interpolé de nouveau
dans la syntaxe de chaîne de requête sans ré-échappement. DiscourseConnect.parse exécute ensuite
Rack::Utils.parse_query dessus (discourse_connect_base.rb#L93) —
un deuxième décodage d’une valeur qui n’a été encodée qu’une seule fois. parse_query convertit le
+ littéral en un espace, et la vérification des caractères base64 ci-dessous lève PayloadParseError,
s’avançant en tant qu’erreur 422 “Login Error”. (Avant que cette vérification n’ait été ajoutée dans #26140, la même
corruption échouait lors de la comparaison de signature — même résultat.)
+ est le seul caractère de l’alphabet base64 que le décodage de formulaire mute, donc le double
décodage est une opération sans effet silencieux pour la plupart des charges utiles — c’est pourquoi cela est passé inaperçu.
Le flux de connexion via navigateur (session/sso_login) n’est pas affecté : il analyse la chaîne de requête brute, c’est-à-dire décode exactement une fois.
L’interpolation date de fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69 (Oct 2014,
“mise à jour triviale pour permettre un endpoint API pour sync_sso”), qui a remplacé l’original —
et correct — DiscourseSingleSignOn.parse(request.query_string).
Ce n’est pas une erreur d’encodage de l’intégrateur : le guide officiel sync_sso
montre la valeur sso envoyée avec un seul encodage de formulaire standard, et le gem officiel
discourse_api fait la même chose — donc un client respectant à la lettre rencontre ce problème chaque fois que le
base64 contient un +.
Étapes pour reproduire (déterministe)
-
Construisez une charge utile sync_sso signée valide pour n’importe quel utilisateur, incluant un champ dont la valeur
contient~~~(trois tilde consécutifs garantissent qu’un atterrit à la position d’octet
≡ 2 mod 3, forçant un+dans le base64).Note pour reproduire depuis Ruby : les charges utiles construites avec la classe
SingleSignOnde Discourse (ou le gemdiscourse_api) ne peuvent jamais contenir un+dans leur
base64 —Rack::Utils.build_queryéchappe~en%7E, et aucun octet de son alphabet de sortie
ne mappe vers un+base64. Pour reproduire, construisez la chaîne de requête interne à la main
avec un~~~littéral dans une valeur de champ (les clés inconnues sont ignorées), par ex.
Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"),
signez-la, et POSTez. C’est aussi pourquoi les specs existantes n’ont jamais attrapé le bug :
les outils de Discourse ne peuvent structurellement pas générer une charge utile déclencheuse. -
POST /admin/users/sync_ssoavecsso/sigen tant que params de formulaire correctement encodés
(+envoyé en tant que%2Bsur le réseau). -
Réponse :
422 {"failed":"FAILED","message":"Login Error"}. -
Même charge utile avec les tildes remplacés par
xxx(base64 maintenant sans+) : 200.
Vérifié contre une instance Discourse en production : structure de charge utile identique, un +
dans le base64 → 422 ; zéro → 200 ; et la charge utile 422 réussit si le + est pré-échappé
en %2B (c’est-à-dire en pré-compensant le décodage supplémentaire).
La spec de régression existante fait également aller la charge utile en boucle via
Rack::Utils.parse_query(sso.payload) avant le POST — la pré-décodant et annulant
le décodage supplémentaire du contrôleur — donc même une charge utile contenant un + passerait à travers.
Correction suggérée
Ré-échappez le paramètre avant de reconstruire la chaîne de requête, en miroir de ce que le
côté fournisseur SSO fait déjà avec CGI.escape(payload) dans la même classe
(discourse_connect_base.rb#L168):
sso = DiscourseConnect.parse(
"sso=#{CGI.escape(params[:sso])}&sig=#{params[:sig]}", server_session:
)
Solution de contournement pour les intégrateurs (en attendant la correction)
Pré-échappez + en %2B dans la valeur sso pour les appels sync_sso uniquement (pas pour la
redirection de connexion via navigateur). Notez que cette solution de contournement doit être supprimée si/quand l’endpoint
est corrigé, car cela entraînerait alors un sur-encodage.