Reports
CRÍTICA[SSRF]#3165242

SSRF en Lichess: el parámetro players de la exportación de partidas hacía que el servidor pidiera cualquier URL

Los endpoints públicos de exportación de partidas de Lichess pasaban el parámetro players, sin validar, al cliente HTTP del servidor. Cualquiera, sin autenticarse, podía hacer que Lichess lanzase peticiones GET a la URL que quisiera.

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

Lichess permite exportar partidas en PGN y, como opción, acepta un parámetro players con la dirección de un fichero de "jugadores reales" que el servidor descarga para enriquecer la exportación. El problema es que esa dirección se tomaba tal cual de la query string y se entregaba al cliente HTTP del servidor sin comprobar a dónde apuntaba. El resultado es una falsificación de peticiones del lado del servidor (SSRF, CWE-918) en endpoints públicos que no exigen sesión.

El investigador lo encontró analizando el código fuente y después lo confirmó probándolo en la web de producción de Lichess. Según indica, por motivos de seguridad y éticos no lanzó peticiones reales contra direcciones internas.

El recorrido del dato es corto. En app/controllers/Game.scala el valor del parámetro va directo a la configuración de la exportación:

val config = GameApiV2.OneConfig(
  format = GameApiV2.Format.byRequest,
  imported = getBool("imported"),
  flags = requestPgnFlags(extended = true),
  playerFile = get("players")  // User input taken directly
)

En modules/api/src/main/GameApiV2.scala, si playerFile tiene valor, se llama a realPlayerApi.apply con él:

realPlayers                  <- config.playerFile.so(realPlayerApi.apply)

Y en modules/web/src/main/RealPlayer.scala esa función acaba descargando la URL:

def apply(url: String): Fu[Option[RealPlayers]] = cache.get(url)
// This leads to:
ws.url(url).withRequestTimeout(3.seconds).get()

En ningún punto de la cadena se filtra el esquema, el dominio ni la IP de destino.

Pasos de reproducción

  1. Elegir el ID de cualquier partida existente en Lichess (por ejemplo, una reciente).
  2. Pedir la exportación de esa partida pasando una URL en players. El ejemplo del reporte apunta al servicio de metadatos de la nube:
/game/export/[GAME_ID]?players=http://169.254.169.254/latest/meta-data/
  1. El servidor hace una petición HTTP a la URL indicada.
  2. El mismo parámetro funciona en otros dos endpoints de exportación:
/api/games/export/_ids?players=[URL]
/api/games/user/[USERNAME]?players=[URL]
  1. Para confirmarlo sin tocar direcciones internas, basta con poner en players una URL propia (los logs de un servidor propio o un servicio como webhook.site) y comprobar que llega la petición.

Impacto

Cualquier visitante, sin cuenta, podía usar el servidor de Lichess como intermediario para lanzar peticiones GET a destinos elegidos por él. Según el investigador, eso abriría la puerta a:

  • Consultar servicios de metadatos de la nube (AWS, GCP) para intentar obtener credenciales y configuración.
  • Escanear la red interna y sus puertos para descubrir servicios no expuestos.
  • Llegar a APIs y paneles de administración internos y, potencialmente, a datos o ficheros de configuración sensibles.
  • Saltarse restricciones basadas en IP de servicios internos, ya que las peticiones salen de los servidores de Lichess.

Estos escenarios son los que el reporte plantea como posibles; lo que el investigador afirma haber comprobado en producción es que el servidor llegaba a hacer la petición a la URL indicada. En HackerOne figura con severidad crítica y como resuelto; se envió el 28 de mayo de 2025 y se divulgó el 3 de junio de 2025.

Remediación

El reporte no detalla qué cambio aplicó Lichess, solo que quedó resuelto. Las medidas que proponía el investigador, centradas en RealPlayerApi.apply(), eran:

  • Validar la URL antes de descargarla y permitir solo dominios o patrones de confianza (lista blanca).
  • Bloquear rangos privados y de enlace local (127.x.x.x, 10.x.x.x, 192.168.x.x, 169.254.x.x).
  • Exigir autenticación en estos endpoints o, si la función no es imprescindible, eliminarla.

Qué aprender de este caso

  • Busca parámetros "auxiliares" que el servidor descarga para enriquecer una respuesta (aquí players en una exportación PGN): suelen pasar desapercibidos porque no son la función principal del endpoint.
  • Si un endpoint necesita un fichero remoto, mejor que el cliente elija entre identificadores conocidos que el servidor traduce a URLs fijas, en lugar de aceptar una URL libre.
  • Cuando la URL sea inevitable, valida la IP ya resuelta (no solo el texto) contra rangos privados y 169.254.0.0/16, y desactiva las redirecciones en el cliente HTTP para que no puedan saltarse el filtro.
  • La caché delante de la descarga (cache.get(url)) no protege: cada URL nueva es una petición nueva. Que un endpoint público pueda disparar descargas a voluntad merece también un límite de frecuencia.