Serverseitiges sync_sso lehnt gültige Payloads mit 422 "Login Error" ab, wenn das Base64 ein `+` enthält (ausgelöst durch `~` in synchronisierten Feldern)

Hallo zusammen~

Ich habe einen ziemlich obskuren Bug gefunden, bei dem b64-codierte Werte mit ‘+’ Zeichen Probleme ausschließlich am sync_sso-Endpunkt verursachen. Ich bin überzeugt, dass es sich nicht um ein Integrationsproblem handelt. Ich habe Claude Fable verwendet, um den Bug zu diagnostizieren und den Bericht zu erstellen – ich hoffe, das ist in Ordnung. Ich habe alles überprüft, den Fehler selbst reproduziert und stimme der Ursache und dem Fix zu. Sagt Bescheid, wenn ihr noch etwas braucht. Für mich ist es nicht mehr super kritisch, da ich eine Umgehungslösung gefunden habe, aber es ist eine seltsame, auf die andere auch stoßen könnten.

Danke für eure harte Arbeit!
Brandon

Zusammenfassung

POST /admin/users/sync_sso decodiert den sso-Parameter doppelt. Wenn die base64-codierte Nutzlast ein +-Zeichen enthält, wird dieses vom Endpunkt in ein Leerzeichen verwandelt und die Anfrage mit der generischen Fehlermeldung 422 Login Error abgelehnt – obwohl Nutzlast und Signatur korrekt sind.

Bei ASCII-Inhalten (URL-codiert) erzeugt base64 nur dann ein +, wenn ein ~-Zeichen (0x7E) an der richtigen Byte-Ausrichtung sitzt – und das nur für Clients, deren URL-Codierung ~ gemäß RFC 3986 unmaskiert lässt (z. B. Python ≥ 3.7, JavaScripts encodeURIComponent). In der Praxis äußert sich das also so: Die Synchronisation funktioniert für alle, außer für Benutzer mit einem ~ in ihrer Bio/ihrem Benutzernamen usw., und das auch nur manchmal, abhängig davon, wie der Rest ihres Profils die Ausrichtung verschiebt. Es sieht aus wie ein benutzerspezifisches HMAC-Rätsel und ist von außen nahezu unmöglich zu diagnostizieren. Dies betrifft wahrscheinlich die Foren meiner Seite stärker als andere, da Koreaner und Koreanisch-Lernende häufig ‘~’ verwenden, um Bios zu beenden.

Bei Browser-Logins und -Synchronisierungen tritt dieses Problem nicht auf (siehe unten). Es ist nur ein Problem für serverseitige sync_sso-Aufrufe.

Ursache

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

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

params[:sso] wurde bereits von Rack/Rails form-dekodiert, wird aber ohne erneutes Escapen in die Query-String-Syntax interpoliert. DiscourseConnect.parse führt dann Rack::Utils.parse_query darauf aus (discourse_connect_base.rb#L93) –
eine zweite Dekodierung eines Werts, der nur einmal kodiert wurde. parse_query wandelt das
literale + in ein Leerzeichen um, und die nachfolgende base64-Zeichenprüfung löst PayloadParseError aus,
welches als 422 “Login Error” angezeigt wird. (Bevor dieser Check in #26140 hinzugefügt wurde, scheiterte dieselbe Korruption stattdessen beim Signaturvergleich – gleiches Ergebnis.)

+ ist das einzige base64-Alphabet-Zeichen, das durch Form-Dekodierung mutiert wird, daher ist die doppelte
Dekodierung für die meisten Nutzlasten ein stiller No-Op – weshalb dies unbemerkt geblieben ist.
Der Browser-Login-Flow (session/sso_login) ist nicht betroffen: Er parst den rohen Query-
String, d. h. decodiert genau einmal.

Die Interpolation stammt aus fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69 (Okt. 2014,
“trivial update to allow api endpoint for sync_sso”), die das ursprüngliche –
und korrekte – DiscourseSingleSignOn.parse(request.query_string) ersetzte.

Dies ist kein Codierungsfehler des Integrators: Die offizielle sync_sso-Anleitung
zeigt den sso-Wert mit einer einzigen Standard-Form-Codierung gesendet, und das offizielle
discourse_api-Gem macht dasselbe – daher trifft ein buchstabengetreuer Client auf dieses Problem, wann immer die base64-Nutzlast ein + enthält.

Schritte zur Reproduktion (deterministisch)

  1. Erstelle eine gültige, signierte sync_sso-Nutzlast für einen beliebigen Benutzer, einschließlich eines Feldes, dessen Wert
    ~~~ enthält (drei aufeinanderfolgende Tilden garantieren, dass eines an der Byte-Position
    ≡ 2 mod 3 landet und ein + in die base64-Nutzlast zwingt).

    Hinweis zur Reproduktion in Ruby: Nutzlasten, die mit Discourses eigenem
    SingleSignOn-Klasse (oder dem discourse_api-Gem) erstellt werden, können niemals ein + in ihrer
    base64-Nutzlast enthalten – Rack::Utils.build_query maskiert ~ als %7E, und kein Byte in seinem Ausgabe-
    Alphabet wird zu einem base64 + gemappt. Um es zu reproduzieren, baue die innere Query-String manuell
    mit einem literalen ~~~ in einem Feldwert (unbekannte Schlüssel werden ignoriert), z. B.
    Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"),
    signiere sie und sende einen POST. Dies ist auch der Grund, warum die vorhandenen Spezifikationen den Bug nie erwischt haben:
    Discourses eigene Tools können strukturell keine auslösende Nutzlast generieren.

  2. POST /admin/users/sync_sso mit sso/sig als korrekt codierte Form-Parameter
    (+ auf der Leitung als %2B gesendet).

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

  4. Gleiche Nutzlast mit den Tilden durch xxx ersetzt (base64 ist jetzt +-frei): 200.

Gegen eine produktive Discourse-Instanz verifiziert: Identische Nutzlaststruktur, ein +
in der base64 → 422; null → 200; und die 422-Nutzlast succeeds, wenn das + vorab als %2B maskiert wird
(d. h. vorab kompensiert für die zusätzliche Dekodierung).

Die vorhandene Regressionsspezifikation leitet die Nutzlast auch durch
Rack::Utils.parse_query(sso.payload) vor dem Posten – decodiert sie vorab und hebt
das zusätzliche Decodieren des Controllers auf – sodass selbst eine +-haltige Nutzlast daran vorbeiglühen würde.

Vorgeschlagener Fix

Maskiere den Parameter erneut, bevor du die Query-String wieder aufbaust, und spiegle dabei, was die
SSO-Provider-Seite bereits mit CGI.escape(payload) in derselben Klasse macht
(discourse_connect_base.rb#L168):

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

Umgehungslösung für Integratoren (bis zur Behebung)

Maskiere + vorab als %2B im sso-Wert nur für sync_sso-Aufrufe (nicht für den
Browser-Login-Redirect). Beachte, dass diese Umgehungslösung entfernt werden muss, wenn/sofern der Endpunkt
behoben wird, da sie dann zu einer Über-Codierung führen würde.