
Um plugin WordPress que expõe um servidor MCP sobre a REST API, com o modelo de segurança como ponto central -- fecha o formato de bypass de OAuth da CVE-2026-15015 ao nunca ter essa superfície.
Em termos simples: isto permite que um agente de IA leia e escreva ligeiramente num site WordPress através de uma Application Password real do WordPress — o mesmo tipo de credencial que o wp-admin já emite — e nada mais. Não há formulário de registo, nem registo de cliente OAuth, nem endpoint de token de qualquer tipo aqui. Um plugin neste mesmo espaço disponibilizou exatamente isso, e foi assim que um atacante não autenticado saiu com acesso total de administrador.
Um plugin WordPress que expõe um servidor MCP sobre a REST API, com o modelo de segurança como ponto central, não como um item de destaque.
CVE-2026-15015, CVSS 9.8: o MountDev AI MCP Connector para WordPress expunha um endpoint de Registo Dinâmico de Cliente OAuth 2.1 publicamente acessível, juntamente com um endpoint de autorização que também não verificava quem estava a pedir. Um atacante regista o seu próprio cliente OAuth — sem credenciais necessárias, o endpoint é não autenticado por design, é isso que "registo dinâmico de cliente" significa — percorre-o através do endpoint de autorização sem supervisão, e recebe um bearer token associado a uma conta de administrador. Controlo total do site: conteúdo, utilizadores, definições. Nada estava mal configurado; o fluxo funcionou exatamente como o registo de cliente OAuth deve funcionar. Simplesmente nunca deveria ter sido acessível sem um administrador já presente para o aprovar.
A correção que se generaliza não é "implementar OAuth com mais cuidado". É
não ter essa superfície. Este plugin não regista nenhum endpoint de
registo de cliente, nenhum endpoint de autorização, e nenhum endpoint de
emissão de tokens de qualquer tipo. Não há nada contra o que um atacante
se possa registar, porque não há aqui nada que distribua credenciais. A
autenticação é uma Application Password do WordPress que um administrador já
criou para um utilizador específico a partir do wp-admin — uma credencial que
existe apenas porque um humano com manage_options escolheu criá-la,
antecipadamente, fora de banda deste plugin por completo.
Nenhuma delas sozinha é nova; executar ambas, de propósito, independentes uma da outra, é o que fecha um erro em qualquer uma delas:
1. Apenas Application Password — nunca uma sessão de cookie. O próprio
is_user_logged_in() do WordPress também é verdadeiro para um separador de
navegador que tenha um cookie e nonce válidos.
Auth::permission_callback() recusa esse caminho
deliberadamente: escuta a ação application_password_did_authenticate que o
núcleo do WordPress dispara especificamente quando a autenticação Basic-auth
por Application Password tem sucesso, e exige esse sinalizador, não apenas
"algum utilizador está autenticado". Um cliente MCP não é um separador de
navegador com um nonce; aceitar autenticação por cookie aqui abriria um
caminho em forma de CSRF para uma superfície de ferramentas que pode receber,
em inglês, a instrução de modificar conteúdo, sem qualquer benefício em
relação à única porta de que este plugin realmente precisa.
2. Capacidade por ferramenta, mais um portão de escrita que a capacidade
sozinha não consegue abrir. ToolRegistry::call()
verifica user_can($user_id, $capability) de novo em cada chamada —
edit_posts para posts, manage_woocommerce para encomendas. Separadamente,
a única ferramenta de escrita, create_draft_post, exige adicionalmente que
um administrador tenha optado por ativá-la em Settings → WP Secure MCP,
desativada por predefinição. Uma Application Password que por acaso tenha
edit_posts continua a não poder escrever nada até um humano ter ativado
esse interruptor — uma verificação de capacidade e um portão ao nível do
site são dois fechaduras diferentes, e os testes provam que nenhuma
substitui a outra em qualquer direção.
limit, 1–20) limitam quanto uma única chamada pode devolver; uma chamada
WP_Query ou wc_get_orders legitimamente dispendiosa continua a custar o
que custa.A entrada tools/list de cada ferramenta inclui anotações readOnlyHint /
destructiveHint, para que um cliente possa decidir se deve perguntar a um
humano antes de chamar uma, sem precisar de já saber o que ela faz.
composer install --no-dev
Copie (ou crie um link simbólico para) o diretório do plugin para
wp-content/plugins/, ative-o a partir do wp-admin, e depois crie uma
Application Password para a conta em nome da qual o agente deve atuar:
Users → o seu perfil → Application Passwords.
As escritas estão desativadas por predefinição. Para permitir
create_draft_post: Settings → WP Secure MCP → Write tools. A
verificação de capacidade por ferramenta continua a aplicar-se por cima
disto — ativar o interruptor ao nível do site não concede a ninguém uma
capacidade que já não tivesse.
55 testes, todos puros — sem instalação do WordPress, sem base de dados,
sem Docker. tests/bootstrap.php cria stubs das
funções do WordPress que src/ chama (current_user_can, get_post,
wp_insert_post, ...) da mesma forma que um plugin WordPress é
convencionalmente testado unitariamente sem carregar o próprio WordPress.
composer install
vendor/bin/phpunit --testdox
A cobertura mais pesada está onde mais importa:
ProtocolTest — 17 testes contra um registo
falso, nada específico do WordPress. Validação do envelope JSON-RPC, todos
os códigos de erro, e a regra de notificação do JSON-RPC 2.0 de que um
pedido sem campo id não recebe resposta, para qualquer método, mesmo um
malformado — não há para onde o erro ir.ToolRegistryTest — prova que a verificação
de capacidade e o portão de escrita são independentes em ambas as direções:
uma capacidade em falta bloqueia uma chamada mesmo quando as escritas estão
ativadas, e um portão de escrita desativado bloqueia uma chamada mesmo
quando a capacidade está presente.AuthTest — o teste que mais importa aqui é
test_logged_in_user_without_application_password_authentication_is_refused:
um utilizador WordPress real e resolvido continua a ser recusado, porque
nunca foi pedida uma sessão de cookie.O CI executa o PHPUnit em PHP 8.1–8.3 e o PHP_CodeSniffer contra o conjunto de regras WordPress Coding Standards em cada push.
O que o CI ainda não executa em cada push: um teste de integração com
WordPress + WooCommerce ao vivo via
@wordpress/env — autenticar com uma Application
Password real contra uma REST API real e uma base de dados real, o
equivalente em instância ao vivo do trabalho de CI com Postgres real do
pg-readonly-mcp. Está escrito e
fundamentado, ligado como um trabalho workflow_dispatch em
ci.yml em vez de um portão obrigatório, porque
o próprio ambiente de desenvolvimento deste repositório encontrou uma falha
local do Docker Desktop que tornou impossível ensaiar o wp-env antes da
primeira execução real deste workflow. Dito aqui em vez de deixado para
outra pessoa descobrir — a mesma regra que o pg-readonly-mcp aplica à sua
própria lacuna não testada 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)
Se um pedido for alguma vez aceite ou recusado por uma razão que resida em
wp-secure-mcp.php ou RestController.php em vez de Auth, ToolRegistry
ou Protocol, isso é um bug — o julgamento pertence a uma camada abaixo,
onde pode ser testado com um array simples e nada mais.
MIT.
| Ferramenta | Capacidade | Apenas leitura | Notas |
|---|
search_posts | edit_posts | Sim | Pesquisa por palavra-chave. Fixada em post_status=publish — nunca devolve rascunhos ou posts privados, mesmo a um chamador que os pudesse ver no wp-admin. |
get_post | edit_posts | Sim | Obter por ID. Recusa um post real num ID real se o seu estado não for publish — um ID não é um bypass à mesma regra que search_posts impõe. |
list_recent_orders | manage_woocommerce | Sim | Apenas estado, total, moeda, data. Nunca nome, email ou morada do cliente — a visibilidade de encomendas não exige entregar a um agente os dados pessoais de um cliente, com ou sem verificação de capacidade. |
create_draft_post | edit_posts | Não | post_status é um literal 'draft' em o código-fonte, não um argumento. Esta ferramenta não pode publicar com nenhuma entrada, mesmo com as escritas ativadas. Desativada ao nível do site até um administrador optar por ativá-la. |