Reports
MEDIA[OTROS]#3972760

Redirección abierta en app.hey.com con "HTTPS://" en mayúsculas carga una web ajena dentro de la app de escritorio de HEY

El endpoint /proxy_image de app.hey.com comparaba el esquema distinguiendo mayúsculas. Con "HTTPS://" redirigía a cualquier web, y la app de escritorio la abría en una ventana propia de HEY.

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

HEY, el servicio de correo de Basecamp, tiene en app.hey.com/proxy_image un endpoint que recibe una URL de imagen, la firma y redirige al proxy de imágenes gopher.hey.com. El fallo estaba en cómo decidía si la URL era "proxyable": comprobaba el esquema https:// distinguiendo mayúsculas de minúsculas. Si el esquema llegaba como HTTPS://, no lo reconocía y, en lugar de rechazarlo, respondía con un 302 directo a esa URL. Resultado: una redirección abierta sin autenticación.

Por sí sola sería un fallo menor, pero la app de escritorio de HEY (Electron) decide qué contenido es "de HEY" y puede abrirse en una ventana propia comprobando si la URL está en app.hey.com. Un enlace que de verdad apunta a app.hey.com supera esa comprobación, y luego la redirección lleva la ventana a la web del atacante.

  • Tipo: redirección abierta (CWE-601) encadenada con una lista de orígenes permitidos en la app de escritorio.
  • Endpoint: app.hey.com/proxy_image/<URL codificada>.
  • Versión donde se confirmó: HEY para Linux 1.3.6 (Electron 40.9.3). Según el investigador, macOS y Windows comparten el mismo código; HEY para Android 1.31.0 abre el enlace en el navegador externo y no está afectado.
  • Severidad: media. Recompensa: sí, importe no público.
  • Enviado el 27-ago-2026 y divulgado el 15-oct-2026. Estado: resuelto.

Pasos de reproducción

  1. Comprobar la redirección con el esquema en mayúsculas. El endpoint devuelve un 302 hacia la URL tal cual:
curl -sI "https://app.hey.com/proxy_image/HTTPS%3A%2F%2Fexample.com%2F"

HTTP/2 302
location: HTTPS://example.com/
  1. Comparar con la misma petición en minúsculas, que se comporta como debe y pasa por el proxy firmado:
curl -sI "https://app.hey.com/proxy_image/https%3A%2F%2Fexample.com%2F"

HTTP/2 302
location: https://gopher.hey.com/sNnTdUWAvh27xF-f273gMtyxvGbOnLHSflbedosO23Ns=/https://example.com/

La diferencia en la cabecera de respuesta:

- location: https://gopher.hey.com/sNnTdUWAvh27xF-f273gMtyxvGbOnLHSflbedosO23Ns=/https://example.com/
+ location: HTTPS://example.com/

Un esquema javascript: sigue rechazándose con un 403, así que no falta un filtro: falla la comparación del esquema. Según la RFC 3986 (sección 3.1) los esquemas no distinguen mayúsculas, de modo que HTTPS:// es una URL válida.

  1. Preparar el enlace para la app de escritorio. Hacen falta dos detalles:
  • La URL interna debe ir codificada (percent-encoding). Sin codificar, Rails colapsa la doble barra y devuelve Location: HTTPS:/example.com/, que el navegador interpreta como ruta relativa dentro de app.hey.com.
  • La URL debe terminar en /print o en .text, que es lo que comprueba la lista de ventanas permitidas de la app. Un parámetro como ?x=/print lo cumple sin cambiar el destino.
https://app.hey.com/proxy_image/HTTPS%3A%2F%2Fexample.com%2F?x=/print
  1. Enviar ese enlace por correo a un usuario de HEY.
  1. La víctima abre el mensaje en la app de escritorio y pulsa el enlace. Se abre una ventana de HEY que muestra example.com (o cualquier dominio que controle el atacante). El investigador confirmó en su servidor que la petición la hacía la propia app, con el agente HEY/1.3.6 Chrome/144.0.7559.236 Electron/40.9.3. Como control, un enlace externo normal en el mismo mensaje sí se abría en el navegador del sistema.

Por qué pasa todas las comprobaciones

El enlace supera tres controles, cada uno correcto por separado, porque su origen real es app.hey.com:

  • El saneador de correo lo conserva: es una URL http normal.
  • El componente hey-message-content solo desvía al navegador externo los enlaces de otro origen:
if (element && element.origin != location.origin && element.protocol.startsWith("http")) {
    return element;   // routed out to the OS browser
}
  • La lista de permitidos de Electron (dist-js/index.js:72-74) abre una ventana nueva para URLs internas que acaben en .text o /print:
else if (Stage.current.locationIsInternal(url) && (url.endsWith(".text") || url.endsWith("/print"))) {
    createWindow(url);          // new HEY BrowserWindow, window.loadURL(url)
}

La ventana carga la URL de app.hey.com, recibe el 302 y termina en el dominio del atacante.

El investigador señala un segundo camino al mismo resultado: Stage.isInternalAttachmentLink (dist-js/stage.js:47-52) deja pasar sin el control de enlaces externos las URLs del mismo origen con ?disposition=attachment. El propio comentario del código advierte de que, si se aceptara esa cadena en una URL arbitraria, un enlace externo podría cargar una página del atacante en la ventana de la app; aquí ocurre lo mismo, pero con una URL que sí es interna.

La misma causa en otro sitio

El reescritor de imágenes del correo entrante tenía la misma comparación. Una etiqueta <img src="HTTPS://host/pixel.png"> no se reescribía hacia gopher.hey.com y el mensaje guardaba la URL original del remitente. Se probaron diez variantes (//host, https:/host, https:host, :443, espacios iniciales, query con fragmento…) y solo escapaba la del esquema en mayúsculas. No se pudo convertir en fuga: la CSP (img-src) bloqueaba la carga en la web y en la app de Linux, y en un móvil Android las imágenes sin proxy no se llegaron a descargar. El investigador lo aportó como prueba de que el fallo era sistemático y no de un solo endpoint.

Impacto

Basta con conocer el correo de la víctima. El atacante envía un mensaje con un enlace que apunta de verdad a https://app.hey.com, el dominio de su propio proveedor de correo. Con un clic, la página del atacante se muestra dentro de una ventana de la app de HEY, que no tiene barra de direcciones ni indicador de origen. Nada en pantalla la distingue de la interfaz legítima.

El ataque natural es una pantalla falsa de inicio de sesión o de reautenticación. Resulta especialmente creíble porque HEY ya pide la contraseña dentro de la app antes de acciones sensibles como exportar el buzón (/accounts/:id/exports). Con la contraseña, el atacante accede al buzón completo de la víctima. El consejo habitual de "mira la URL antes de escribir la contraseña" no sirve aquí: la URL del enlace era de HEY.

El investigador valoró el alcance como "Changed" en CVSS, porque el defecto está en la web (app.hey.com) y el control que anula, en otro activo: la regla de la app de escritorio de que lo del mismo origen es seguro.

Remediación

El reporte figura como resuelto; no detalla qué cambio aplicó Basecamp. Las correcciones que propuso el investigador:

  • Analizar la URL y comparar el esquema normalizado antes de decidir si se pasa por el proxy, tanto en /proxy_image como en el reescritor de imágenes del correo entrante.
  • Fallar en cerrado: si el valor no es una URL http(s) reconocible y apta para el proxy, devolver un error en lugar de redirigir al cliente.
  • Dejar de considerar "mismo origen" como sinónimo de "seguro para cargar en una ventana de la app", porque cualquier redirección abierta en app.hey.com vuelve a abrir las dos protecciones de escritorio.

Qué aprender de este caso

  • En endpoints de proxy o de redirección que deciden según el esquema, prueba HTTPS://, HtTp:// y variantes en mayúsculas: una comparación de cadenas tipo startsWith("https://") deja pasar URLs válidas por la RFC.
  • La rama "no reconozco esto" de un endpoint es tan importante como la principal: aquí lo no reconocido se redirigía en vez de devolver un error, y eso convirtió un proxy de imágenes en una redirección abierta.
  • En apps Electron, cualquier lista de permitidos basada en el origen (locationIsInternal, origin == location.origin) hereda todas las redirecciones abiertas de ese dominio. Si buscas fallos en una app de escritorio, empieza por encontrar un 302 abierto en su propio dominio.
  • Las condiciones del tipo url.endsWith("/print") se cumplen con un simple ?x=/print; si hay que comprobar la ruta, analiza la URL y compara pathname, no la cadena entera.