Kahoodle
mod_kahoodle
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).
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()(orrequire_course_login()for the index page) before accessing protected data - State-changing actions in
view.php(start,finish,leave,newround) are gated byrequire_sesskey()plus capability checks - All seven web service classes validate context with
external_api::validate_context()and callrequire_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
$DBwith 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()andcore\files\curl_security_helperto block disallowed URLs rather than rawcurl_init/file_get_contents - DDL operations are confined to
db/upgrade.php - SQL portability is handled correctly in
backup/moodle2/backup_kahoodle_stepslib.php(MSSQLTOPvs other enginesLIMIT)
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
userinfosetting - No third-party libraries bundled, so no
thirdpartylibs.xmlis 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.
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
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.
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.
Data flow.
- A user with
mod/kahoodle:manage_questionsenters answer options in the question form (mod_kahoodle\form\question). mod_kahoodle\local\game\questions::add_question()/edit_question()calls the question type'ssanitize_data()which delegates tomultichoice::sanitize_question_config_data()(classes/local/questiontypes/multichoice.php). That method runs each option line throughclean_param($o['text'], PARAM_TEXT)and stores the result inside thequestionconfigtext field ofkahoodle_question_versions.- At render time,
multichoice::get_answers_options()parses the stored config back into['text' => …, 'iscorrect' => …]rows. multichoice::export_template_data()andexport_template_data_participant()build the template context including the rawtext. The context is JSON-encoded intotypedataand passed to Mustache.- 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 toeditingteacherandmanagerroles by default. - The same option text is also referenced by the report builder formatter (
questions::format_response()inclasses/local/questiontypes/multichoice.php), but that path usesformat_string(), so it is not affected by this finding.
<span class="mod_kahoodle-option-text">{{{text}}}</span>
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.
<span class="mod_kahoodle-option-text">{{{text}}}</span>
Apply the same fix as in facilitator_question.mustache (either switch to {{text}} or pre-format with format_string() in multichoice::export_template_data()).
foreach ($answers as $index => $answer) {
$option = [
'optionnumber' => $index + 1,
'letter' => $letters[$index] ?? (string)($index + 1),
'text' => $answer['text'],
];
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]),
];
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.