Hola a todos~
He encontrado un error bastante oscuro relacionado con valores codificados en base64 que contienen +, lo cual causa problemas únicamente con el endpoint sync_sso. Estoy convencido de que no se trata de un problema de integración. Utilicé Claude Fable para diagnosticar el error y generar el informe, espero que esto esté bien. He revisado todo, lo he reproducido yo mismo y estoy de acuerdo con la causa raíz y la solución. Avísame si necesitas algo más. Ya no es súper crítico para mí porque encontré una solución alternativa, pero es un problema extraño que otros podrían encontrarse.
¡Gracias por todo vuestro arduo trabajo!
Brandon
Resumen
POST /admin/users/sync_sso decodifica dos veces el parámetro sso. Si la carga útil codificada en base64 contiene un carácter +, el endpoint lo corrompe convirtiéndolo en un espacio y rechaza la solicitud con el genérico error 422 Login Error, aunque la carga útil y la firma sean correctas.
Para contenido de carga útil ASCII (codificado en URL), base64 solo produce un + cuando un carácter ~ (0x7E) se encuentra en la alineación de bytes correcta, y solo para clientes cuya codificación de URL deja ~ sin escapar según la RFC 3986 (por ejemplo, Python ≥ 3.7, encodeURIComponent de JavaScript). Por lo tanto, en la práctica, esto se manifiesta como: la sincronización funciona para todos, excepto para los usuarios que tienen un ~ en su biografía/nombre de usuario/etc., y solo a veces, dependiendo de cómo el resto de su perfil desplace la alineación. Parece un misterio de HMAC por usuario y es casi imposible de diagnosticar desde fuera. Esto probablemente afecta más a los foros de mi sitio que a otros, ya que los coreanos y los estudiantes de coreano utilizan ‘~’ con frecuencia para finalizar sus biografías.
Para los inicios de sesión y sincronizaciones desde el navegador, este problema no se manifiesta (ver más abajo). Solo es un problema para las llamadas server-side a sync_sso.
Causa raíz
En Admin::UsersController#sync_sso (users_controller.rb#L500):
sso = DiscourseConnect.parse("sso=#{params[:sso]}&sig=#{params[:sig]}", server_session:)
params[:sso] ya ha sido decodificado como formulario por Rack/Rails, pero se interpola de nuevo en la sintaxis de cadena de consulta sin volver a escapar. DiscourseConnect.parse luego ejecuta Rack::Utils.parse_query sobre ella (discourse_connect_base.rb#L93), una segunda decodificación de un valor que solo fue codificado una vez. parse_query convierte el + literal en un espacio, y la comprobación de caracteres base64 a continuación lanza PayloadParseError, que se presenta como 422 “Login Error”. (Antes de que se añadiera esa comprobación en #26140, la misma corrupción fallaba en la comparación de firmas en su lugar, con el mismo resultado).
+ es el único carácter del alfabeto base64 que la decodificación de formulario muta, por lo que la doble decodificación es una operación nula silenciosa para la mayoría de las cargas útiles, lo que explica por qué esto ha pasado desapercibido. El flujo de inicio de sesión del navegador (session/sso_login) no se ve afectado: analiza la cadena de consulta cruda, es decir, decodifica exactamente una vez.
La interpolación data de fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69 (octubre de 2014, “actualización trivial para permitir endpoint de API para sync_sso”), que reemplazó el original —y correcto— DiscourseSingleSignOn.parse(request.query_string).
Este no es un error de codificación del integrador: la guía oficial de sync_sso muestra el valor sso enviado con una codificación de formulario estándar única, y la gema oficial discourse_api hace lo mismo, por lo que un cliente que siga las instrucciones al pie de la letra se encontrará con esto siempre que el base64 contenga un +.
Pasos para reproducir (determinista)
-
Construye una carga útil sync_sso firmada válida para cualquier usuario, incluyendo un campo cuyo valor contenga
~~~(tres tilde consecutivas garantizan que una cae en la posición de byte ≡ 2 mod 3, forzando un+en el base64).Nota para reproducir desde Ruby: las cargas útiles construidas con la propia clase
SingleSignOnde Discourse (o la gemadiscourse_api) nunca pueden contener un+en su base64 —Rack::Utils.build_queryescapa~como%7E, y ningún byte en su alfabeto de salida mapea a un+base64. Para reproducir, construye la cadena de consulta interna a mano con un~~~literal en un valor de campo (las claves desconocidas se ignoran), por ejemploBase64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"), fírmala y haz POST. Esta es también la razón por la que las especificaciones existentes nunca han detectado el error: las propias herramientas de Discourse no pueden generar estructuralmente una carga útil que lo active. -
POST /admin/users/sync_ssoconsso/sigcomo parámetros de formulario correctamente codificados (+enviado como%2Ben la red). -
Respuesta:
422 {"failed":"FAILED","message":"Login Error"}. -
La misma carga útil con las tildes reemplazadas por
xxx(base64 ahora libre de+): 200.
Verificado contra una instancia de Discourse en producción: estructura de carga útil idéntica, un + en el base64 → 422; cero → 200; y la carga útil 422 tiene éxito si el + se pre-escapa como %2B (es decir, compensando de antemano la decodificación extra).
La especificación de regresión existente también hace un ciclo completo de la carga útil a través de Rack::Utils.parse_query(sso.payload) antes de hacer POST —decodificándola de antemano y cancelando la decodificación extra del controlador—, por lo que incluso una carga útil que contenga + pasaría desapercibida.
Solución sugerida
Volver a escapar el parámetro antes de reconstruir la cadena de consulta, imitando lo que el lado del proveedor de SSO ya hace con CGI.escape(payload) en la misma clase (discourse_connect_base.rb#L168):
sso = DiscourseConnect.parse(
"sso=#{CGI.escape(params[:sso])}&sig=#{params[:sig]}", server_session:
)
Solución alternativa para integradores (hasta que se corrija)
Pre-escapa + como %2B en el valor sso para llamadas sync_sso únicamente (no para la redirección de inicio de sesión del navegador). Ten en cuenta que esta solución alternativa debe eliminarse si/cuando se corrija el endpoint, ya que entonces codificaría en exceso.