The Exploit
An authenticated user with Subscriber role (the lowest privilege tier in WordPress) can obtain and use an OAuth token to invoke admin-level MCP tools without holding administrator capabilities.
POST /wp-json/mwai/v1/mcp/authorize HTTP/1.1
Host: vulnerable-site.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in_..=subscriber_session_cookie
client_id=malicious-mcp-client&response_type=code&redirect_uri=http://attacker.local/callback&scope=admin&_mwai_nonce=<valid_nonce>
The Subscriber user receives a consent page and can mint an OAuth token. Once obtained, that token grants access to admin-level MCP operations (such as wp_create_user or file operations) that the Subscriber could never invoke directly in WordPress. The attacker observes HTTP 200 with a valid authorization code, exchanges it for a bearer token, and subsequently calls MCP endpoints with that token in the Authorization header. Each call executes with the privileges of the token-holder, not the Subscriber's actual WordPress role.
What the Patch Did
Before
if ( $this->oauth ) {
$token_data = $this->oauth->validate_token( $token );
if ( $token_data ) {
// Set current user based on OAuth token
wp_set_current_user( $token_data['user_id'] );
$auth_result = 'oauth';
After
if ( $this->oauth ) {
$token_data = $this->oauth->validate_token( $token );
if ( $token_data ) {
if ( !$this->oauth->user_can_authorize( $token_data['user_id'] ) ) {
if ( $this->logging ) {
error_log( '[AI Engine MCP] ❌ OAuth token rejected: user ' . $token_data['user_id'] . ' is not an administrator.' );
}
return false;
}
wp_set_current_user( $token_data['user_id'] );
$auth_result = 'oauth';
The patch added a runtime capability gate: the user_can_authorize() method. This method is called every time an OAuth token is validated, ensuring the linked user currently holds administrator capabilities. If the check fails, the token is rejected and an error is logged. Additionally, the OAuth consent and token-submission endpoints now call user_can_authorize() at authorization time, preventing non-administrators from minting tokens in the first place:
if ( !$this->user_can_authorize( $user->ID ) ) {
if ( $this->logging ) {
error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . $user->ID . ' tried to authorize client ' . $params['client_id'] );
}
$this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
exit;
}
Root Cause
CWE-269: Improper Access Control (Broken Authorization). The vulnerability is a missing privilege check at the OAuth token consumption boundary. When a user presents a valid OAuth token (validated via signature or database lookup), the old code accepted it and set the current user based on $token_data['user_id'] without verifying that this user retained administrator privileges at the moment of use. The token itself was not scope-limited or capability-bound; it granted access to all MCP tools unconditionally. Since MCP tools are designed for admin-level operations (user creation, file manipulation, etc.), any user who could obtain a token could escalate to the capabilities implied by the MCP layer. The authorization endpoint (labs/mcp-oauth.php lines 386–395) was also missing a capability check, allowing non-admin users to reach the consent flow and mint tokens.
Why It Works
The load-bearing line is if ( !$this->oauth->user_can_authorize( $token_data['user_id'] ) ) in the token validation path. Without this check, a Subscriber's token would pass through and wp_set_current_user() would make all downstream code see the Subscriber as the current user — but the MCP layer would treat that user as if they held admin privileges (because it was checking the token, not the user's actual role). The engineer added two additional defenses: the same check at token-minting time (authorization endpoint) and the same check at token-submission time (token endpoint). Together, these block non-admins from ever obtaining a token and reject any pre-existing tokens from downgraded users. The logging in all three locations provides forensic visibility. Without the runtime check in the token validation path, a token minted before a user was demoted from Admin to Subscriber would still grant admin MCP access — a token-refresh or privilege-revocation scenario. Without the authorization-time checks, an attacker who discovers they can reach the OAuth endpoints would immediately mint a token. The patch applies the gate at every trust boundary.
Hardening Checklist
-
Apply capability gates at every auth boundary. Do not assume that validating a credential (token, session cookie, API key) is sufficient; verify the user's current permissions at the moment the credential is used via
current_user_can( 'manage_options' )or similar role-based checks. Tokens and sessions persist; roles and permissions do not. -
Scope OAuth tokens to explicit capabilities at mint time. Do not design tokens to inherit all capabilities of the user who minted them. Implement a
scopeparameter in the authorization request and store granted scopes in the token payload. Verify scope at the tool invocation boundary, not user role. -
Gate privileged OAuth endpoints. Treat the authorization endpoint, token endpoint, and scope-approval UI as privileged actions. Wrap these with
wp_verify_nonce()andcurrent_user_can( 'manage_options' )before rendering consent or issuing tokens. -
Log all auth failures and scope mismatches. Use
error_log()with a machine-parseable format (e.g.,[AI Engine MCP] ❌ ...) to record rejected tokens, failed capability checks, and unauthorized scope requests. Parse these logs weekly to detect token abuse. -
Implement per-endpoint capability checks, not layer-wide role assumptions. Each MCP tool endpoint should call
current_user_can()with a tool-specific capability (e.g.,mcp_create_user,mcp_read_file) rather than assuming all authenticated users holding a token can invoke all tools.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-8719