
Un plugin WordPress che espone un server MCP tramite la REST API, con il modello di sicurezza come punto centrale -- chiude la forma di bypass OAuth della CVE-2026-15015 non avendo mai quella superficie.
In parole semplici: questo permette a un agente AI di leggere e scrivere in modo limitato su un sito WordPress tramite una vera Application Password di WordPress — lo stesso tipo di credenziale che wp-admin già rilascia — e nient'altro. Non c'è alcun modulo di registrazione, nessuna registrazione di client OAuth, nessun endpoint di token di alcun tipo qui. Un plugin in questo stesso ambito ha distribuito esattamente questo, ed è così che un attaccante non autenticato è uscito con pieno accesso da amministratore.
Un plugin WordPress che espone un server MCP tramite la REST API, con il modello di sicurezza come punto centrale, non come voce elenco di funzionalità.
CVE-2026-15015, CVSS 9.8: il MountDev AI MCP Connector per WordPress esponeva un endpoint OAuth 2.1 Dynamic Client Registration pubblicamente accessibile insieme a un endpoint di autorizzazione che non controllava nemmeno chi stesse facendo la richiesta. Un attaccante registra il proprio client OAuth — nessuna credenziale richiesta, l'endpoint è non autenticato per progettazione, è questo che significa "dynamic client registration" — lo fa passare attraverso l'endpoint di autorizzazione senza supervisione, e riceve un bearer token legato a un account amministratore. Pieno controllo del sito: contenuti, utenti, impostazioni. Nulla era mal configurato; il flusso funzionava esattamente come la registrazione di client OAuth dovrebbe funzionare. Semplicemente non sarebbe mai dovuto essere raggiungibile senza che un amministratore fosse già presente per approvarlo.
La correzione che generalizza non è "implementare OAuth con più attenzione".
È non avere quella superficie. Questo plugin non registra alcun endpoint di
registrazione client, alcun endpoint di autorizzazione, e alcun endpoint di
emissione di token di alcun tipo. Non c'è nulla contro cui un attaccante
possa registrarsi, perché non c'è nulla qui che distribuisca credenziali.
L'autenticazione è una Application Password di WordPress che un
amministratore ha già creato per uno specifico utente da wp-admin — una
credenziale che esiste solo perché un umano con manage_options ha scelto di
crearla, in anticipo, fuori banda rispetto a questo plugin interamente.
Nessuno dei due da solo è nuovo; eseguirli entrambi, di proposito, indipendenti l'uno dall'altro, è ciò che chiude un errore in uno dei due:
1. Solo Application Password — mai una sessione cookie. L'is_user_logged_in()
di WordPress è vero anche per una scheda del browser che detiene un cookie e
un nonce validi. Auth::permission_callback() rifiuta quel
percorso deliberatamente: ascolta l'azione application_password_did_authenticate
che il core di WordPress attiva specificamente quando l'autenticazione Basic-auth
con Application Password riesce, e richiede quel flag, non solo "un utente
è loggato". Un client MCP non è una scheda del browser con un nonce;
accettare l'autenticazione cookie qui aprirebbe un percorso a forma di CSRF
verso una superficie di strumenti a cui si può dire, in inglese, di
modificare contenuti, senza alcun beneficio rispetto all'unica porta di cui
questo plugin ha effettivamente bisogno.
2. Capacità per singolo strumento, più un gate di scrittura che la sola
capacità non può aprire. ToolRegistry::call()
controlla user_can($user_id, $capability) ex novo a ogni chiamata —
edit_posts per i post, manage_woocommerce per gli ordini. Separatamente,
l'unico strumento di scrittura, create_draft_post, richiede inoltre che un
amministratore abbia aderito da Impostazioni → WP Secure MCP, disattivato
per impostazione predefinita. Una Application Password che porta con sé
edit_posts non può comunque scrivere nulla finché un umano non ha attivato
quell'interruttore — un controllo di capacità e un gate a livello di sito
sono due serrature diverse, e i test dimostrano che nessuno dei due
sostituisce l'altro in nessuna delle due
direzioni.
limit,
1–20) limitano quanto può restituire una singola chiamata; una chiamata
WP_Query o wc_get_orders legittimamente costosa costa comunque quello
che costa.La voce tools/list di ogni strumento porta annotazioni readOnlyHint /
destructiveHint, così un client può decidere se chiedere a un umano prima
di chiamarne uno senza dover già sapere cosa fa.
composer install --no-dev
Copia (o crea un symlink de) la directory del plugin in wp-content/plugins/,
attivalo da wp-admin, poi crea una Application Password per l'account come
cui l'agente dovrebbe agire: Utenti → il tuo profilo → Application Passwords.
Le scritture sono disattivate per impostazione predefinita. Per consentire
create_draft_post: Impostazioni → WP Secure MCP → Strumenti di scrittura.
Il controllo di capacità per singolo strumento si applica comunque sopra
questo — attivare l'interruttore a livello di sito non concede a nessuno una
capacità che non aveva già.
55 test, tutti puri — nessuna installazione di WordPress, nessun database,
nessun Docker. tests/bootstrap.php stubba le
funzioni di WordPress che src/ chiama (current_user_can, get_post,
wp_insert_post, ...) allo stesso modo in cui un plugin WordPress viene
convenzionalmente testato a livello unitario senza caricare WordPress stesso.
composer install
vendor/bin/phpunit --testdox
La copertura più pesante si trova dove conta di più:
ProtocolTest — 17 test contro un registry
finto, nulla di specifico di WordPress. Validazione dell'involucro
JSON-RPC, ogni codice di errore, e la regola delle notifiche JSON-RPC 2.0
secondo cui una richiesta senza campo id non riceve risposta, per
qualsiasi metodo, anche uno malformato — non c'è posto dove l'errore
possa andare.ToolRegistryTest — dimostra che il
controllo di capacità e il gate di scrittura sono indipendenti in entrambe
le direzioni: una capacità mancante blocca una chiamata anche quando le
scritture sono abilitate, e un gate di scrittura disabilitato blocca una
chiamata anche quando la capacità è presente.AuthTest — il test che conta di più qui è
test_logged_in_user_without_application_password_authentication_is_refused:
un utente WordPress reale e risolto viene comunque rifiutato, perché una
sessione cookie non è mai stata richiesta.La CI esegue PHPUnit su PHP 8.1–8.3 e PHP_CodeSniffer contro il ruleset WordPress Coding Standards a ogni push.
Su cosa la CI non esegue ancora a ogni push: un test di integrazione
WordPress + WooCommerce dal vivo tramite
@wordpress/env — autenticarsi con una vera Application
Password contro una vera REST API e un vero database, l'equivalente su
istanza dal vivo del job CI con Postgres reale di
pg-readonly-mcp. È scritto e
ragionato, collegato come job workflow_dispatch in
ci.yml piuttosto che come gate obbligatorio,
perché l'ambiente di sviluppo di questo stesso repository ha incontrato un
guasto locale di Docker Desktop che ha reso impossibile provare wp-env
prima della prima esecuzione reale di questo workflow. Detto qui piuttosto
che lasciato a qualcun altro da scoprire — la stessa regola che
pg-readonly-mcp applica al proprio divario non testato di differenziale del
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)
Se una richiesta viene mai accettata o rifiutata per una ragione che risiede
in wp-secure-mcp.php o RestController.php invece che in Auth,
ToolRegistry o Protocol, quello è un bug — il giudizio appartiene a un
livello più in basso, dove può essere testato con un semplice array e
nient'altro.
MIT.
| Strumento | Capacità | Sola lettura | Note |
|---|
search_posts | edit_posts | Sì | Ricerca per parola chiave. Codificato su post_status=publish — non restituisce mai bozze o post privati, nemmeno a un chiamante che potrebbe vederli in wp-admin. |
get_post | edit_posts | Sì | Recupero per ID. Rifiuta un post reale a un ID reale se il suo stato non è publish — un ID non è un bypass della stessa regola che search_posts applica. |
list_recent_orders | manage_woocommerce | Sì | Solo stato, totale, valuta, data. Mai nome del cliente, email o indirizzo — la visibilità degli ordini non richiede di consegnare a un agente i dati personali di un cliente, controllo di capacità o meno. |
create_draft_post | edit_posts | No | post_status è un letterale 'draft' in il sorgente, non un argomento. Questo strumento non può pubblicare con alcun input, nemmeno con le scritture abilitate. Disattivato a livello di sito finché un amministratore non aderisce. |