
Un plugin de WordPress que expone un servidor MCP sobre la API REST, con el modelo de seguridad como punto central -- cierra la forma de bypass de OAuth de CVE-2026-15015 al no tener nunca esa superficie en absoluto.
En términos sencillos: esto permite que un agente de IA lea y escriba ligeramente en un sitio de WordPress a través de una Application Password real de WordPress — el mismo tipo de credencial que wp-admin ya emite — y nada más. Aquí no hay formulario de registro, ni registro de cliente OAuth, ni endpoint de emisión de tokens de ningún tipo. Un plugin de este mismo ámbito publicó exactamente eso, y así fue como un atacante no autenticado se hizo con acceso completo de administrador.
Un plugin de WordPress que expone un servidor MCP sobre la REST API, con el modelo de seguridad como punto central, no como una viñeta de características.
CVE-2026-15015, CVSS 9.8: el MountDev AI MCP Connector para WordPress exponía un endpoint de Registro Dinámico de Cliente OAuth 2.1 de acceso público junto con un endpoint de autorización que tampoco comprobaba quién lo solicitaba. Un atacante registra su propio cliente OAuth — sin credenciales, el endpoint no está autenticado por diseño, eso es lo que significa "registro dinámico de cliente" — lo guía por el endpoint de autorización sin supervisión, y recibe un bearer token vinculado a una cuenta de administrador. Control total del sitio: contenido, usuarios, ajustes. Nada estaba mal configurado; el flujo funcionó exactamente como se supone que debe funcionar el registro de cliente OAuth. Simplemente nunca debió ser accesible sin que un administrador ya estuviera presente para aprobarlo.
La corrección que generaliza no es "implementar OAuth con más cuidado". Es no
tener esa superficie. Este plugin no registra ningún endpoint de registro
de cliente, ningún endpoint de autorización, ni ningún endpoint de emisión de
tokens de ningún tipo. No hay nada contra lo que un atacante pueda
registrarse, porque aquí no hay nada que entregue credenciales. La
autenticación es una Application Password de WordPress que un administrador
ya creó para un usuario específico desde wp-admin — una credencial que existe
solo porque un humano con manage_options eligió crearla, de antemano, al
margen de este plugin por completo.
Ninguna por sí sola es nueva; ejecutar ambas, a propósito, independientes entre sí, es lo que cierra un error en cualquiera de las dos:
1. Solo Application Password — nunca una sesión de cookie. El propio
is_user_logged_in() de WordPress también es verdadero para una pestaña del
navegador que tiene una cookie y un nonce válidos.
Auth::permission_callback() rechaza esa vía
deliberadamente: escucha la acción application_password_did_authenticate
que el núcleo de WordPress dispara específicamente cuando la autenticación
Basic-auth con Application Password tiene éxito, y exige ese indicador, no
solo "algún usuario ha iniciado sesión". Un cliente MCP no es una pestaña del
navegador con un nonce; aceptar autenticación por cookie aquí abriría una vía
con forma de CSRF hacia una superficie de herramientas a la que se le puede
pedir, en inglés, que modifique contenido, sin ningún beneficio sobre la
única puerta que este plugin realmente necesita.
2. Capacidad por herramienta, más una compuerta de escritura que la
capacidad por sí sola no puede abrir. ToolRegistry::call()
comprueba user_can($user_id, $capability) de nuevo en cada llamada —
edit_posts para entradas, manage_woocommerce para pedidos. Por separado,
la única herramienta de escritura, create_draft_post, requiere además que
un administrador haya optado por habilitarla desde Ajustes → WP Secure
MCP, desactivada por defecto. Una Application Password que casualmente
tenga edit_posts sigue sin poder escribir nada hasta que un humano haya
activado ese interruptor — una comprobación de capacidad y una compuerta a
nivel de sitio son dos cerraduras distintas, y las pruebas demuestran que
ninguna sustituye a la otra en ninguna
dirección.
limit, 1–20) acotan cuánto puede devolver una sola llamada; una llamada
legítimamente costosa a WP_Query o wc_get_orders sigue costando lo que
cuesta.La entrada tools/list de cada herramienta lleva anotaciones
readOnlyHint / destructiveHint, para que un cliente pueda decidir si
preguntar a un humano antes de llamar a una sin necesidad de saber ya qué
hace.
composer install --no-dev
Copia (o enlaza simbólicamente) el directorio del plugin en
wp-content/plugins/, actívalo desde wp-admin, y luego crea una Application
Password para la cuenta como la que debe actuar un agente: Usuarios → tu
perfil → Application Passwords.
Las escrituras están desactivadas por defecto. Para permitir
create_draft_post: Ajustes → WP Secure MCP → Herramientas de escritura.
La comprobación de capacidad por herramienta sigue aplicándose por encima de
esto — activar el interruptor a nivel de sitio no otorga a nadie una
capacidad que no tuviera ya.
55 pruebas, todas puras — sin instalación de WordPress, sin base de datos,
sin Docker. tests/bootstrap.php simula las
funciones de WordPress que llama src/ (current_user_can, get_post,
wp_insert_post, ...) de la misma forma en que convencionalmente se hacen
pruebas unitarias a un plugin de WordPress sin cargar WordPress en sí.
composer install
vendor/bin/phpunit --testdox
La cobertura más intensa está donde más importa:
ProtocolTest — 17 pruebas contra un registro
falso, nada específico de WordPress en absoluto. Validación del sobre
JSON-RPC, todos los códigos de error, y la regla de notificación de
JSON-RPC 2.0 de que una solicitud sin campo id no recibe respuesta, para
cualquier método, incluso uno malformado — no hay adónde ir con el error.ToolRegistryTest — demuestra que la
comprobación de capacidad y la compuerta de escritura son independientes en
ambas direcciones: una capacidad ausente bloquea una llamada incluso cuando
las escrituras están habilitadas, y una compuerta de escritura deshabilitada
bloquea una llamada incluso cuando la capacidad está presente.AuthTest — la prueba que más importa aquí es
test_logged_in_user_without_application_password_authentication_is_refused:
un usuario de WordPress real y resuelto sigue siendo rechazado, porque
nunca se pidió una sesión de cookie.CI ejecuta PHPUnit en PHP 8.1–8.3 y PHP_CodeSniffer contra el conjunto de reglas de WordPress Coding Standards en cada push.
En lo que CI todavía no se ejecuta en cada push: una prueba de
integración con WordPress + WooCommerce en vivo mediante
@wordpress/env — autenticarse con una Application
Password real contra una REST API real y una base de datos real, el
equivalente en instancia viva del trabajo de CI con Postgres real de
pg-readonly-mcp. Está escrito y
razonado, conectado como un trabajo workflow_dispatch en
ci.yml en lugar de una compuerta obligatoria,
porque el propio entorno de desarrollo de este repositorio sufrió un fallo
local de Docker Desktop que hizo imposible ensayar wp-env antes de la
primera ejecución real de este workflow. Se dice aquí en lugar de dejarlo
para que lo descubra otra persona — la misma regla que pg-readonly-mcp aplica
a su propia brecha no probada de diferencial de parser.
wp-secure-mcp.php plugin bootstrap: loads the autoloader, wires one REST route
src/
Protocol.php JSON-RPC 2.0 dispatch. Zero WordPress calls. Pure.
ToolRegistryInterface.php the seam Protocol talks to instead of WordPress directly
ToolRegistry.php the two locks: per-tool capability, server-wide write gate
Auth.php Application-Password-only permission_callback
Settings.php the write-gate admin toggle
RestController.php thin bootstrap: one route, delegates immediately
Tools/ the four v1 tools
tests/
bootstrap.php WordPress function stubs
ProtocolTest.php, ToolRegistryTest.php, AuthTest.php, ...
bin/smoke-test.sh live wp-env integration script (see Tests, above)
Si alguna vez se acepta o rechaza una solicitud por una razón que vive en
wp-secure-mcp.php o RestController.php en lugar de Auth,
ToolRegistry o Protocol, eso es un bug — el juicio pertenece una capa más
abajo, donde puede probarse con un array simple y nada más.
MIT.
| Herramienta | Capacidad | Solo lectura | Notas |
|---|
search_posts | edit_posts | Sí | Búsqueda por palabra clave. Fijada a post_status=publish — nunca devuelve borradores ni entradas privadas, ni siquiera a un llamante que podría verlas en wp-admin. |
get_post | edit_posts | Sí | Obtener por ID. Rechaza una entrada real en un ID real si su estado no es publish — un ID no es un bypass de la misma regla que aplica search_posts. |
list_recent_orders | manage_woocommerce | Sí | Solo estado, total, moneda y fecha. Nunca nombre, correo electrónico ni dirección del cliente — la visibilidad de pedidos no requiere entregarle a un agente los datos personales de un cliente, haya comprobación de capacidad o no. |
create_draft_post | edit_posts | No | post_status es un literal 'draft' en el código fuente, no un argumento. Esta herramienta no puede publicar con ninguna entrada, ni siquiera con las escrituras habilitadas. Desactivada a nivel de sitio hasta que un administrador opte por habilitarla. |