Resumen
HackerOne Code (app.pullrequest.com) envía el token de restablecimiento de contraseña dentro de la URL del enlace que llega por correo, como clave suelta de la query string. Esa página carga el SDK de navegador de Datadog RUM (versión 4.14.0), que anota la URL entera en el campo view.url de cada vista y la manda a rum.browser-intake-datadoghq.com. Resultado: el token de 64 caracteres hexadecimales, todavía vigente porque el formulario aún no se ha enviado, llega a Datadog en cuanto se abre el enlace.
El origen es la configuración del SDK. Al iniciarlo no se define ningún callback beforeSend, que es el punto que Datadog ofrece para limpiar campos antes de enviarlos, y sampleRate vale 100. Por tanto, se registran todas las sesiones de todos los usuarios y no se redacta ninguna URL en ninguna ruta.
Además, la misma sesión de RUM (session_id) recoge más tarde las rutas autenticadas, que llevan el usuario (handle) de la cuenta en la ruta. El endpoint de restablecimiento pide token y email a la vez; con el token y el handle agrupados bajo una misma sesión, quien lea esos datos tiene mucho más fácil saber a qué cuenta pertenece cada token.
El investigador lo relaciona con un reporte anterior, el #4000185, sobre el mismo activo: entonces el token se filtraba a Segment a través de los eventos de página automáticos. Aquella corrección añadió un middleware de Segment (analytics.addSourceMiddleware) que limpia la URL solo en las rutas /password-reset y /forgot-password, y solo dentro del flujo de Segment. Datadog RUM es otra librería, con su propio envío, así que ese arreglo no le afectaba. Durante las pruebas, la página ya no mandaba nada a Segment, Intercom ni Sentry: Datadog era el único destino que recibía la URL con el token.
| Elemento | Valor | | --- | --- | | Página | app.pullrequest.com/password-reset | | Endpoint de restablecimiento | POST /api/v1/password-reset (campos token, email, password) | | SDK | Datadog RUM browser SDK 4.14.0 | | Destino | rum.browser-intake-datadoghq.com/api/v2/rum | | Etiquetas | service:pullrequest, env:production |
Configuración leída desde la consola del navegador:
> Object.keys(window.DD_RUM.getInitConfiguration())
["applicationId","clientToken","site","service","env","version","sampleRate","trackInteractions"]
> typeof window.DD_RUM.getInitConfiguration().beforeSend
"undefined"
> window.DD_RUM.getInitConfiguration().sampleRate
100
Forma del enlace de restablecimiento (el token es la clave, sin valor, antes de los parámetros de campaña):
https://app.pullrequest.com/password-reset?<64 hex characters>&utm_swu=547&utm_source=account%20action%20emails&utm_campaign=Reset%20Password%20...&utm_medium=email
Pasos de reproducción
- Pide un restablecimiento de contraseña para una cuenta tuya en
app.pullrequest.com. - Abre el enlace del correo, pero no envíes el formulario. La página muestra con normalidad los campos de email y nueva contraseña, así que el token sigue sin usar.
- En las herramientas de red verás peticiones POST a Datadog con
service:pullrequest, aceptadas con HTTP 202 y repetidas cada unos 30 segundos mientras la vista sigue activa:
POST https://rum.browser-intake-datadoghq.com/api/v2/rum
?ddsource=browser&ddtags=sdk_version:4.14.0,env:production,service:pullrequest...
&batch_time=1790747851585
-> HTTP 202
POST https://rum.browser-intake-datadoghq.com/api/v2/rum
...&batch_time=1790747881586
-> HTTP 202
- En la consola, con
TOKENigual a los 64 caracteres hexadecimales del enlace, comprueba que el token está en el campo que se envía y cómo está configurado el SDK (anota elsession_id):
window.DD_RUM.getInternalContext().view.url.includes(TOKEN) // true
typeof window.DD_RUM.getInitConfiguration().beforeSend // "undefined"
window.DD_RUM.getInitConfiguration().sampleRate // 100
window.DD_RUM.getInternalContext().session_id // note this
- Completa el restablecimiento e inicia sesión. En una página autenticada vuelve a leer
session_id: es el mismo, y elview.urlde esa vista contiene el handle de la cuenta. El investigador lo resume así:
view 1 /password-reset?<live token> session_id [REDACTADO]...
view 2 /dash/scans/1/<account handle> session_id [REDACTADO]...
- Para comprobar que la captura no depende de la página de restablecimiento, en cualquier ruta autenticada se puede añadir un parámetro inventado con un marcador sin valor real. RUM también lo registra, sin cambiar de sesión:
> history.pushState({}, '', location.pathname + '?demo_param=' + MARK);
rum_tracked_the_spa_route_change true
RUM_CAPTURED_ARBITRARY_QUERY_VALUE true
session_id unchanged
- Desde el lado del programa, con acceso a Datadog, se confirma con esta consulta en RUM Explorer:
service:pullrequest @view.url:*password-reset*
El investigador comprobó también que el token es de un solo uso: enviar uno ya consumido a POST /api/v1/password-reset devuelve HTTP 422. Un token seguía siendo válido unos 15 minutos después de emitirse; es un mínimo medido, no la caducidad real, que no llegó a averiguar.
Impacto
Cualquiera que pudiera leer los datos de RUM de service:pullrequest en Datadog obtenía tokens de recuperación vigentes y, en la misma sesión, la cuenta a la que pertenecían. Eso incluye a quien tenga acceso de lectura a RUM en esa organización de Datadog, las claves de API o integraciones con permiso para leerlo, y cualquiera que llegara a conseguir esos accesos. Ese grupo no está controlado por el modelo de permisos de la aplicación. El token no necesita contraseña ni segundo factor para usarse.
El caso más realista es el restablecimiento abandonado: el usuario abre el enlace y no termina (le interrumpen, lo abre en otro dispositivo o recuerda la contraseña). El token queda válido en la telemetría de un tercero hasta que caduca, sin que el usuario lo sepa. Según el investigador, Datadog conserva por defecto los eventos de RUM 30 días.
Al faltar beforeSend por completo, cualquier secreto que acabe en una URL de la aplicación se registraría igual, en cualquier ruta.
Límites que el propio investigador señala: no afecta a credenciales de sesión, porque las peticiones autenticadas llevan el bearer token en una cabecera y no en la URL. Tampoco encontró códigos de autorización OAuth en URLs capturadas, ya que /oauth/callback responde con un 302 del servidor y no llega a ejecutarse script en esa página. Sin acceso al Datadog del programa, solo pudo demostrar la parte del cliente.
El investigador pidió severidad Media (CVSS 3.1 AV:N/AC:H/PR:H/UI:R/S:U/C:H/I:H/A:N, 5.9), pero el reporte consta como Baja, igual que el #4000185. Hubo recompensa, de importe no público.
Remediación
El reporte figura como resuelto, pero no detalla qué cambio se aplicó. Lo que propuso el investigador:
- Sacar el token de la URL: aceptarlo en el cuerpo del POST, o canjearlo en el servidor en la primera carga por una cookie de vida corta y redirigir a una ruta limpia. Así se cubren de una vez RUM, analítica,
Referer, historial del navegador y logs de CDN o proxy. - Mientras tanto, definir
beforeSenden el SDK de RUM para quitar la query string, eliminándola por defecto y dejando pasar solo los parámetros que se sepa que son seguros. - Borrar los eventos guardados que coincidan con
@view.url:*password-reset*y dar por expuesto cualquier token que siga vigente. - Valorar una caducidad más corta para los tokens de restablecimiento.
- Poner una
Referrer-Policyrestrictiva explícita en las páginas que llevan credenciales.
Qué aprender de este caso
- Si corriges una fuga de un secreto a un proveedor de telemetría, revisa todos los SDK que carga la página (Segment, Datadog, Sentry, Intercom…): cada uno envía por su cuenta y tiene su propio hook de limpieza, y arreglar uno no arregla los demás.
- Al auditar una aplicación con Datadog RUM, ejecuta
window.DD_RUM.getInitConfiguration()en la consola: si no haybeforeSendysampleRatees 100, cualquier parámetro sensible en la URL acaba en Datadog para todos los usuarios. - Busca tokens de un solo uso que viajen en la query string (restablecimiento, invitaciones, confirmación de email) y comprueba si
session_idu otro identificador del mismo proveedor los relaciona después con rutas que revelan la cuenta. - Para evitarlo de raíz, no metas secretos en la URL: canjéalos en el servidor por una cookie de vida corta y redirige a una ruta limpia antes de que se cargue ningún script de terceros.