El sitio llevaba más de dos años en producción. Una asociación profesional con área de socios, documentación interna, listados de colegiados y un directorio restringido por roles. Todo funcionaba bien de cara al panel: los usuarios entraban, veían su zona privada, descargaban documentos. Nadie había reportado ningún fallo, ningún acceso indebido, ningún comportamiento raro. Hasta que alguien, desde fuera, envió un correo con una captura que no debería haber podido hacer.
La captura mostraba nombres completos, correos electrónicos y roles de usuario. No venía de un panel. No venía de una página del sitio. Venía de una ruta directa a /wp-json/, ejecutada desde un navegador corriente, sin credenciales. Esa información pertenecía a la API REST del sitio, y estaba disponible para cualquiera que supiera dónde mirar. El sitio no estaba caído. No mostraba errores. Simplemente estaba respondiendo a quien preguntaba, sin verificar quién era.
Lo primero que pensé fue que algún plugin del área privada tenía una brecha. Pero la ruta de la captura era nativa de WordPress. Esto no encajaba con un fallo de plugin.

Operario: WordPin
Archivo del caso: La API REST que exponía datos sensibles
Empresa o negocio: Asociación profesional con área privada
Archivado en: Seguridad en WordPress
Nivel: Avanzado
Lo que la API REST mostraba sin pedir permiso
Lo primero fue reproducir lo que mostraba esa captura. Abrí Postman y lancé una petición GET al endpoint /wp-json/wp/v2/users, sin cabeceras de autenticación. En esa instalación, la respuesta llegó limpia: un JSON con los datos públicos de los usuarios que el endpoint devolvía. Nombres, slugs, enlaces a sus archivos de autor, avatares.
GET /wp-json/wp/v2/users
Status: 200 OK
[
{
"id": 4,
"name": "Laura Méndez Carrasco",
"slug": "laura-mendez",
"link": "https://ejemplo.org/author/laura-mendez/",
"avatar_urls": { ... }
},
{
"id": 7,
"name": "Javier Ruiz Solana",
"slug": "javier-ruiz",
"link": "https://ejemplo.org/author/javier-ruiz/",
...
}
]Hasta ahí, el sitio respondía según su configuración activa. Pero tenía un plugin de directorio de colegiados que ampliaba los perfiles de usuario con campos personalizados: número de colegiado, teléfono profesional y correo interno. La pregunta era si esos campos también viajaban por los endpoints.
La primera pista iba por ahí, pero antes de confirmar nada, había que descartar otros vectores.
Primer sospechoso: el XML-RPC abierto
El archivo xmlrpc.php estaba accesible. Un POST con el método wp.getUsersBlogs devolvía respuesta. En un sitio con área privada, eso podía parecer una vía de filtración. Lancé unas cuantas llamadas con cURL para verificar qué se podía obtener por XML-RPC sin credenciales válidas.
curl -X POST https://ejemplo.org/xmlrpc.php \
-H "Content-Type: text/xml" \
-d '<methodCall>
<methodName>wp.getUsersBlogs</methodName>
<params>
<param><value>test</value></param>
<param><value>test</value></param>
</params>
</methodCall>'
# Respuesta: faultCode 403 - Incorrect username or password.El XML-RPC rechazaba las credenciales falsas y no devolvía datos de usuario. Podía ser un problema de superficie de ataque, pero no era el origen de la filtración que mostraba la captura. Los datos que aparecían en la captura venían en formato JSON, no XML. Descarte limpio.
Segundo sospechoso: el plugin de directorio
El sitio usaba un plugin de directorio de miembros que generaba fichas de colegiado con acceso restringido por rol. Desde fuera, las páginas del directorio pedían login. Eso funcionaba. Pero algunos plugins de este tipo registran sus propios endpoints REST para servir datos al frontend con JavaScript. Si el directorio tenía un endpoint propio sin control de permisos, podía ser el canal de fuga.
Revisé las rutas registradas accediendo a /wp-json/ directamente desde el navegador. El índice mostraba todos los namespaces activos. El plugin de directorio no registraba ningún namespace propio. Sus datos se servían únicamente a través de shortcodes renderizados en servidor, protegidos por la lógica de roles de la plantilla. Demasiado evidente para ser el culpable.
Así que el directorio no exponía nada por la API REST. Sus páginas estaban bien cerradas. Pero entonces la filtración venía de más abajo.
La grieta estaba en los cimientos, no en el tejado
El alcance real de la API REST sin restricciones
Volví al endpoint /wp-json/wp/v2/users. La respuesta incluía campos que el sitio consideraba públicos: nombre, slug, enlace al archivo de autor. Pero al inspeccionar la respuesta completa, aparecían campos adicionales que no deberían estar ahí. El plugin de directorio no registraba endpoints propios, pero sí usaba register_meta() para asociar campos personalizados al objeto user. En esa implementación concreta, el plugin registraba esos campos con 'show_in_rest' => true y los metadatos quedaban expuestos a través del endpoint nativo de usuarios sin el control de acceso que el sitio necesitaba.
register_meta('user', 'telefono_profesional', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
]);
register_meta('user', 'numero_colegiado', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
]);En ese contexto particular, la configuración permitía que esos datos viajaran en abierto. No era un bug: era una configuración incompleta para el tipo de sitio que alojaba. En un blog personal, mostrar el nombre del autor es normal. En una asociación profesional con datos de contacto internos, omitir ese control es una brecha.
Pero había más. Además de los campos de usuario, el endpoint /wp-json/wp/v2/posts devolvía entradas de tipo post que incluían contenido de actas internas publicadas como entradas privadas de categorías restringidas. Esas entradas no aparecían como privadas en la API porque su post_status era publish. La restricción de acceso se hacía únicamente a nivel de plantilla PHP, con un condicional que comprobaba el rol antes de mostrar el contenido. Pero la API REST no pasaba por esa plantilla. Servía el contenido tal cual lo encontraba en la base de datos.
Un contenido puede parecer protegido en el navegador y estar completamente abierto por otra puerta. La plantilla no es un muro: es una cortina.
El impacto era doble. Por un lado, datos personales de miembros accesibles sin autenticación, con implicaciones claras en protección de datos. Por otro, esas rutas JSON podían ser rastreadas o consultadas por terceros si permanecían accesibles públicamente. No era un fallo ruidoso. Era una fuga silenciosa que llevaba meses activa.
Cerrar las puertas que nadie sabía que estaban abiertas
La primera idea fue desactivar la API REST por completo para usuarios no autenticados. Hay filtros que permiten devolver un error en cada petición si el visitante no tiene sesión activa. Es rápido, es contundente y a primera vista parece seguro. Pero este sitio usaba la API internamente: un bloque de Gutenberg personalizado cargaba contenido dinámico desde el endpoint de posts, y el formulario de contacto enviaba datos a través de una ruta REST propia del plugin de formularios. Cortar todo de golpe habría roto funcionalidades visibles.
Qué cerrar primero sin romper lo que funcionaba
El camino era más quirúrgico. Había que intervenir directamente sobre la respuesta de los endpoints que exponían datos sensibles, sin tocar los que el sitio necesitaba para funcionar con normalidad. Para el endpoint de usuarios, la solución más precisa en este caso fue actuar sobre el filtro rest_prepare_user, que permite modificar la respuesta antes de que llegue al cliente. Así se podía eliminar selectivamente los campos problemáticos según el nivel de permisos del solicitante:
add_filter('rest_prepare_user', function ($response, $user, $request) {
if (!current_user_can('list_users')) {
$response->data['meta'] = [];
$response->data['name'] = '';
$response->data['slug'] = '';
$response->data['link'] = '';
}
return $response;
}, 10, 3);Eso eliminaba los datos sensibles de la respuesta con esos campos vaciados para los visitantes sin permisos. Los usuarios autenticados con el capability list_users seguían viendo todo desde el panel. Los visitantes anónimos recibían una respuesta con esos campos vaciados, sin datos útiles que procesar.
Para las entradas internas, la solución fue distinta. Esas actas no deberían haber sido publish en primer lugar. Pero cambiar su estado a private de golpe podía generar problemas con referencias internas y enlaces en el área de socios. Lo que hice fue registrar un custom post type específico para documentación interna, con 'show_in_rest' => false y acceso controlado por capabilities propias. Migré las actas al nuevo tipo, verifiqué que la plantilla del área privada las servía correctamente por rol, y eliminé las entradas originales.
register_post_type('acta_interna', [
'public' => false,
'show_ui' => true,
'show_in_rest' => false,
'capability_type' => 'acta',
'map_meta_cap' => true,
'supports' => ['title', 'editor'],
]);El riesgo de hacerlo mal era dejar contenido huérfano o romper las referencias del área de socios. Por eso la migración se hizo primero en un entorno de staging, verificando que los shortcodes y condicionales de plantilla seguían funcionando con el nuevo post type. Solo cuando el staging respondía igual que producción —menos la exposición por la API— se ejecutó el cambio en el sitio real.
La validación final fue una batería de peticiones con cURL contra todos los endpoints activos, sin cookies de sesión. Los campos de usuario devolvían respuestas con esos datos vaciados. El post type de actas no aparecía en ninguna ruta REST. Los endpoints del formulario de contacto y los bloques de Gutenberg seguían operativos. Que un sistema responda no significa que deba responder a todo el mundo con la misma información.
Después revisé los meta registrados por otros plugins menores. Dos de ellos usaban 'show_in_rest' => true en campos que no necesitaban exposición pública. Los corregí con el mismo patrón de filtrado sobre rest_prepare_user. En cuanto al índice de /wp-json/, se dejó accesible: bloquearlo habría requerido evaluar el impacto sobre cada implementación activa del sitio, y en este caso los datos sensibles ya estaban controlados en origen. Mantenerlo visible no añadía riesgo real una vez cerradas las rutas problemáticas.

💡 Qué he aprendido
Este caso me confirmó algo que ya intuía pero que no había visto con tanta claridad: la API REST de WordPress no es insegura por diseño, pero su comportamiento depende de cómo esté configurado cada sitio. En un blog público, exponer nombres de autor y contenido publicado es coherente. En un sitio con datos profesionales restringidos, esa misma configuración puede convertirse en una brecha sin que nadie reciba un solo error.
Aprendí a no confiar en la capa de presentación como barrera de acceso. Que una plantilla oculte contenido no significa que ese contenido sea inaccesible. Si el dato existe con estado público y hay un endpoint que lo sirve, alguien lo va a encontrar. La seguridad real se define en el registro del dato y en los permisos configurados para cada campo y ruta, no en lo que el tema decide mostrar o esconder.
Y aprendí que en una implementación como esta, cada campo registrado para exponerse por REST debe revisarse, porque puede convertirse en una vía de exposición de datos que nadie detecta hasta que alguien envía una captura desde fuera.
Cuaderno de WordPin
Forense WordPress
🧩 Notas técnicas
⬋⬋
El sitio de una asociación profesional con área privada llevaba meses sirviendo datos personales de miembros y contenido interno a través de endpoints REST abiertos. Nadie lo detectó hasta que un tercero accedió a la información desde un navegador sin autenticación.
La causa estaba en campos de usuario registrados con exposición REST activa que, en esa implementación concreta, quedaban accesibles sin el control necesario, y en entradas internas con estado público protegidas solo por la plantilla. Se intervino sobre la respuesta de los endpoints problemáticos, se migraron contenidos a un post type privado y se validaron los permisos de cada ruta activa.
🔎 Pistas detectadas
⬋⬋
- Captura externa con nombres, correos y roles obtenidos desde /wp-json/wp/v2/users sin autenticación en esa instalación concreta.
- Campos personalizados de colegiados visibles en la respuesta JSON del endpoint nativo de usuarios.
- Entradas de actas internas con
post_status: publishaccesibles desde /wp-json/wp/v2/posts. - Metadatos de usuario registrados con
show_in_rest => truesin el control de acceso que el sitio requería en esa implementación. - Restricción de contenido implementada solo en la capa de plantilla PHP, sin equivalente en la capa REST.
🚫 Errores en WordPress detectados
⬋⬋
- Campos de usuario con exposición REST activa sin el control de acceso adecuado para el contexto del sitio: exponía teléfonos y números de colegiado a cualquier visitante.
- Contenido interno publicado como publish en lugar de private: la API lo servía sin restricción porque el estado lo permitía.
- Protección de acceso solo a nivel de plantilla: creaba una falsa sensación de seguridad que no cubría las rutas REST.
- Plugins secundarios registrando metadatos con exposición REST innecesaria y sin el control de permisos que el sitio requería.
- Ausencia de filtros sobre
rest_prepare_user: ningún control intermedio validaba qué datos viajaban en las respuestas de usuario.
📘 Glosario de WordPress
⬋⬋
- REST API: interfaz que permite acceder a datos de WordPress mediante peticiones HTTP, devolviendo respuestas en formato JSON.
- Endpoint: ruta específica dentro de la API que responde a un tipo de recurso concreto, como usuarios, entradas o taxonomías.
- show_in_rest: parámetro que determina si un tipo de contenido, campo o post type es accesible a través de la API REST; su comportamiento depende del contexto completo del registro y de los permisos configurados.
- Capability: permiso individual asignado a un rol de usuario que define qué acciones puede realizar dentro de WordPress.
- rest_prepare_user: filtro de WordPress que permite modificar la respuesta del endpoint de usuarios antes de que se envíe al cliente.
📏 Mini guía práctica
⬋⬋
- Audita los endpoints activos accediendo a /wp-json/ sin sesión y revisando cada namespace registrado.
- Comprueba qué campos personalizados tienen
show_in_rest => truey evalúa si el control de acceso configurado es adecuado para el contexto del sitio. - Filtra la respuesta de los endpoints sensibles con
rest_prepare_userorest_prepare_postpara eliminar datos según capabilities del solicitante. - Migra contenido interno a custom post types con
show_in_rest => falsey permisos propios si su acceso debe ser restringido. - Verifica en staging cualquier cambio sobre permisos REST antes de aplicar en producción, lanzando peticiones sin autenticación contra todos los endpoints activos.
Deja una respuesta
SEO desde cero y el cliente que lo desconocía · Serpion 000
El rich snippet que desapareció de la SERP · Serpion 006
Expedientes y Archivos