Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/quiz-master-next/mlw_quizmaster2.php
+++ b/quiz-master-next/mlw_quizmaster2.php
@@ -2,7 +2,7 @@
/**
* Plugin Name: Quiz And Survey Master
* Description: Easily and quickly add quizzes and surveys to your website.
- * Version: 11.2.1
+ * Version: 11.2.2
* Author: ExpressTech
* Author URI: https://quizandsurveymaster.com/
* Plugin URI: https://expresstech.io/
@@ -43,7 +43,7 @@
* @var string
* @since 4.0.0
*/
- public $version = '11.2.1';
+ public $version = '11.2.2';
/**
* QSM Alert Manager Object
--- a/quiz-master-next/php/admin/options-page-text-tab.php
+++ b/quiz-master-next/php/admin/options-page-text-tab.php
@@ -289,6 +289,17 @@
global $mlwQuizMasterNext;
if ( isset( $_POST['nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'qsm_save_text_message_nonce' ) ) {
$quiz_id = isset( $_POST['quiz_id'] ) ? intval( $_POST['quiz_id'] ) : 0;
+ // The nonce above is global (not bound to a quiz), so enforce a per-quiz
+ // ownership check before writing to prevent a cross-quiz IDOR write.
+ if ( ! function_exists( 'qsm_current_user_can_edit_quiz' ) || ! qsm_current_user_can_edit_quiz( $quiz_id ) ) {
+ echo wp_json_encode(
+ array(
+ 'success' => false,
+ 'message' => __( 'You are not allowed to edit this quiz.', 'quiz-master-next' ),
+ )
+ );
+ exit;
+ }
$text_id = isset( $_POST['text_id'] ) ? sanitize_text_field( wp_unslash( $_POST['text_id'] ) ) : '';
$message = isset( $_POST['message'] ) ? wp_kses_post( wp_unslash( $_POST['message'] ) ) : '';
$settings = $mlwQuizMasterNext->pluginHelper->get_quiz_setting( 'quiz_text' );
--- a/quiz-master-next/php/classes/class-qmn-quiz-manager.php
+++ b/quiz-master-next/php/classes/class-qmn-quiz-manager.php
@@ -822,9 +822,20 @@
$categories_tree = ( isset( $categories['tree'] ) ? $categories['tree'] : array() );
if ( ! empty( $category_ids ) ) {
- $term_ids = implode( ',', $category_ids );
- $question_id = implode( ',', $question_ids );
- $term_ids = ( '' !== $quiz_options->randon_category ) ? $quiz_options->randon_category : $term_ids;
+ // Security (CVE-2026-15963): the randon_category quiz option is a
+ // user-controlled, comma-separated list of category (term) IDs. Category and
+ // question IDs are always integers, so cast every value to a positive int
+ // before it is interpolated into the raw SQL IN() lists below — otherwise a
+ // Contributor+/Custom+ user can inject SQL via randon_category.
+ $term_ids = implode( ',', array_filter( array_map( 'absint', $category_ids ) ) );
+ $question_id = implode( ',', array_filter( array_map( 'absint', $question_ids ) ) );
+ if ( '' !== $quiz_options->randon_category ) {
+ $term_ids = implode( ',', array_filter( array_map( 'absint', explode( ',', $quiz_options->randon_category ) ) ) );
+ }
+ // Guard against an empty IN() list (e.g. a non-numeric randon_category value),
+ // which would otherwise be a SQL syntax error; '0' safely matches no rows.
+ $term_ids = '' !== $term_ids ? $term_ids : '0';
+ $question_id = '' !== $question_id ? $question_id : '0';
$tq_ids = $wpdb->get_results(
"SELECT DISTINCT qt.term_id, qt.question_id
FROM {$wpdb->prefix}mlw_question_terms AS qt
@@ -869,26 +880,36 @@
if ( empty( $category ) || empty( $category_question_limit['question_limit_key'][ $key ] ) ) {
continue;
}
- $limit = $category_question_limit['question_limit_key'][ $key ];
- $exclude_ids = 0;
+ // Security (SQLi): the quiz id, category (term) id, per-category limit and the
+ // exclude-id list all derive from the Contributor-settable select_category_question
+ // quiz option, so cast every value to an int before it reaches the query — otherwise
+ // this Mode-2 branch is SQL injection (sibling of the Mode-1 randon_category fix above).
+ $limit = intval( $category_question_limit['question_limit_key'][ $key ] );
+ $exclude_ids = '0';
if ( ! empty( $tq_ids ) && ! empty( ( array_column( array_merge( ...array_map( 'array_merge', $tq_ids ) ), 'question_id' ) ) ) ) {
- $exclude_ids = implode( ',', array_column( array_merge( ...array_map( 'array_merge', $tq_ids ) ), 'question_id' ) );
+ $exclude_ids = implode( ',', array_filter( array_map( 'absint', array_column( array_merge( ...array_map( 'array_merge', $tq_ids ) ), 'question_id' ) ) ) );
+ $exclude_ids = '' !== $exclude_ids ? $exclude_ids : '0';
}
$category_order_sql = '';
if ( in_array( 'questions', $randomness_order, true ) || in_array( 'pages', $randomness_order, true ) ) {
$category_order_sql = 'ORDER BY rand()';
}
$tq_ids[] = $wpdb->get_results(
- "SELECT DISTINCT q.`question_id`
- FROM `{$wpdb->prefix}mlw_questions` AS q
- JOIN `{$wpdb->prefix}mlw_question_terms` AS qt ON q.`question_id` = qt.`question_id`
- WHERE qt.`quiz_id` = $quiz_id
- AND qt.`term_id` = $category
- AND qt.`taxonomy` = 'qsm_category'
- AND qt.`question_id` NOT IN ($exclude_ids)
- AND q.`deleted` = 0
- " . esc_sql( $category_order_sql ) . "
- LIMIT $limit",
+ $wpdb->prepare(
+ "SELECT DISTINCT q.`question_id`
+ FROM `{$wpdb->prefix}mlw_questions` AS q
+ JOIN `{$wpdb->prefix}mlw_question_terms` AS qt ON q.`question_id` = qt.`question_id`
+ WHERE qt.`quiz_id` = %d
+ AND qt.`term_id` = %d
+ AND qt.`taxonomy` = 'qsm_category'
+ AND qt.`question_id` NOT IN ($exclude_ids)
+ AND q.`deleted` = 0
+ " . esc_sql( $category_order_sql ) . "
+ LIMIT %d",
+ intval( $quiz_id ),
+ intval( $category ),
+ $limit
+ ),
ARRAY_A
);
}
--- a/quiz-master-next/php/question-types/qsm-question-type-polar.php
+++ b/quiz-master-next/php/question-types/qsm-question-type-polar.php
@@ -33,7 +33,7 @@
$slider_data_atts .= ' data-answer1=' . $answar1 . ' ';
$slider_data_atts .= ' data-answer2=' . $answar2 . ' ';
$slider_data_atts .= ' data-is_reverse=' . intval( $is_reverse ) . ' ';
- $slider_data_atts .= ' data-is_required=' . $required . ' ';
+ $slider_data_atts .= ' data-is_required=' . intval( $required ) . ' ';
if ( 0 == $required ) {
$mlw_require_class = 'mlwRequiredText mlwRequiredPolar';
} else {
--- a/quiz-master-next/php/rest-api.php
+++ b/quiz-master-next/php/rest-api.php
@@ -66,8 +66,9 @@
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'qsm_rest_get_results',
- 'permission_callback' => function () {
- return current_user_can( 'edit_qsm_quizzes' );
+ 'permission_callback' => function ( WP_REST_Request $request ) {
+ return current_user_can( 'edit_qsm_quizzes' )
+ && qsm_current_user_can_edit_quiz( $request['id'] );
},
)
);
@@ -89,8 +90,9 @@
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'qsm_rest_get_emails',
- 'permission_callback' => function () {
- return current_user_can( 'edit_qsm_quizzes' );
+ 'permission_callback' => function ( WP_REST_Request $request ) {
+ return current_user_can( 'edit_qsm_quizzes' )
+ && qsm_current_user_can_edit_quiz( $request['id'] );
},
)
);
@@ -149,8 +151,9 @@
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'qsm_rest_get_categories',
- 'permission_callback' => function () {
- return current_user_can( 'edit_qsm_quizzes' );
+ 'permission_callback' => function ( WP_REST_Request $request ) {
+ return current_user_can( 'edit_qsm_quizzes' )
+ && qsm_current_user_can_edit_quiz( $request['id'] );
},
)
);
@@ -560,6 +563,16 @@
$current_user = wp_get_current_user();
if ( 0 !== $current_user ) {
$question = QSM_Questions::load_question( $request['id'] );
+ // Security (IDOR): the {id} in this route is a QUESTION id, so authorise against
+ // the question's OWNING quiz ($question['quiz_id']) — NOT $request['id'] — before
+ // disclosing it. The flat-cap permission_callback alone lets any Contributor read
+ // another author's question; mirrors the internal checks in the save_* callbacks.
+ if ( ! empty( $question ) && ! qsm_current_user_can_edit_quiz( $question['quiz_id'] ) ) {
+ return array(
+ 'status' => 'error',
+ 'msg' => __( 'Unauthorized!', 'quiz-master-next' ),
+ );
+ }
$categorysArray = QSM_Questions::get_question_categories( $question['question_id'] );
if ( ! empty( $question ) ) {
$is_linking = isset( $request['is_linking'] ) ? intval( $request['is_linking'] ) : 0;
--- a/quiz-master-next/renderer/frontend/class-qsm-render-pagination.php
+++ b/quiz-master-next/renderer/frontend/class-qsm-render-pagination.php
@@ -385,10 +385,21 @@
$categories_tree = ( isset( $categories_data['tree'] ) ? $categories_data['tree'] : array() );
if ( ! empty( $category_ids ) ) {
- $term_ids = implode( ',', $category_ids );
- $question_id_str = implode( ',', $question_ids );
- $term_ids = ( '' !== $this->quiz_options->randon_category ) ? $this->quiz_options->randon_category : $term_ids;
-
+ // Security (CVE-2026-15963): the randon_category quiz option is a
+ // user-controlled, comma-separated list of category (term) IDs. Category and
+ // question IDs are always integers, so cast every value to a positive int
+ // before it is interpolated into the raw SQL IN() lists below — otherwise a
+ // Contributor+/Custom+ user can inject SQL via randon_category.
+ $term_ids = implode( ',', array_filter( array_map( 'absint', $category_ids ) ) );
+ $question_id_str = implode( ',', array_filter( array_map( 'absint', $question_ids ) ) );
+ if ( '' !== $this->quiz_options->randon_category ) {
+ $term_ids = implode( ',', array_filter( array_map( 'absint', explode( ',', $this->quiz_options->randon_category ) ) ) );
+ }
+ // Guard against an empty IN() list (e.g. a non-numeric randon_category value),
+ // which would otherwise be a SQL syntax error; '0' safely matches no rows.
+ $term_ids = '' !== $term_ids ? $term_ids : '0';
+ $question_id_str = '' !== $question_id_str ? $question_id_str : '0';
+
$tq_ids = $wpdb->get_results(
"SELECT DISTINCT qt.term_id, qt.question_id
FROM {$wpdb->prefix}mlw_question_terms AS qt
--- a/quiz-master-next/renderer/templates/questions/polar.php
+++ b/quiz-master-next/renderer/templates/questions/polar.php
@@ -40,7 +40,7 @@
$slider_data_atts .= ' data-answer1=' . $answar1 . ' ';
$slider_data_atts .= ' data-answer2=' . $answar2 . ' ';
$slider_data_atts .= ' data-is_reverse=' . intval( $is_reverse ) . ' ';
-$slider_data_atts .= ' data-is_required=' . $required . ' ';
+$slider_data_atts .= ' data-is_required=' . intval( $required ) . ' ';
$mlw_require_class = 0 == $required ? 'mlwRequiredText mlwRequiredPolar' : '';