Kahoodle
mod_kahoodle
Kahoodle is a Moodle activity module that provides a real-time, game-like quiz experience similar to popular classroom quiz platforms. A facilitator displays questions on a shared screen while participants answer simultaneously on their own devices, with live leaderboards, point scoring based on response speed, multiple rounds, question versioning, and configurable identity modes (real name, optional alias, required alias, fully anonymous).
The plugin depends on tool_realtime for WebSocket/polling based real-time communication and optionally integrates with tool_kahoodleplus for extended reports. Version 4.5.0 supports Moodle 4.5 through 5.2.
This plugin demonstrates a high level of code quality and adherence to Moodle development standards. Access control is consistent and defence-in-depth: every entry point validates context and checks capabilities, state-changing requests use require_sesskey() or the web service/dynamic-form framework's built-in CSRF handling, and the real-time callback in lib.php re-checks context plus role before dispatching each action. All database access goes through the $DB API with placeholders — no raw concatenation, no direct PDO/mysqli, no ad-hoc filesystem writes outside the File API. Inputs are cleaned with clean_param and Moodle form types. The plugin ships a full Privacy API implementation (contexts, users, export, delete-all, delete-for-user), proper backup/restore with user-data gating, Report Builder integration, unit tests, event classes, language strings, and capability definitions with appropriate riskbitmask values. The SSRF concern on the profile-picture download is properly mitigated via core\files\curl_security_helper::url_is_blocked(). Only minor issues were found: a single non-portable LIMIT 1 SQL literal in classes/local/game/questions.php, and a web service parameter that accepts any integer for identitymode rather than validating against the enumerated values. Neither has a security impact. The remaining observations are informational (code conventions and defence-in-depth suggestions).
Overview
mod_kahoodle is a well-engineered real-time quiz activity module, comparable in scope and quality to mature core Moodle modules. The code is cleanly factored into entities (round, round_question, round_stage, participant, rank, statistics), game orchestration (progress, participants, questions, responses, realtime_channels), question types (extensible via questiontypes/base.php), output/renderers, Report Builder entities and system reports, dynamic forms, events, backup/restore, privacy provider, and an ad-hoc task for auto-archiving stalled rounds.
Security posture
Strong areas:
- Capability/context checks: Every web service (
classes/external/*.php), thelib.phprealtime callback, theinplace_editablecallback, and every top-level PHP entry point callsvalidate_context()(orrequire_login()for pages) and thenrequire_capability()orhas_capability()against the correct module context before performing privileged operations. - CSRF:
view.phpusesrequire_sesskey()for all state-changing actions (start,finish,leave,newround). Web services anddynamic_formhandle sesskey automatically. - SQL: All queries use
$DBplaceholders or Moodle'sget_in_or_equal. No concatenation of user input into SQL was found. - Output escaping: Consistent pattern of pre-escaping in PHP (
s(),format_string(),format_text()) combined with{{{...}}}in Mustache — this is a legitimate Moodle convention that avoids double-escaping. Question text goes throughformat_text()with the correct context and filearea. - File handling: Uses
file_storage/stored_file/send_stored_file/make_pluginfile_urlthroughout.kahoodle_pluginfileenforces per-filearea capability checks, including the clever participant-owns-avatar check forFILEAREA_AVATAR. - SSRF mitigation: In
save_profile_picture_to_avatar, the URL comes fromcore_user::get_profile_picture()(gravatar or local) and is explicitly checked withcurl_security_helper::url_is_blocked()beforedownload_file_content(). - Anonymous mode:
md5($USER->id . sesskey() . $roundid)is a sensible per-session participant token — an attacker cannot forge another user's code without that user's sesskey. - Guest access: Correctly gated on capability + identity mode +
tool_realtime::allow_guests(). - Privacy API: Full implementation including
get_contexts_for_userid,get_users_in_context, export, delete-all, delete-for-user(s), and avatar-file cleanup on deletion.
Minor code-quality findings (no security impact):
- A hand-written
LIMIT 1inclasses/local/game/questions.php:540— MSSQL doesn't supportLIMIT, so this should useget_record_sqlwith the$limitnumparameter. - The
create_instanceexternal function acceptsidentitymodeasPARAM_INTwithout restricting it to the four enumerated values. An out-of-range value would be persisted but would fall through to realname-like behaviour in identity comparisons.
Architecture highlights
- Question versioning:
kahoodle_question_versionswithislastflag — previous rounds keep their exact question snapshot. - Auto-archive task: Rounds left in progress for more than
MAX_ROUND_DURATION(3h) or stuck in revision stage are automatically archived via an adhoc task scheduled on each stage transition. - Real-time channels: Separate facilitator/game/participant channels with appropriate granularity for rank-reveal animations.
- Identity modes: Four modes (realname/optional/alias/anonymous) with correct UI wiring in the join form, report builder column filtering, and event triggering (events are suppressed for anonymous participants to preserve anonymity).
Findings
classes/local/game/questions.php embeds LIMIT 1 directly inside a SQL string. Moodle supports multiple database engines (MySQL/MariaDB, PostgreSQL, and MSSQL). MSSQL does not support LIMIT — it uses TOP N — so this query would fail on SQL Server installations.
The canonical Moodle approach is to pass the limit through the $limitfrom / $limitnum parameters of $DB->get_record_sql() / $DB->get_records_sql(), which emits the correct per-engine syntax. The same file's backup stepslib already branches between TOP and LIMIT correctly — this one call was missed.
This is a portability/code-quality issue, not a security issue.
Low risk. Not a security issue. The plugin lists [405, 502] in $plugin->supported and the Moodle project officially supports MSSQL, so the portability contract applies. The practical impact is that teachers running Kahoodle on an MSSQL-backed Moodle would hit a SQL error when deleting a question that has multiple versions — a functional bug rather than a vulnerability. MySQL and PostgreSQL installations are unaffected.
This line is inside delete_question(). When the last version of a question is removed and the question has other versions left, the code queries for the highest remaining version ID to flag it as islast = 1. The query only runs in the delete path.
$newlastversionid = $DB->get_field_sql(
'SELECT id FROM {kahoodle_question_versions} WHERE questionid = ? ORDER BY version DESC LIMIT 1',
[$questionid]
);
Use get_record_sql with $limitnum, or get_records_sql_menu with limit, so the cross-DB layer emits the right dialect:
$newlastversion = $DB->get_record_sql(
'SELECT id FROM {kahoodle_question_versions} WHERE questionid = ? ORDER BY version DESC',
[$questionid],
0,
1
);
$newlastversionid = $newlastversion ? $newlastversion->id : null;
Or, because version has a unique index with questionid, use MAX(version) in a subquery for cleaner portability.
The mod_kahoodle_create_instance external function declares identitymode as PARAM_INT with no range validation. The valid values are defined in constants::IDENTITYMODE_REALNAME (0) through constants::IDENTITYMODE_ANONYMOUS (3), and the mod_form.php select element enforces this range in the regular UI. The web service does not — a caller with mod/kahoodle:addinstance can pass any integer (negative, zero, 42, PHP_INT_MAX, etc.) and it will be persisted to kahoodle.identitymode.
Downstream comparisons like $identitymode === constants::IDENTITYMODE_ANONYMOUS all fall through to realname-like behaviour for unknown values, so the practical effect is that some configurations become unreachable via the UI but storable via API. It is a data-integrity / defence-in-depth gap rather than an exploit.
Low risk. The attacker needs editingteacher+ privileges, and the impact is confined to their own activity — no cross-user effect, no privilege escalation, no disclosure. The bad state is also observable and recoverable: a teacher can edit the activity in the UI, which will force identitymode back into the valid range via the select element. Reported as a code-quality/defence-in-depth finding to encourage explicit range checks at the API boundary.
The external function is guarded by mod/kahoodle:addinstance at the course level (archetypes: editingteacher, manager). Reaching this code path requires a role that can already add activities to the course, which is a high-trust role. The function creates a course module via add_moduleinfo.
'identitymode' => new external_value(
PARAM_INT,
'Identity mode (0 = real name, 1 = optional alias, 2 = required alias, 3 = fully anonymous)',
VALUE_OPTIONAL
),
Validate the value explicitly before assigning it, e.g. in execute() after parameter validation:
$validmodes = [
constants::IDENTITYMODE_REALNAME,
constants::IDENTITYMODE_OPTIONAL,
constants::IDENTITYMODE_ALIAS,
constants::IDENTITYMODE_ANONYMOUS,
];
if (!in_array($params['identitymode'], $validmodes, true)) {
throw new \invalid_parameter_exception('Invalid identitymode');
}
Apply the same validation to questionformat (valid values 0 and 1) for consistency.
Consistent output-escaping pattern. The plugin uses the pattern of pre-escaping values in PHP (s(), format_string(), format_text()) and then emitting them with {{{...}}} in Mustache. This is a legitimate Moodle convention that avoids double-escaping — it is not a bug, and no XSS vectors were found. Note however that the pattern relies on every producer pre-escaping; a future refactor that changes a getter (e.g. participant::get_display_name()) to return unescaped text would silently introduce XSS in the templates that render it. If the team later prefers an even more defensive posture, switching these occurrences to {{...}} and removing the s() calls would make the templates self-contained.
SSRF is already mitigated. classes/local/game/participants.php::save_profile_picture_to_avatar() calls download_file_content() on a URL returned by core_user::get_profile_picture(). The URL is not user-controlled (Moodle produces it) and is additionally checked with core\files\curl_security_helper::url_is_blocked() before the download. No change needed; noting it here for reviewer awareness.
Anonymous-mode participant identification. The md5($USER->id . sesskey() . $roundid) scheme correctly ties anonymous participants to a session without exposing the real identity. One consequence to be aware of (the code is correct, this is a design property): if a user's session ends mid-game, their next sesskey produces a different participantcode, and they will be treated as a new participant on reconnect. This is documented behaviour and guarded by the round's is_participant() cache for the current request.
Test coverage. The plugin ships an extensive PHPUnit suite under tests/ covering events, external web services, entities, game logic, question types, outputs, privacy provider, report builder, and the auto-archive task, plus behat generator scaffolding. This is well above the minimum expected for a Moodle activity module.
Auto-archive adhoc task. mod_kahoodle\task\auto_archive_round is scheduled each time a round enters lobby or revision and runs at timestarted + MAX_ROUND_DURATION (3 hours) or stagestarttime + MAX_REVISION_DURATION (15 minutes), whichever is earlier. The task correctly handles the race where the round was already finished or deleted manually before the task fires.