Reports
MEDIA[EXPOSICIÓN DE DATOS]#3878586

Liberapay: el historial de pagos de un equipo revelaba donantes que habían pedido no aparecer en listas públicas

La exportación pública de pagos de los equipos de Liberapay solo miraba la visibilidad que tenía la donación al pagarse, así que mostraba nombre, importe y fecha de donantes que después se habían ocultado.

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

En Liberapay, quien dona a un equipo puede elegir que su donación sea pública (visibility = 3), y los equipos pueden publicar la lista de esos donantes. Además, cada donante dispone de dos ajustes de privacidad, hide_giving y hide_from_lists, que el resto de superficies públicas respetan, y puede cambiar más adelante una donación pública a privada o secreta.

El investigador its9me encontró que el historial de pagos de los equipos, servido por www/%username/income/payments.spt, no respetaba nada de eso. El endpoint se puede consultar sin iniciar sesión y decide si enseña la identidad del donante con una única condición: la visibilidad que tenía el pago cuando se hizo. El resultado es una exposición de datos: cualquiera podía obtener el usuario, el nombre público, el importe exacto y la fecha de donaciones cuyos autores habían pedido expresamente no figurar en listas públicas, o que después habían pasado a privadas o secretas.

Cómo debería comportarse

La lista de donantes del perfil del equipo (www/%username/index.html.spt) sirve de referencia. Para mostrar a cada donante exige todas estas condiciones:

  • index.html.spt:32: el equipo ha activado la lista, if patron_visibilities & 4:
  • index.html.spt:42: AND tip.visibility = 3
  • index.html.spt:45: AND NOT tipper_p.hide_giving
  • index.html.spt:46: AND tipper_p.hide_from_lists = 0

Además, lee la visibilidad actual desde current_tips.

Qué hacía el endpoint vulnerable

En www/%username/income/payments.spt:

  • Línea 33: participant = get_participant(state, restrict=False), es decir, sin autenticación.
  • Línea 34: la única restricción es if participant.kind != 'group' (solo equipos).
  • Líneas 51-53: selecciona payer.id, payer.username y payer.public_name.
  • Línea 62: el único filtro sobre la identidad del donante:
LEFT JOIN participants payer ON payer.id = pi.payer AND pt.visibility = 3

Faltan hide_giving, hide_from_lists y la comprobación patron_visibilities & 4 del equipo. Y hay un problema añadido: pt.visibility es payin_transfers.visibility, un valor que se fija una sola vez al pagar (liberapay/payin/common.py:752, abs(visibility)) y que no se actualiza nunca. Ninguna de las cuatro sentencias UPDATE payin_transfers toca la columna visibility, y cambiar la visibilidad de una donación con set_tip_to o desde la página de donaciones solo inserta una fila nueva en tips. La visibilidad de cada pago queda congelada para siempre.

De ahí salen dos fallos de privacidad:

  1. Se ignora la exclusión voluntaria. Un donante con hide_from_lists = 1 o con hide_giving sigue apareciendo aquí con nombre, importe exacto de cada pago y fecha, aunque el perfil del equipo sí lo excluya.
  2. Se ignora el cambio a privado o secreto. Es el caso más serio. Quien hizo una donación pública y luego la pasó a privada o secreta desaparece del perfil y de /<team>/patrons/public.csv, porque ambos leen current_tips.visibility, pero sus pagos públicos anteriores siguen expuestos en este endpoint.

Pasos de reproducción

Comprobar la superficie expuesta

El investigador lo confirmó en producción el 21 de julio de 2026 con una petición sin cookie ni sesión:

GET https://liberapay.com/GIMP/income/payments.json HTTP/2
Host: liberapay.com
(no Cookie, no session)

La respuesta es un HTTP 200 con un array JSON de 45 KB. Las entradas de donaciones públicas incluyen:

"payer_id": <int>, "payer_username": "<username>", "payer_public_name": "<real name>"

junto con amount, unit_amount, period, n_units, date y payment_method. En las entradas no públicas, payer_id, payer_username y payer_public_name valen null, lo que confirma que el único filtro es pt.visibility = 3. El investigador indica que la respuesta contenía identidades reales de donantes y que las ocultó en su evidencia.

La variante /<team>/income/payments.csv devuelve todo el historial en una sola petición: en CSV, per_page vale None por defecto y no hay límite de paginación.

Saltarse los ajustes de privacidad

  1. Registra una cuenta gratuita, A.
  2. Con A, haz una donación pública (visibility=3) a cualquier equipo y completa el pago. Sirve cualquier importe pequeño.
  3. Con A, haz una de estas dos cosas:
  • cambia la visibilidad de la donación a secreta en /<A>/giving/, o
  • activa «Hide me from public lists» (hide_from_lists) en los ajustes de privacidad.
  1. Comprueba que A ya no aparece en el perfil del equipo, https://liberapay.com/<team>/, ni en https://liberapay.com/<team>/patrons/public.csv.
  2. Sin iniciar sesión, pide https://liberapay.com/<team>/income/payments.json. La cuenta A sigue en la lista con su nombre de usuario, el importe exacto y la fecha.

Impacto

Cualquier persona, sin cuenta, podía conocer la identidad de donantes públicos de un equipo, cuánto habían donado exactamente y en qué fechas, incluso cuando esos donantes habían activado hide_giving o hide_from_lists, o habían convertido después su donación en privada o secreta. El donante había tomado una medida de privacidad deliberada y tenía motivos para creer que ya estaba oculto en todas partes. Como señala el reporte, las relaciones de donación y sus importes son el principal activo de privacidad de la plataforma.

Remediación

El reporte figura como resuelto y recibió recompensa, de importe no público. Se envió el 21 de julio de 2026 y se divulgó el 1 de agosto de 2026. El texto publicado no detalla el cambio que aplicó Liberapay; lo que consta es la propuesta del investigador, que consiste en igualar la consulta al modelo del perfil:

LEFT JOIN participants payer
       ON payer.id = pi.payer
      AND pt.visibility = 3
      AND payer.hide_giving IS NOT TRUE
      AND payer.hide_from_lists = 0

Además, proponía condicionar el endpoint a que el equipo tenga activado patron_visibilities & 4 y filtrar por la visibilidad actual de la donación, uniendo con current_tips, en lugar de usar el valor congelado de payin_transfers.visibility. Así un cambio de privacidad posterior se respeta aquí igual que en el resto del sitio.