
Un plugin WordPress exposant un serveur MCP via l'API REST, avec le modèle de sécurité comme point central -- comble la faille de contournement OAuth de type CVE-2026-15015 en n'ayant jamais cette surface du tout.
En termes simples : cela permet à un agent IA de lire et d'écrire légèrement sur un site WordPress via un véritable mot de passe d'application WordPress — le même type d'identifiant que wp-admin délivre déjà — et rien d'autre. Il n'y a pas de formulaire d'inscription, pas d'enregistrement de client OAuth, pas de point de terminaison de jeton d'aucune sorte ici. Un plugin dans ce même domaine a livré exactement cela, et c'est ainsi qu'un attaquant non authentifié est reparti avec un accès administrateur complet.
Un plugin WordPress exposant un serveur MCP via l'API REST, avec le modèle de sécurité comme point central, et non comme argument de vente.
CVE-2026-15015, CVSS 9.8 : le MountDev AI MCP Connector pour WordPress exposait un point de terminaison d'enregistrement dynamique de client OAuth 2.1 accessible publiquement, aux côtés d'un point de terminaison d'autorisation qui ne vérifiait pas non plus qui faisait la demande. Un attaquant enregistre son propre client OAuth — aucun identifiant requis, le point de terminaison est non authentifié par conception, c'est ce que signifie « enregistrement dynamique de client » — le fait passer par le point de terminaison d'autorisation sans surveillance, et reçoit un jeton bearer lié à un compte administrateur. Contrôle total du site : contenu, utilisateurs, réglages. Rien n'était mal configuré ; le flux fonctionnait exactement comme l'enregistrement de client OAuth est censé fonctionner. Il n'aurait simplement jamais dû être accessible sans qu'un administrateur soit déjà présent pour l'approuver.
Le correctif qui se généralise n'est pas « implémenter OAuth plus
soigneusement ». C'est de ne pas avoir cette surface. Ce plugin
n'enregistre aucun point de terminaison d'enregistrement de client, aucun
point de terminaison d'autorisation, et aucun point de terminaison
d'émission de jeton d'aucune sorte. Il n'y a rien contre quoi un attaquant
puisse s'enregistrer, car il n'y a ici rien qui distribue des identifiants.
L'authentification est un mot de passe d'application WordPress qu'un
administrateur a déjà créé pour un utilisateur spécifique depuis wp-admin —
un identifiant qui n'existe que parce qu'un humain disposant de
manage_options a choisi de le créer, à l'avance, hors bande de ce plugin
entièrement.
Aucune des deux n'est nouvelle à elle seule ; exécuter les deux, à dessein, indépendamment l'une de l'autre, c'est ce qui ferme une erreur dans l'une ou l'autre :
1. Mot de passe d'application uniquement — jamais une session par
cookie. Le is_user_logged_in() de WordPress est également vrai pour un
onglet de navigateur détenant un cookie et un nonce valides.
Auth::permission_callback() refuse cette voie
délibérément : il écoute l'action
application_password_did_authenticate que le cœur de WordPress déclenche
spécifiquement lorsque l'authentification par mot de passe d'application en
Basic-auth réussit, et exige ce drapeau, pas seulement « un utilisateur
quelconque est connecté ». Un client MCP n'est pas un onglet de navigateur
avec un nonce ; accepter l'authentification par cookie ici ouvrirait une
voie de type CSRF vers une surface d'outils à laquelle on peut demander, en
anglais, de modifier du contenu, sans aucun bénéfice par rapport à la seule
porte dont ce plugin a réellement besoin.
2. Capacité par outil, plus un verrou d'écriture que la capacité seule ne
peut pas ouvrir. ToolRegistry::call() vérifie
user_can($user_id, $capability) à chaque appel — edit_posts pour les
articles, manage_woocommerce pour les commandes. Séparément, le seul outil
d'écriture, create_draft_post, exige en plus qu'un administrateur ait
activé l'option depuis Réglages → WP Secure MCP, désactivée par défaut.
Un mot de passe d'application qui porte edit_posts ne peut toujours rien
écrire tant qu'un humain n'a pas basculé ce commutateur — une vérification
de capacité et un verrou à l'échelle du site sont deux serrures différentes,
et les tests prouvent qu'aucune ne remplace l'autre
dans un sens comme dans l'autre.
limit, 1–20) bornent ce qu'un seul appel peut renvoyer ; un appel
WP_Query ou wc_get_orders légitimement coûteux coûte toujours ce qu'il
coûte.L'entrée tools/list de chaque outil porte les annotations readOnlyHint /
destructiveHint, afin qu'un client puisse décider s'il doit demander à un
humain avant d'en appeler un, sans avoir besoin de savoir déjà ce qu'il fait.
composer install --no-dev
Copiez (ou créez un lien symbolique vers) le répertoire du plugin dans
wp-content/plugins/, activez-le depuis wp-admin, puis créez un mot de
passe d'application pour le compte sous lequel l'agent doit agir :
Utilisateurs → votre profil → Mots de passe d'application.
Les écritures sont désactivées par défaut. Pour autoriser
create_draft_post : Réglages → WP Secure MCP → Outils d'écriture. La
vérification de capacité par outil s'applique toujours par-dessus — basculer
le commutateur à l'échelle du site ne confère à personne une capacité qu'il
n'avait pas déjà.
55 tests, tous purs — aucune installation de WordPress, aucune base de
données, aucun Docker. tests/bootstrap.php
remplace les fonctions WordPress appelées par src/ (current_user_can,
get_post, wp_insert_post, ...) de la même manière qu'un plugin WordPress
est conventionnellement testé unitairement sans charger WordPress lui-même.
composer install
vendor/bin/phpunit --testdox
La couverture la plus lourde se situe là où cela compte le plus :
ProtocolTest — 17 tests contre un registre
factice, rien de spécifique à WordPress. Validation de l'enveloppe
JSON-RPC, chaque code d'erreur, et la règle de notification JSON-RPC 2.0
selon laquelle une requête sans champ id ne reçoit aucune réponse, pour
n'importe quelle méthode, même malformée — il n'y a nulle part où
l'erreur peut aller.ToolRegistryTest — prouve que la
vérification de capacité et le verrou d'écriture sont indépendants dans
les deux sens : une capacité manquante bloque un appel même lorsque les
écritures sont activées, et un verrou d'écriture désactivé bloque un appel
même lorsque la capacité est présente.AuthTest — le test qui compte le plus ici est
test_logged_in_user_without_application_password_authentication_is_refused :
un utilisateur WordPress réel et résolu est toujours refusé, parce qu'une
session par cookie n'a jamais été demandée.La CI exécute PHPUnit sur PHP 8.1–8.3 et PHP_CodeSniffer contre le jeu de règles WordPress Coding Standards à chaque push.
Ce sur quoi la CI ne s'exécute pas encore à chaque push : un test
d'intégration WordPress + WooCommerce en direct via
@wordpress/env — s'authentifier avec un véritable mot
de passe d'application contre une véritable API REST et une véritable base
de données, l'équivalent en instance live du job CI sur un vrai Postgres de
pg-readonly-mcp. Il est écrit et
raisonné, câblé comme job workflow_dispatch dans
ci.yml plutôt que comme porte obligatoire,
parce que l'environnement de développement de ce dépôt lui-même a rencontré
une défaillance locale de Docker Desktop qui a rendu impossible de répéter
wp-env avant la première exécution réelle de ce workflow. Dit ici plutôt
que laissé à quelqu'un d'autre pour le découvrir — la même règle que
pg-readonly-mcp applique à sa propre lacune de différentiel d'analyseur non
testée.
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 une requête est un jour acceptée ou refusée pour une raison qui réside
dans wp-secure-mcp.php ou RestController.php au lieu de Auth,
ToolRegistry ou Protocol, c'est un bug — le jugement appartient à la
couche inférieure, où il peut être testé avec un simple tableau et rien
d'autre.
MIT.
| Outil | Capacité | Lecture seule | Notes |
|---|
search_posts | edit_posts | Oui | Recherche par mot-clé. Codé en dur sur post_status=publish — ne renvoie jamais de brouillons ni d'articles privés, même à un appelant qui pourrait les voir dans wp-admin. |
get_post | edit_posts | Oui | Récupération par ID. Refuse un véritable article à un véritable ID si son statut n'est pas publish — un ID n'est pas un contournement de la même règle que search_posts applique. |
list_recent_orders | manage_woocommerce | Oui | Statut, total, devise, date uniquement. Jamais le nom, l'e-mail ou l'adresse du client — la visibilité des commandes n'exige pas de remettre à un agent les données personnelles d'un client, vérification de capacité ou non. |
create_draft_post | edit_posts | Non | post_status est un littéral 'draft' dans la source, pas un argument. Cet outil ne peut pas publier sous aucune entrée, même avec les écritures activées. Désactivé à l'échelle du site tant qu'un administrateur n'a pas activé l'option. |