settings = $settings; $this->tokens = $tokens; $this->limiter = $limiter; } /** * Hook: remember that WordPress authenticated this request with an * Application Password (as opposed to a cookie). */ public static function register_hooks(): void { add_action( 'application_password_did_authenticate', static function () { self::$app_password_used = true; } ); } /** * Extract the Authorization header from the request. * * @param \WP_REST_Request $request Request. */ public static function authorization_header( \WP_REST_Request $request ): string { $header = (string) $request->get_header( 'authorization' ); if ( '' === $header ) { foreach ( array( 'HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION' ) as $key ) { if ( ! empty( $_SERVER[ $key ] ) ) { $header = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); break; } } } if ( '' === $header && function_exists( 'getallheaders' ) ) { // Some Apache setups keep the header out of $_SERVER but still expose it here. foreach ( (array) getallheaders() as $name => $value ) { if ( 'authorization' === strtolower( (string) $name ) ) { $header = sanitize_text_field( (string) $value ); } } } return trim( $header ); } /** * Client IP for rate limiting (never stored raw). */ public static function client_ip(): string { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; // Behind Cloudflare or a load balancer every request comes from the // proxy. Only when the owner says a proxy is in front, and the request // really comes from one, use the client address it forwards. if ( ! \HandyMCP\Support\Env::is_trusted_proxy( $ip ) ) { return $ip; } // CF-Connecting-IP is only set by Cloudflare's edge, so it is only read from a Cloudflare address. if ( \HandyMCP\Support\Env::is_cloudflare( $ip ) ) { $cf = isset( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) ) : ''; if ( filter_var( $cf, FILTER_VALIDATE_IP ) ) { return $cf; } } if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { // Each proxy appends the address it saw, so the client's own claim is on the left: // walk from the right and take the first address that is not one of our proxies. $hops = array_reverse( array_map( 'trim', explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ) ) ); foreach ( $hops as $hop ) { if ( ! filter_var( $hop, FILTER_VALIDATE_IP ) ) { break; } if ( ! \HandyMCP\Support\Env::is_trusted_proxy( $hop ) ) { return $hop; } } } // X-Real-IP is only a fallback for proxies that do not send X-Forwarded-For. $real = isset( $_SERVER['HTTP_X_REAL_IP'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ) ) : ''; if ( filter_var( $real, FILTER_VALIDATE_IP ) ) { return $real; } return $ip; } /** * Transport-level checks that run before credentials are inspected. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function check_transport( \WP_REST_Request $request ) { if ( ! $this->settings->get( 'enabled', true ) ) { return new \WP_Error( 'mcp_disabled', 'The MCP server is disabled on this site.', array( 'status' => 503 ) ); } if ( ! Env::request_is_secure() && ! Env::is_local() && ! $this->settings->get( 'allow_insecure_http' ) ) { return new \WP_Error( 'https_required', 'HTTPS is required for MCP connections.', array( 'status' => 403 ) ); } $query = (array) $request->get_query_params(); foreach ( array( 'access_token', 'token', 'api_key', 'password' ) as $param ) { if ( isset( $query[ $param ] ) ) { return new \WP_Error( 'credentials_in_url', 'Credentials must never be sent in the URL. Use the Authorization header.', array( 'status' => 400 ) ); } } $origin = (string) $request->get_header( 'origin' ); if ( '' !== $origin && ! $this->origin_allowed( $origin ) ) { return new \WP_Error( 'origin_rejected', 'Origin not allowed.', array( 'status' => 403 ) ); } // The failed-sign-in limit is applied in fail(), to wrong credentials only: a valid token keeps // working even when someone else behind the same address (an AI vendor, an office NAT) fails. return true; } /** * Origin allowlist (DNS-rebinding protection for Streamable HTTP). * * @param string $origin Origin header. */ public function origin_allowed( string $origin ): bool { $origin = untrailingslashit( strtolower( $origin ) ); $site = strtolower( untrailingslashit( (string) wp_parse_url( home_url(), PHP_URL_SCHEME ) . '://' . wp_parse_url( home_url(), PHP_URL_HOST ) . ( wp_parse_url( home_url(), PHP_URL_PORT ) ? ':' . wp_parse_url( home_url(), PHP_URL_PORT ) : '' ) ) ); if ( $origin === $site ) { return true; } foreach ( (array) $this->settings->get( 'allowed_origins', array() ) as $allowed ) { if ( strtolower( untrailingslashit( (string) $allowed ) ) === $origin ) { return true; } } return false; } /** * Authenticate the request. * * @param \WP_REST_Request $request Request. * @return Identity|\WP_Error */ public function authenticate( \WP_REST_Request $request ) { // MCP requests never get administrative capabilities, whatever the user's role. CapGuard::apply(); $transport = $this->check_transport( $request ); if ( is_wp_error( $transport ) ) { return $transport; } $header = self::authorization_header( $request ); if ( 0 === stripos( $header, 'bearer ' ) ) { $raw = trim( substr( $header, 7 ) ); $row = $this->tokens->find_raw( $raw ); if ( ! $row || ! in_array( $row->kind, array( 'pat', 'at' ), true ) ) { return $this->fail( 'invalid_token', 'The access token is invalid.' ); } if ( ! $this->tokens->is_active( $row ) ) { return $this->fail( 'invalid_token', (int) $row->revoked ? 'The access token has been revoked.' : 'The access token has expired.' ); } $user = get_user_by( 'id', (int) $row->user_id ); if ( ! $user ) { return $this->fail( 'invalid_token', 'The token owner no longer exists.' ); } if ( ! $this->user_allowed( $user ) ) { return new \WP_Error( 'role_not_allowed', 'This user role is not allowed to use MCP.', array( 'status' => 403 ) ); } if ( ( $row->client_id && Pause::is_paused( 'client', (string) $row->client_id ) ) || ( 'pat' === $row->kind && Pause::is_paused( 'token', (string) $row->id ) ) ) { // 403, not 401: the app keeps its sign-in and must not start a new one. return new \WP_Error( 'connection_paused', 'This connection is paused by the site owner. Nothing can be read or changed until they resume it in HandyMCP > Connection.', array( 'status' => 403 ) ); } wp_set_current_user( $user->ID ); $this->tokens->touch( (int) $row->id ); $scopes = Scopes::sanitize( explode( ' ', (string) $row->scopes ) ); return new Identity( (int) $user->ID, 'at' === $row->kind ? 'oauth' : 'pat', (int) $row->id, $scopes, (string) ( $row->client_id ? $row->client_id : $row->name ) ); } if ( 0 === stripos( $header, 'basic ' ) ) { if ( ! $this->settings->get( 'app_password_auth', true ) ) { return $this->fail( 'invalid_request', 'Application Password authentication is disabled for MCP.' ); } $user_id = get_current_user_id(); if ( ! $user_id || ! self::$app_password_used ) { return $this->fail( 'invalid_token', 'Application Password authentication failed.' ); } $user = wp_get_current_user(); if ( ! $this->user_allowed( $user ) ) { return new \WP_Error( 'role_not_allowed', 'This user role is not allowed to use MCP.', array( 'status' => 403 ) ); } return new Identity( (int) $user_id, 'app_password', 0, Scopes::sanitize( (array) $this->settings->get( 'app_password_scopes', array( 'read' ) ) ), 'application-password' ); } return new \WP_Error( 'unauthorized', 'Authentication required.', array( 'status' => 401, 'headers' => array( 'WWW-Authenticate' => $this->www_authenticate() ), ) ); } /** * Failure: counts toward the auth-failure limiter. * * @param string $code Error code. * @param string $message Message. */ private function fail( string $code, string $message ): \WP_Error { $key = 'authfail:' . self::client_ip(); if ( ! $this->limiter->allow( $key, 'auth_fail', false ) ) { return new \WP_Error( 'rate_limited', 'Too many failed authentication attempts. Try again later.', array( 'status' => 429, 'headers' => array( 'Retry-After' => '600' ) ) ); } $this->limiter->hit( $key, 'auth_fail' ); return new \WP_Error( $code, $message, array( 'status' => 401, 'headers' => array( 'WWW-Authenticate' => $this->www_authenticate( $code ) ), ) ); } /** * RFC 6750 / RFC 9728 challenge. * * @param string $error OAuth error code. */ public function www_authenticate( string $error = '' ): string { $value = 'Bearer realm="handymcp", resource_metadata="' . esc_url_raw( \HandyMCP\Admin\Diagnostics::resource_metadata_url() ) . '"'; if ( $error ) { $value .= ', error="' . preg_replace( '/[^a-z_]/', '', $error ) . '"'; } return $value; } /** * Role allowlist. * * @param \WP_User $user User. */ public function user_allowed( \WP_User $user ): bool { if ( is_multisite() && is_super_admin( $user->ID ) ) { return true; } $allowed = (array) $this->settings->get( 'allowed_roles', array( 'administrator' ) ); return (bool) array_intersect( $allowed, (array) $user->roles ); } }