true, 'power_mode' => false, 'require_https' => true, 'rate_limit' => 120, 'api_keys' => array(), ); $settings = get_option( 'agentdesk_settings', array() ); return wp_parse_args( is_array( $settings ) ? $settings : array(), $defaults ); } /** * Update settings. * * @param array $settings Settings. */ public static function update_settings( $settings ) { $current = self::get_settings(); $merged = wp_parse_args( $settings, $current ); update_option( 'agentdesk_settings', $merged ); } /** * Whether safe mode is enabled. * * @return bool */ public static function is_safe_mode() { $settings = self::get_settings(); return ! empty( $settings['safe_mode'] ); } /** * Whether power mode is enabled. * * @return bool */ public static function is_power_mode() { $settings = self::get_settings(); return ! empty( $settings['power_mode'] ); } /** * Check if a tool requires power mode. * * @param array $tool Tool definition. * @return bool */ public static function tool_requires_power_mode( $tool ) { return ! empty( $tool['power_mode'] ); } /** * Validate tool can run under current mode. * * @param array $tool Tool definition. * @return true|WP_Error */ public static function validate_tool_mode( $tool ) { if ( self::tool_requires_power_mode( $tool ) && ! self::is_power_mode() ) { return new WP_Error( 'agentdesk_power_mode_required', __( 'This tool requires power mode. Enable it in Agentdesk Site Operations settings.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } if ( ! empty( $tool['blocked_in_safe_mode'] ) && self::is_safe_mode() ) { return new WP_Error( 'agentdesk_safe_mode_blocked', __( 'This tool is blocked while safe mode is enabled.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } return true; } /** * Check user capability for a tool. * * @param array $tool Tool definition. * @return true|WP_Error */ public static function validate_capability( $tool ) { $cap = isset( $tool['capability'] ) ? $tool['capability'] : 'read'; if ( ! current_user_can( $cap ) ) { return new WP_Error( 'agentdesk_forbidden', sprintf( /* translators: %s capability name */ __( 'Insufficient permissions. Required capability: %s', 'agentdesk-site-operations' ), $cap ), array( 'status' => 403 ) ); } return true; } /** * Rate limit check, scoped to the authenticated caller. * * @param string $identifier Stable per-caller identity (e.g. "key:abc" or "user:5"). * Falls back to the current user when omitted. * @return true|WP_Error */ public static function check_rate_limit( $identifier = '' ) { $settings = self::get_settings(); $limit = max( 10, absint( $settings['rate_limit'] ) ); $identifier = '' !== (string) $identifier ? (string) $identifier : ( 'user:' . ( get_current_user_id() ?: 0 ) ); $key = 'agentdesk_rate_' . md5( $identifier ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return new WP_Error( 'agentdesk_rate_limited', __( 'Rate limit exceeded. Try again shortly.', 'agentdesk-site-operations' ), array( 'status' => 429 ) ); } if ( 0 === $count ) { set_transient( $key, 1, MINUTE_IN_SECONDS ); } else { // Keep the original window; do not extend it on every request. set_transient( $key, $count + 1, MINUTE_IN_SECONDS ); } return true; } /** * Whether HTTPS is required and satisfied. * * @return true|WP_Error */ public static function validate_transport_security() { $settings = self::get_settings(); if ( empty( $settings['require_https'] ) ) { return true; } /** * Filter HTTPS requirement for local development. * * @param bool $require Whether HTTPS is required. */ $require = apply_filters( 'agentdesk_require_https', ! empty( $settings['require_https'] ) ); if ( $require && ! is_ssl() ) { return new WP_Error( 'agentdesk_https_required', __( 'HTTPS is required for MCP requests.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } return true; } /** * Coarse per-IP flood guard, checked BEFORE authentication so unauthenticated * bursts (including failed auth) cannot tie up the server. Separate from the * finer per-key limit applied after a caller is identified. * * @return true|WP_Error */ public static function check_ip_rate_limit() { $ip = self::client_ip(); if ( '' === $ip ) { return true; } $limit = (int) apply_filters( 'agentdesk_ip_rate_limit', 300 ); $key = 'agentdesk_ipr_' . md5( $ip ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return new WP_Error( 'agentdesk_ip_rate_limited', __( 'Too many requests from this IP address. Please slow down.', 'agentdesk-site-operations' ), array( 'status' => 429 ) ); } set_transient( $key, $count + 1, MINUTE_IN_SECONDS ); return true; } /** * Best-effort client IP. Honors X-Forwarded-For only when the site owner has * opted into trusting the proxy (AGENTDESK_TRUST_PROXY). * * @return string */ private static function client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( defined( 'AGENTDESK_TRUST_PROXY' ) && AGENTDESK_TRUST_PROXY && ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $parts = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ); $ip = trim( $parts[0] ); } return preg_replace( '/[^0-9a-fA-F:.]/', '', $ip ); } /** * Post types this plugin will never touch. Block-theme template types are * excluded because writing them is equivalent to editing theme files. * * @return array */ public static function blocked_post_types() { return array( 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'oembed_cache', 'user_request', 'customize_changeset', 'revision', 'nav_menu_item', ); } /** * Validate that a post type may be operated on. * * @param string $type Post type. * @return string|WP_Error Sanitized type. */ public static function validate_post_type( $type ) { $type = sanitize_key( (string) $type ); if ( in_array( $type, self::blocked_post_types(), true ) ) { return new WP_Error( 'agentdesk_post_type_forbidden', __( 'This post type cannot be modified through this plugin.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } $object = get_post_type_object( $type ); if ( ! $object ) { return new WP_Error( 'agentdesk_post_type_unknown', __( 'Unknown post type.', 'agentdesk-site-operations' ) ); } if ( empty( $object->public ) && empty( $object->show_ui ) ) { return new WP_Error( 'agentdesk_post_type_forbidden', __( 'Internal post types cannot be modified.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } return $type; } /** * Per-object edit check. Generic capabilities are not enough: a Contributor * has edit_posts but must not be able to edit someone else's published post. * * @param int $post_id Post ID. * @return true|WP_Error */ public static function can_edit_post( $post_id ) { $post_id = absint( $post_id ); $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'agentdesk_not_found', __( 'Post not found.', 'agentdesk-site-operations' ) ); } $type = self::validate_post_type( $post->post_type ); if ( is_wp_error( $type ) ) { return $type; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'agentdesk_forbidden', __( 'You are not allowed to edit this item.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } return true; } /** * Per-object delete check. * * @param int $post_id Post ID. * @return true|WP_Error */ public static function can_delete_post( $post_id ) { $post_id = absint( $post_id ); $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'agentdesk_not_found', __( 'Item not found.', 'agentdesk-site-operations' ) ); } $type = self::validate_post_type( $post->post_type ); if ( is_wp_error( $type ) ) { return $type; } if ( ! current_user_can( 'delete_post', $post_id ) ) { return new WP_Error( 'agentdesk_forbidden', __( 'You are not allowed to delete this item.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } return true; } /** * Check create/publish/author rights for a new post of a given type. * * @param string $type Post type. * @param string $status Desired status. * @param int $author Desired author ID (0 = current user). * @return true|WP_Error */ public static function can_create_post( $type, $status = 'draft', $author = 0 ) { $object = get_post_type_object( $type ); if ( ! $object ) { return new WP_Error( 'agentdesk_post_type_unknown', __( 'Unknown post type.', 'agentdesk-site-operations' ) ); } if ( ! current_user_can( $object->cap->create_posts ) ) { return new WP_Error( 'agentdesk_forbidden', __( 'You are not allowed to create this content type.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } if ( in_array( $status, array( 'publish', 'future', 'private' ), true ) && ! current_user_can( $object->cap->publish_posts ) ) { return new WP_Error( 'agentdesk_forbidden', __( 'You are not allowed to publish content.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } $author = absint( $author ); if ( $author && $author !== get_current_user_id() && ! current_user_can( $object->cap->edit_others_posts ) ) { return new WP_Error( 'agentdesk_forbidden', __( 'You are not allowed to assign another author.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } return true; } /** * Sanitize an Elementor element tree. * * Elementor's HTML and Shortcode widgets render their settings verbatim, so an * unfiltered tree is an arbitrary-script vector. Users who legitimately hold * unfiltered_html keep full control; everyone else gets those widgets rejected * and all string settings passed through wp_kses_post(). * * @param mixed $tree Element tree. * @return mixed|WP_Error */ public static function sanitize_elementor_tree( $tree ) { if ( current_user_can( 'unfiltered_html' ) ) { return $tree; } // Widgets that render their settings as raw markup or execute shortcodes. $blocked_widgets = array( 'html', 'shortcode' ); // Settings rendered onto the element wrapper; an "onmouseover|..." value here // would become an event handler, and it contains no tags for kses to catch. $blocked_keys = array( '_attributes', 'custom_attributes' ); // Only these settings hold rich text. Everything else (URLs, sizes, colours) // is left alone so kses entity-encoding cannot corrupt it. $rich_keys = array( 'editor', 'title', 'text', 'description', 'content', 'caption', 'alert_description', 'tab_content', 'html', ); $error = null; $walk = function ( $node, $key = '' ) use ( &$walk, $blocked_widgets, $blocked_keys, $rich_keys, &$error ) { if ( is_array( $node ) ) { if ( isset( $node['widgetType'] ) && in_array( strtolower( (string) $node['widgetType'] ), $blocked_widgets, true ) ) { $error = new WP_Error( 'agentdesk_widget_forbidden', __( 'The HTML and Shortcode widgets require the unfiltered_html capability.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); return $node; } $out = array(); foreach ( $node as $child_key => $value ) { if ( in_array( (string) $child_key, $blocked_keys, true ) ) { continue; // drop wrapper-attribute injection vectors entirely } $out[ $child_key ] = $walk( $value, (string) $child_key ); } return $out; } if ( is_string( $node ) ) { if ( in_array( $key, $rich_keys, true ) ) { return wp_kses_post( $node ); } // Non-rich values: strip tags and any javascript: / data: scheme. $clean = wp_strip_all_tags( $node ); if ( preg_match( '#^\s*(javascript|data|vbscript)\s*:#i', $clean ) ) { return ''; } return $clean; } return $node; }; $clean = $walk( $tree ); if ( $error ) { return $error; } return $clean; } /** * Per-object READ check. Published public content is readable by anyone who * can reach the endpoint; drafts, private, and protected items require the * same rights WordPress would demand in wp-admin. * * @param int $post_id Post ID. * @return true|WP_Error */ public static function can_read_post( $post_id ) { $post_id = absint( $post_id ); $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'agentdesk_not_found', __( 'Item not found.', 'agentdesk-site-operations' ) ); } $type = self::validate_post_type( $post->post_type ); if ( is_wp_error( $type ) ) { return $type; } if ( ! current_user_can( 'read_post', $post_id ) ) { return new WP_Error( 'agentdesk_forbidden', __( 'You are not allowed to view this item.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } // Never hand back a password-protected body. if ( ! empty( $post->post_password ) && ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'agentdesk_forbidden', __( 'This item is password protected.', 'agentdesk-site-operations' ), array( 'status' => 403 ) ); } return true; } }