Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/hd-quiz/classes/dashboard.php
+++ b/hd-quiz/classes/dashboard.php
@@ -71,10 +71,11 @@
$html = '<div id="hdq_list_quizzes">';
if (!empty($quizzes) && !is_wp_error($quizzes)) {
foreach ($quizzes as $quiz) {
- // if author mode is active, only show quizzes belonging to current author (or admins access all)
+ // if author mode is active, only show quizzes belonging to current author
+ // (Editors/Admins with edit_others_posts can access all)
$quiz_type = sanitize_text_field(get_term_meta($quiz->term_id, "hdq_quiz_type", true));
$author_id = intval(get_term_meta($quiz->term_id, "hdq_author_id", true));
- if ($this->settings["allow_authors_access"] === "yes" && $author_id !== $user_id && !current_user_can('publish_posts')) {
+ if ($this->settings["allow_authors_access"] === "yes" && $author_id !== $user_id && !current_user_can('edit_others_posts')) {
continue;
}
--- a/hd-quiz/classes/question.php
+++ b/hd-quiz/classes/question.php
@@ -199,6 +199,21 @@
$question_type = sanitize_text_field($question_type);
$this->weighted = $weighted;
+ // only allow dispatch to registered question types
+ $allowed = array();
+ foreach ($this->question_types as $group) {
+ foreach ($group as $type) {
+ if (isset($type["value"])) {
+ $allowed[] = $type["value"];
+ }
+ }
+ }
+
+ if (!in_array($question_type, $allowed, true)) {
+ echo '{"status": "fail", "message": "No render found for this question type: ' . esc_attr($question_type) . '"}';
+ return;
+ }
+
if (method_exists($this, $question_type)) {
$this->$question_type($this->data["question_answers"]);
} else {
@@ -243,7 +258,9 @@
private function validateAccess()
{
- if (!current_user_can("publish_posts")) {
+ // Editors and Admins (edit_others_posts) may access all quizzes.
+ // Authors (publish_posts only) must own the quiz.
+ if (!current_user_can("edit_others_posts")) {
// must be author. make sure Ids match
$author_id = intval(get_term_meta($this->quiz_id, "hdq_author_id", true));
$user_id = get_current_user_id();
--- a/hd-quiz/classes/quiz.php
+++ b/hd-quiz/classes/quiz.php
@@ -561,7 +561,9 @@
private function validateAccess()
{
- if (!current_user_can("publish_posts")) {
+ // Editors and Admins (edit_others_posts) may access all quizzes.
+ // Authors (publish_posts only) must own the quiz.
+ if (!current_user_can("edit_others_posts")) {
// must be author. make sure Ids match
$author_id = intval(get_term_meta($this->quiz_id, "hdq_author_id", true));
$user_id = get_current_user_id();
--- a/hd-quiz/includes/actions-ajax.php
+++ b/hd-quiz/includes/actions-ajax.php
@@ -118,6 +118,8 @@
function hdq_get_question_type()
{
+ hdq_validate_nonce($_POST);
+
if (!hdq_user_permission()) {
die();
}
@@ -215,7 +217,8 @@
}
add_action("wp_ajax_hdq_csv_import_question", "hdq_csv_import_question");
-function hdq_get_correct_answers()
+// Server-side grading for "secure mode" quizzes
+function hdq_grade_questions()
{
if (!isset($_POST['nonce'])) {
wp_send_json_error(array("message" => "Missing nonce"));
@@ -232,8 +235,7 @@
$data = json_decode($data, true);
$quiz_id = intval($data["quiz_id"]);
- $question_ids = array_map("intval", $data["question_ids"]);
-
+ $answers = isset($data["answers"]) && is_array($data["answers"]) ? $data["answers"] : array();
$quiz = hdq_get_quiz($quiz_id);
if (!is_array($quiz) || empty($quiz['quiz_name'])) {
@@ -245,20 +247,168 @@
"status" => true,
"data" => array()
);
- foreach ($question_ids as $question_id) {
+ foreach ($answers as $entry) {
+ $question_id = intval($entry["question_id"]);
+ $user_answer = isset($entry["selected"]) ? $entry["selected"] : null;
+
$question = hdq_get_question($question_id, $quiz_id);
if (!is_array($question) || empty($question['question_type'])) {
echo json_encode(array("success" => false, "message" => "Question not found: ID " . $question_id));
die();
}
- $response["data"][] = array(
- "question_id" => $question_id,
- "correct_answers" => $question["question_answers"]
- );
+ $response["data"][] = hdq_grade_question($question_id, $question, $user_answer, $quiz);
}
echo json_encode($response);
die();
}
-add_action("wp_ajax_hdq_get_correct_answers", "hdq_get_correct_answers");
-add_action("wp_ajax_nopriv_hdq_get_correct_answers", "hdq_get_correct_answers");
+add_action("wp_ajax_hdq_grade_questions", "hdq_grade_questions");
+add_action("wp_ajax_nopriv_hdq_grade_questions", "hdq_grade_questions");
+
+// Grade a single question
+function hdq_grade_question($question_id, $question, $user_answer, $quiz)
+{
+ $type = $question["question_type"];
+ $question_answers = isset($question["question_answers"]) && is_array($question["question_answers"]) ? $question["question_answers"] : array();
+ $mark_questions = (isset($quiz["mark_questions"]) && $quiz["mark_questions"] === "yes");
+ $mark_answers = (isset($quiz["mark_answers"]) && $quiz["mark_answers"] === "yes");
+
+ $result = array(
+ "question_id" => $question_id,
+ "type" => $type,
+ "score" => 0,
+ "max" => 0,
+ "answered" => false,
+ "marks" => array(), // answer id => "correct" | "wrong" | "reveal"
+ );
+
+ // no answers to grade
+ if ($type === "question_as_title") {
+ $result["skip"] = true;
+ return $result;
+ }
+
+ // Figure out the "weight" of the answer
+ $value_of = function ($answer) {
+ if (isset($answer["selected"]) && $answer["selected"] === "yes") {
+ return 1;
+ }
+ return isset($answer["selected"]) ? intval($answer["selected"]) : 0;
+ };
+
+ // TODO: Question types should have their own validators in the future
+ // so that I can abstract this all out
+
+ if ($type === "text_based_answer") {
+ $user = is_string($user_answer) ? strtoupper(trim($user_answer)) : "";
+ $result["answered"] = ($user !== "");
+ $result["max"] = 1;
+
+ $accepted = array();
+ foreach ($question_answers as $a) {
+ $accepted[] = strtoupper(trim($a["value"]));
+ }
+
+ $correct = 0;
+ foreach ($accepted as $acc) {
+ if ($acc === "") {
+ continue;
+ }
+ if (substr($acc, -1) === "*") {
+ $stem = substr($acc, 0, -1);
+ if ($stem === $user || ($user !== "" && strpos($user, $stem) === 0)) {
+ $correct = 1;
+ }
+ }
+ if ($acc === $user) {
+ $correct = 1;
+ }
+ }
+ $result["score"] = $correct;
+
+ if ($mark_questions) {
+ $result["mark_text"] = ($correct === 1) ? "correct" : "wrong";
+ // only reveal an accepted answer when the owner enabled the reveal feature
+ if ($correct === 0 && $mark_answers && !empty($accepted)) {
+ $result["reveal_text"] = $accepted[0];
+ }
+ }
+ return $result;
+ }
+
+ // choice-based types: collect the user's selected answer ids
+ $selected_ids = array();
+ if (is_array($user_answer)) {
+ foreach ($user_answer as $sid) {
+ $selected_ids[] = intval($sid);
+ }
+ }
+ $result["answered"] = (count($selected_ids) > 0);
+
+ if ($type === "multiple_choice_text" || $type === "multiple_choice_image") {
+ $max = 0;
+ $score = 0;
+ foreach ($question_answers as $a) {
+ $val = $value_of($a);
+ if ($val > $max) {
+ $max = $val;
+ }
+ if (in_array(intval($a["id"]), $selected_ids, true)) {
+ $score = $val;
+ }
+ }
+ $result["max"] = $max;
+ $result["score"] = $score;
+
+ if ($mark_questions) {
+ foreach ($question_answers as $a) {
+ $id = intval($a["id"]);
+ $val = $value_of($a);
+ if (in_array($id, $selected_ids, true)) {
+ $result["marks"][$id] = ($val > 0) ? "correct" : "wrong";
+ } else if ($mark_answers && $val > 0) {
+ $result["marks"][$id] = "reveal";
+ }
+ }
+ }
+ return $result;
+ }
+
+ if ($type === "select_all_apply_text" || $type === "select_all_apply_image") {
+ $result["max"] = 1;
+ $score = 1;
+ foreach ($question_answers as $a) {
+ $val = $value_of($a);
+ $is_sel = in_array(intval($a["id"]), $selected_ids, true);
+ if ($val > 0 && !$is_sel) {
+ $score = 0; // missed a correct answer
+ }
+ if ($is_sel && $val == 0) {
+ $score = 0; // selected an incorrect answer
+ }
+ }
+ $result["score"] = $score;
+
+ if ($mark_questions) {
+ foreach ($question_answers as $a) {
+ $id = intval($a["id"]);
+ $val = $value_of($a);
+ $is_sel = in_array($id, $selected_ids, true);
+ if ($is_sel) {
+ if ($val == 0) {
+ $result["marks"][$id] = "wrong";
+ } else {
+ $result["marks"][$id] = ($score === 0) ? "reveal" : "correct";
+ }
+ } else if ($val > 0) {
+ $result["marks"][$id] = "reveal";
+ }
+ }
+ }
+ return $result;
+ }
+
+ // unknown question or personality question
+ $result["skip"] = true;
+ return $result;
+}
--- a/hd-quiz/includes/functions.php
+++ b/hd-quiz/includes/functions.php
@@ -37,6 +37,7 @@
$res->status = "fail";
$res->html = "Unable to validate your credentials. Your NONCE may have expired. Please reload this page from your WordPress admin to refresh your NONCE.";
echo json_encode($res);
+ die();
}
}
--- a/hd-quiz/index.php
+++ b/hd-quiz/index.php
@@ -5,7 +5,7 @@
* Plugin URI: https://harmonicdesign.ca/hd-quiz/
* Author: Harmonic Design
* Author URI: https://harmonicdesign.ca
- * Version: 2.2.1
+ * Version: 2.2.2
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: hd-quiz
@@ -23,7 +23,7 @@
die('Invalid request.');
}
if (!defined('HDQ_PLUGIN_VERSION')) {
- define('HDQ_PLUGIN_VERSION', '2.2.1');
+ define('HDQ_PLUGIN_VERSION', '2.2.2');
}
// Settings that a power user might want to change,