Reports
CRÍTICA[APIS]#3801107

bbPress 2.7.0: la API REST de WordPress dejaba leer sin login los temas y respuestas de foros privados y ocultos

En la rama de desarrollo 2.7.0 de bbPress, el endpoint REST de WordPress para un único tema o respuesta no aplicaba la visibilidad del foro: cualquiera, sin sesión, podía leer el contenido de foros privados y ocultos recorriendo IDs.

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

bbPress, el plugin de foros de WordPress, permite crear foros privados y ocultos cuyos temas y respuestas solo pueden ver los miembros autorizados. El fallo estaba en cómo se aplicaba esa restricción en la versión de desarrollo 2.7.0 (trunk).

La clave está en dónde guarda bbPress la marca de "restringido". Solo el foro lleva un estado especial (private o el estado propio hidden); los temas y respuestas que contiene se guardan siempre con post_status = publish. Para que no aparezcan donde no deben, bbPress usa un filtro sobre las consultas, bbp_pre_get_posts_normalize_forum_visibility() (src/includes/forums/functions.php:2329), enganchado a pre_get_posts, que excluye los foros restringidos de cualquier WP_Query.

En 2.7.0, al añadir soporte para el editor de bloques (Gutenberg), los tipos de contenido foro, tema y respuesta pasaron a registrarse con show_in_rest => true (src/bbpress.php:548, :578 y :608). Con eso WordPress crea automáticamente las rutas estándar de su API REST:

/wp-json/wp/v2/forum
/wp-json/wp/v2/topic
/wp-json/wp/v2/reply

El listado (GET /wp-json/wp/v2/topic) sí estaba protegido, porque internamente lanza un WP_Query y el filtro de bbPress actúa. Pero la ruta de un elemento concreto no: WP_REST_Posts_Controller::get_item() carga la entrada con get_post( $id ), una búsqueda directa por clave primaria que no pasa por WP_Query ni dispara pre_get_posts. Después, check_read_permission() da por buena la lectura de cualquier entrada en estado publish, sin mirar capacidades ni el foro padre. Como bbPress no registraba ningún control propio en REST (ni rest_{post_type}_item_permissions_check, ni permission_callback, ni ajuste de rest_{post_type}_query), la petición anónima recibía el contenido completo.

La rama publicada 2.6.x no activa show_in_rest, así que no estaba afectada. El programa clasificó el reporte como crítico, lo resolvió y pagó recompensa (el importe no es público). Se envió el 14-jun-2026 y se divulgó el 12-oct-2026.

Pasos de reproducción

  1. Instalar WordPress con bbPress 2.7.0 (trunk) activo y enlaces permanentes "bonitos" activados.
  2. Crear un foro oculto (o uno privado) y publicar dentro un tema y una respuesta. En la base de datos ambos quedan con post_status = publish; solo el foro lleva el estado restringido.
  3. Sin cookie ni cabecera de autenticación, pedir el tema y la respuesta por ID. La respuesta es HTTP 200 y el campo content.rendered del JSON trae el texto íntegro:
GET /wp-json/wp/v2/topic/<topic_id>
GET /wp-json/wp/v2/reply/<reply_id>
  1. Comprobar que ese mismo cliente anónimo sí está bloqueado por cualquier otro camino:
  • GET /wp-json/wp/v2/topic devuelve una lista sin el tema restringido.
  • El enlace público del tema en la web devuelve HTTP 404.
  • GET /wp-json/wp/v2/forum/<forum_id> devuelve HTTP 401.
  1. Recorrer <topic_id> y <reply_id>, que son enteros consecutivos, para descargar todo el contenido de los foros privados y ocultos.

En la prueba del investigador, con un foro oculto y uno privado creados con la propia API de bbPress, las tres peticiones anónimas devolvieron HTTP 200 con el contenido de prueba:

GET /wp-json/wp/v2/topic/5   -> 200  "SECRET-TOPIC: acquiring Acme Corp for $4.2B, board vote Friday."
GET /wp-json/wp/v2/reply/6   -> 200  "SECRET-REPLY: wire the deposit to account 12345 before close."
GET /wp-json/wp/v2/topic/9   -> 200  "SECRET-PRIVATE: payouts hit wallets Monday 09:00 UTC."

En cambio, el listado GET /wp-json/wp/v2/topic devolvió un array vacío, el enlace público dio 404 y GET /wp-json/wp/v2/forum/4 (el foro oculto) devolvió HTTP 401 rest_forbidden. Al recorrer de topic/1 a topic/10, solo los dos temas restringidos devolvieron contenido; el resto dio 404. El reporte incluía además un reproduce.sh y un docker-compose.yml para montar el entorno y lanzar las peticiones.

Impacto

Un atacante sin cuenta, sin sesión y sin interacción de nadie podía leer el texto completo (no un extracto) de cualquier tema o respuesta publicado en foros privados u ocultos. Como los IDs son consecutivos, no necesitaba conocerlos de antemano: bastaba con recorrerlos para volcar todo el contenido reservado a miembros.

El fallo solo existía en la rama de desarrollo 2.7.0 y se reportó antes de que esa versión se publicara, para evitar que la regresión llegara a las instalaciones de bbPress.

Remediación

El reporte figura como resuelto, pero no detalla qué cambio se aplicó. El investigador propuso tres alternativas, cualquiera de ellas suficiente:

  • Añadir para foros, temas y respuestas un permission_callback o un filtro rest_{post_type}_item_permissions_check que llame a bbp_current_user_can_see() (o compruebe la visibilidad del foro antecesor) y deniegue la lectura si el usuario no puede ver el foro.
  • Exponer el contenido en REST solo a usuarios con capacidad de moderar o editar ese tipo de contenido, lo justo para que funcione el editor de bloques.
  • Registrar un rest_controller_class propio que pase tanto los listados como las lecturas individuales por la lógica de visibilidad existente.

También recomendó que la corrección cubriera listado y elemento individual de los tres tipos, y comprobarla contra los mismos controles: 404 en la web, exclusión en el listado y 401 en el foro.

Qué aprender de este caso

  • Si un permiso se aplica solo con un filtro de consultas (pre_get_posts en WordPress), cualquier ruta que cargue por ID con get_post() se lo salta. Al auditar un plugin, busca los endpoints de "un solo elemento" y compáralos con el listado: que el listado oculte algo no significa que el detalle lo proteja.
  • Cuando la restricción vive en el contenedor (el foro) y no en el elemento (tema o respuesta, en publish), cualquier código que solo mire el estado del elemento lo dará por público. Revisa siempre qué marca exactamente como privado cada objeto.
  • Activar show_in_rest en un tipo de contenido personalizado expone rutas que usan los permisos por defecto de WordPress. Antes de activarlo, decide quién debe leer por REST y añade el control de permisos en ese mismo cambio.
  • Para probar este tipo de fallo, usa como referencia las rutas que sí deniegan (404 en la web, 401 en el foro) y pide el mismo recurso por todas las vías disponibles, recorriendo IDs consecutivos.