MDL Shield

Kahoodle

mod_kahoodle

Print Report
Plugin Information

Kahoodle is a real-time, multiplayer quiz activity for Moodle, similar to popular classroom quiz platforms. A facilitator runs a session (typically displayed on a projector) while participants join from their own devices via a QR code or activity link; all participants answer the same question simultaneously with points awarded for both correctness and speed.

Key features:

  • Round model with versioned questions so historical results stay consistent when questions are edited
  • Multiple identity modes: real name, optional alias, required alias, and fully anonymous (with guest support)
  • Per-question and per-round overrides for durations and point values
  • Stored avatars (admin-uploaded pool plus profile-picture import) with a candidate picker for participants
  • QR-code lobby, generated countdown/podium animation, leaderboard, and revision stage
  • Web services for AI-driven content creation (create_instance, add_questions)
  • Auto-archive scheduled task for stale rounds
  • Full backup/restore and Privacy API implementations

Real-time communication is delegated to the required tool_realtime plugin (HTTP polling or Centrifugo WebSocket subplugin).

Version:2026050500
Release:4.5.1
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-05-06
140 files·29,316 lines
Grade Justification

The plugin is well-architected and shows careful attention to Moodle security and coding conventions throughout.

Access control is consistent and thorough:

  • Every entry-point script uses require_login() (or require_course_login() for the index page) before accessing protected data
  • State-changing actions in view.php (start, finish, leave, newround) are gated by require_sesskey() plus capability checks
  • All seven web service classes validate context with external_api::validate_context() and call require_capability() for the appropriate capability
  • The kahoodle_pluginfile() callback applies different capability requirements per file area (question images, QR codes, avatars) and correctly restricts avatar fetching so a non-privileged participant can only retrieve their own avatar
  • The mod_kahoodle_realtime_event_received() callback splits actions into facilitator and participant tracks with appropriate guards on each

Data layer hygiene:

  • Every database query goes through $DB with named placeholders; no string concatenation of variables into SQL
  • Filesystem access stays within the File API (get_file_storage, make_pluginfile_url, file_save_draft_area_files); no direct writes to $CFG->dataroot/filedir
  • Profile-picture downloading uses download_file_content() and core\files\curl_security_helper to block disallowed URLs rather than raw curl_init / file_get_contents
  • DDL operations are confined to db/upgrade.php
  • SQL portability is handled correctly in backup/moodle2/backup_kahoodle_stepslib.php (MSSQL TOP vs other engines LIMIT)

Quality signals:

  • Comprehensive PHPUnit and Behat test suite
  • Privacy provider implements metadata, contextlist, userlist, export, and delete paths
  • Backup/restore correctly toggles user-data scope based on the userinfo setting
  • No third-party libraries bundled, so no thirdpartylibs.xml is required
  • Modern PHP (constructor promotion, match expressions, readonly properties) is used cleanly

The only observation worth raising is a low-severity template hygiene issue: multichoice answer-option text is rendered with unescaped Mustache ({{{text}}}) after only being run through clean_param(PARAM_TEXT), which strips tags but does not HTML-encode characters and does not invoke the multilang/auto-link filters. The exploitation surface is essentially nil because PARAM_TEXT removes any tag that could carry an event handler, but the value should still pass through format_string() for full Moodle-style filtering. No high or critical issues were found.

AI Summary

Kahoodle is a substantial real-time quiz module that integrates well with Moodle conventions: capability-checked entry points, parameterised SQL throughout, File API and curl-wrapper usage instead of low-level alternatives, full Privacy/Backup/Restore implementations, and a thorough automated test suite. Identity-mode handling (real-name → optional → required-alias → anonymous) is thoughtful, with the anonymous mode using a session-tied participant code rather than userid to preserve anonymity. Avatar serving via pluginfile correctly restricts non-privileged users to their own avatar.

Only one minor finding emerged from the review: multichoice option text reaches the templates only clean_param(PARAM_TEXT)-cleaned and is then rendered with the unescaped Mustache form {{{text}}}. PARAM_TEXT strips dangerous tags so this is not an XSS vector, but it bypasses the multilang/auto-link filter chain that format_string() provides and lets characters such as bare & render as malformed HTML. Switching to format_string() (or {{text}}) would close the gap.

Findings

best practiceLow
Multichoice option text rendered unescaped without format_string filtering

The multichoice question type stores answer-option text after running it through clean_param($o['text'], PARAM_TEXT) in multichoice::sanitize_question_config_data() and later renders it in Mustache templates with the triple-mustache, unescaped form {{{text}}}.

PARAM_TEXT in Moodle strips most HTML tags but does not HTML-encode characters (raw &, lone </> survive) and does not run the multilang/auto-link filter chain. The recommended Moodle pattern is to either pass the value through format_string() before handing it to the template (and keep {{{text}}} so the formatter's output stays unescaped), or to use the auto-escaped {{text}} form.

The practical impact is limited:

  • Tag-based XSS is blocked because PARAM_TEXT removes complete tags such as <script> or <img onerror=...>
  • Multilang <span class="multilang" lang="…">…</span> blocks survive PARAM_TEXT but do not get filtered, so all language variants render at once instead of the user's current language
  • A teacher-typed bare & in the option text becomes literal & in the output, producing technically malformed HTML that browsers tolerate but is non-conformant

The same pattern appears in templates/questiontypes/multichoice/facilitator_question.mustache and templates/questiontypes/multichoice/facilitator_results.mustache.

Risk Assessment

Low risk.

A practical XSS exploit is not possible: PARAM_TEXT strips the only constructs that could deliver script execution (event handlers, <script>, <iframe>, <svg onload=…>, etc.). What survives the cleaner is essentially plain text plus the two restricted multilang tag forms.

The real impact is limited to:

  • Multilang content not being filtered, so all languages display at once for every viewer
  • Bare special characters such as & rendering as malformed (but browser-tolerant) HTML

This is a code-quality / Moodle-conventions issue rather than a security flaw. Exploitation requires the elevated mod/kahoodle:manage_questions capability, the blast radius is limited to that activity's question display, and there is no path to escalate to other users' data or sessions.

Context

Data flow.

  1. A user with mod/kahoodle:manage_questions enters answer options in the question form (mod_kahoodle\form\question).
  2. mod_kahoodle\local\game\questions::add_question() / edit_question() calls the question type's sanitize_data() which delegates to multichoice::sanitize_question_config_data() (classes/local/questiontypes/multichoice.php). That method runs each option line through clean_param($o['text'], PARAM_TEXT) and stores the result inside the questionconfig text field of kahoodle_question_versions.
  3. At render time, multichoice::get_answers_options() parses the stored config back into ['text' => …, 'iscorrect' => …] rows.
  4. multichoice::export_template_data() and export_template_data_participant() build the template context including the raw text. The context is JSON-encoded into typedata and passed to Mustache.
  5. The templates render {{{text}}} — the unescaped form — which writes the value verbatim into the rendered HTML.

Surrounding controls.

  • Questions are created/edited only by users with mod/kahoodle:manage_questions, which the access definitions grant to editingteacher and manager roles by default.
  • The same option text is also referenced by the report builder formatter (questions::format_response() in classes/local/questiontypes/multichoice.php), but that path uses format_string(), so it is not affected by this finding.
Identified Code
<span class="mod_kahoodle-option-text">{{{text}}}</span>
Suggested Fix

Either auto-escape the value at render time:

<span class="mod_kahoodle-option-text">{{text}}</span>

or (preferred, to keep multilang/auto-link filters working) apply format_string() to the option text in multichoice::export_template_data() before it reaches the template:

$option = [
    'optionnumber' => $index + 1,
    'letter' => $letters[$index] ?? (string)($index + 1),
    'text' => format_string($answer['text'], true, ['context' => $roundquestion->guess_context()]),
];

The template can then keep the {{{text}}} form, since format_string() already returns safe HTML.

Identified Code
<span class="mod_kahoodle-option-text">{{{text}}}</span>
Suggested Fix

Apply the same fix as in facilitator_question.mustache (either switch to {{text}} or pre-format with format_string() in multichoice::export_template_data()).

Identified Code
foreach ($answers as $index => $answer) {
    $option = [
        'optionnumber' => $index + 1,
        'letter' => $letters[$index] ?? (string)($index + 1),
        'text' => $answer['text'],
    ];
Suggested Fix

Run the option text through format_string() (which applies the multilang and other filters and returns properly-escaped HTML) before adding it to the template context:

$context = $roundquestion->guess_context();
foreach ($answers as $index => $answer) {
    $option = [
        'optionnumber' => $index + 1,
        'letter' => $letters[$index] ?? (string)($index + 1),
        'text' => format_string($answer['text'], true, ['context' => $context]),
    ];
Additional AI Notes

Strong privacy implementation. classes/privacy/provider.php covers the metadata, core_userlist_provider, and plugin_provider interfaces; delete_data_for_user, delete_data_for_users, and delete_data_for_all_users_in_context all clean both kahoodle_responses/kahoodle_participants rows and the participant's mod_kahoodle/avatar files. The metadata correctly declares the core_files link.

Multi-database awareness. backup/moodle2/backup_kahoodle_stepslib.php switches between TOP 1 (MSSQL) and LIMIT 1 (others) to support every engine Moodle ships with — a common oversight in third-party plugins that this one handles correctly.

Anonymous-mode design. In IDENTITYMODE_ANONYMOUS, participants::generate_participant_code() derives the participant identifier from md5($USER->id . sesskey() . $roundid) and stores it in the unique (roundid, participantcode) index. MD5 is used here as a non-cryptographic identifier (uniqueness only); session expiry consequently invalidates the participant binding by design, which is documented in the inline comment.

Realtime channel boundary. The plugin implements mod_kahoodle_realtime_event_received as the single server entry-point for the realtime channel; facilitator and participant actions are split, with participant actions guarded by $round->is_participant() (capability + DB membership check). The plugin trusts tool_realtime (a hard dependency) to enforce subscription authorization on its end — that boundary is therefore worth keeping in mind during any future refactor.

No bundled third-party libraries. A thirdpartylibs.xml file is not required: the AMD build/ directory contains only Moodle Grunt-compiled outputs of the plugin's own src/ modules, and the pix/, templates/, lang/, and backup/ directories contain no vendor code.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.