
WordPressプラグインで、REST API経由でMCPサーバーを公開し、セキュリティモデルを要点としている -- そのような攻撃面をそもそも持たないことで、CVE-2026-15015のOAuthバイパスの形を塞ぐ。
平たく言えば: これは AI エージェントが、実際の WordPress Application Password — wp-admin がすでに発行しているのと同じ種類の資格情報 — を通じて、WordPress サイトを読み取り、少しだけ書き込めるようにするものであり、それ以外の何ものでもありません。ここにはサインアップフォームも、OAuth クライアント登録も、いかなる種類のトークンエンドポイントもありません。同じ領域のあるプラグインはまさにそれを実装してしまい、それが未認証の攻撃者が完全な管理者アクセスを持ち出した方法でした。
セキュリティモデルを機能の箇条書きではなく主旨として据えた、REST API 経由で MCP サーバーを公開する WordPress プラグインです。
CVE-2026-15015、CVSS 9.8: WordPress 用の MountDev AI MCP Connector は、誰が要求しているかを確認しない認可エンドポイントと並んで、公開アクセス可能な OAuth 2.1 Dynamic Client Registration エンドポイントを公開していました。攻撃者は自身の OAuth クライアントを登録し — 資格情報は不要で、そのエンドポイントは設計上未認証であり、それが「dynamic client registration」の意味です — 認可エンドポイントを無人で通過させ、管理者アカウントに紐づいたベアラートークンを受け取ります。完全なサイト制御: コンテンツ、ユーザー、設定。何も設定ミスはありませんでした。フローは OAuth クライアント登録が本来そうあるべきとおりに正確に機能しました。ただ、承認する管理者がすでに存在しない限り到達可能であるべきでは決してなかったのです。
一般化できる修正は「OAuth をより慎重に実装する」ことではありません。その表面を持たないことです。このプラグインは、クライアント登録エンドポイントも、認可エンドポイントも、いかなる種類のトークン発行エンドポイントも登録しません。 攻撃者が登録する対象は何もありません。なぜなら、ここには資格情報を配るものが何もないからです。認証は、管理者が wp-admin から特定のユーザー用にすでに作成した WordPress Application Password です — これは manage_options を持つ人間が、このプラグインとは完全に帯域外で、事前に作成することを選んだからこそ存在する資格情報です。
どちらか一方だけでは新しいものではありません。両方を、意図的に、互いに独立して動かすことが、どちらか一方の誤りを塞ぎます:
1. Application Password のみ — 決して Cookie セッションではない。 WordPress 自身の is_user_logged_in() は、有効な Cookie と nonce を保持するブラウザタブに対しても true です。Auth::permission_callback() はその経路を意図的に拒否します: これは WordPress コアが Basic 認証の Application Password 認証が成功したときに特異的に発火する application_password_did_authenticate アクションを監視し、「誰かユーザーがログインしている」だけでなくそのフラグを要求します。MCP クライアントは nonce を持つブラウザタブではありません。ここで Cookie 認証を受け入れることは、英語でコンテンツを変更するよう指示できるツール表面への CSRF 的な経路を開くことになり、このプラグインが実際に必要とする 1 つの扉に対して何の利点もありません。
2. ツールごとのケイパビリティ、およびケイパビリティだけでは開けない書き込みゲート。 ToolRegistry::call() は呼び出しごとに user_can($user_id, $capability) を新たにチェックします — 投稿には edit_posts、注文には manage_woocommerce。別途、唯一の書き込みツールである create_draft_post は、さらに管理者が Settings → WP Secure MCP からオプトインしていることを要求し、デフォルトではオフです。たまたま edit_posts を持つ Application Password でも、人間がそのスイッチを入れるまで何も書き込めません — ケイパビリティチェックとサイト全体のゲートは 2 つの異なる錠であり、テストはどちらも他方の代わりにならないことを証明しています、どちらの方向でも。
limit、1〜20) は 1 回の呼び出しが返せる量を制限しますが、正当に高コストな WP_Query や wc_get_orders の呼び出しは、かかるコストをそのままかけます。すべてのツールの tools/list エントリは readOnlyHint / destructiveHint アノテーションを持ち、クライアントはそれが何をするかをすでに知らなくても、呼び出す前に人間に尋ねるべきかどうかを判断できます。
composer install --no-dev
プラグインディレクトリを wp-content/plugins/ にコピー (またはシンボリックリンク) し、wp-admin から有効化してから、エージェントがそのアカウントとして動作すべきアカウントの Application Password を作成します: Users → your profile → Application Passwords。
書き込みはデフォルトでオフです。create_draft_post を許可するには: Settings → WP Secure MCP → Write tools。ツールごとのケイパビリティチェックはこれに加えて依然として適用されます — サイト全体のスイッチを入れても、誰かがすでに持っていないケイパビリティを付与することはありません。
55 テスト、すべて純粋 — WordPress インストールなし、データベースなし、Docker なし。 tests/bootstrap.php は、WordPress 自体をロードせずに WordPress プラグインを慣習的にユニットテストするのと同じ方法で、src/ が呼び出す WordPress 関数 (current_user_can、get_post、wp_insert_post、...) をスタブ化します。
composer install
vendor/bin/phpunit --testdox
最も重いカバレッジは、最も重要なところにあります:
ProtocolTest — 偽のレジストリに対する 17 テスト、WordPress 固有のものは一切なし。JSON-RPC エンベロープ検証、すべてのエラーコード、そして id フィールドのないリクエストは応答を得ないという JSON-RPC 2.0 の通知ルール — あらゆるメソッドに対して、不正なものでさえも — エラーの行き先がどこにもないのです。ToolRegistryTest — ケイパビリティチェックと書き込みゲートが両方向で独立していることを証明します: 書き込みが有効でもケイパビリティの欠如は呼び出しをブロックし、ケイパビリティが存在しても無効な書き込みゲートは呼び出しをブロックします。AuthTest — ここで最も重要なテストは test_logged_in_user_without_application_password_authentication_is_refused です: 実在し、解決された WordPress ユーザーであっても拒否されます。なぜなら Cookie セッションは決して要求されなかったからです。CI はすべてのプッシュで PHP 8.1〜8.3 にわたって PHPUnit を、そして WordPress Coding Standards ルールセットに対して PHP_CodeSniffer を実行します。
CI がまだすべてのプッシュで実行していないもの: @wordpress/env によるライブ WordPress + WooCommerce 統合テスト — 実際の Application Password で実際の REST API と実際のデータベースに対して認証する、pg-readonly-mcp の実 Postgres CI ジョブに相当するライブインスタンス版です。これは書かれ、論じ尽くされ、ci.yml 内で必須ゲートではなく workflow_dispatch ジョブとして配線されています。なぜなら、このリポジトリ自身の開発環境がローカルの Docker Desktop 障害に遭遇し、このワークフローの最初の実実行前に wp-env をリハーサルすることが不可能になったからです。これは pg-readonly-mcp が自身の未テストのパーサー差分ギャップに適用しているのと同じルールとして、誰か他の人が発見するために残すのではなく、ここに記します。
wp-secure-mcp.php プラグインブートストラップ: オートローダーをロードし、1 つの REST ルートを配線する
src/
Protocol.php JSON-RPC 2.0 ディスパッチ。WordPress 呼び出しはゼロ。純粋。
ToolRegistryInterface.php Protocol が WordPress 直接ではなく対話するシーム
ToolRegistry.php 2 つの錠: ツールごとのケイパビリティ、サーバー全体の書き込みゲート
Auth.php Application-Password-only の permission_callback
Settings.php 書き込みゲートの管理トグル
RestController.php 薄いブートストラップ: 1 つのルート、即座に委譲
Tools/ 4 つの v1 ツール
tests/
bootstrap.php WordPress 関数スタブ
ProtocolTest.php, ToolRegistryTest.php, AuthTest.php, ...
bin/smoke-test.sh ライブ wp-env 統合スクリプト (上記の Tests を参照)
リクエストが Auth、ToolRegistry、または Protocol ではなく wp-secure-mcp.php や RestController.php に住む理由で受け入れられたり拒否されたりすることがあれば、それはバグです — 判断は 1 つ下の層に属し、そこではプレーンな配列とそれ以外何もなしでテストできます。
MIT。
| ツール | ケイパビリティ | 読み取り専用 | 備考 |
|---|
search_posts | edit_posts | はい | キーワード検索。post_status=publish にハードコード — wp-admin で見られる呼び出し元であっても、下書きや非公開投稿を決して返しません。 |
get_post | edit_posts | はい | ID で取得。ステータスが publish でない場合、実在する ID の実在する投稿を拒否します — ID は search_posts が強制する同じルールを回避する手段ではありません。 |
list_recent_orders | manage_woocommerce | はい | ステータス、合計、通貨、日付のみ。顧客名、メール、住所は決して含みません — 注文の可視性は、ケイパビリティチェックの有無にかかわらず、エージェントに顧客の PII を渡すことを要求しません。 |
create_draft_post | edit_posts | いいえ | post_status はソース内のリテラル 'draft' であり、引数ではありません。このツールは、書き込みが有効であっても、いかなる入力でも公開できません。管理者がオプトインするまでサイト全体でオフです。 |