MDL Shield

Kahoodle

mod_kahoodle

Print Report
Plugin Information

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.

Version:2026041800
Release:4.5.0
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-04-19
140 files·29,316 lines
Grade Justification

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).

AI Summary

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), the lib.php realtime callback, the inplace_editable callback, and every top-level PHP entry point calls validate_context() (or require_login() for pages) and then require_capability() or has_capability() against the correct module context before performing privileged operations.
  • CSRF: view.php uses require_sesskey() for all state-changing actions (start, finish, leave, newround). Web services and dynamic_form handle sesskey automatically.
  • SQL: All queries use $DB placeholders or Moodle's get_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 through format_text() with the correct context and filearea.
  • File handling: Uses file_storage / stored_file / send_stored_file / make_pluginfile_url throughout. kahoodle_pluginfile enforces per-filearea capability checks, including the clever participant-owns-avatar check for FILEAREA_AVATAR.
  • SSRF mitigation: In save_profile_picture_to_avatar, the URL comes from core_user::get_profile_picture() (gravatar or local) and is explicitly checked with curl_security_helper::url_is_blocked() before download_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):

  1. A hand-written LIMIT 1 in classes/local/game/questions.php:540 — MSSQL doesn't support LIMIT, so this should use get_record_sql with the $limitnum parameter.
  2. The create_instance external function accepts identitymode as PARAM_INT without 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_versions with islast flag — 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

code qualityLow
Non-portable `LIMIT 1` SQL literal in `questions.php`

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.

Risk Assessment

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.

Context

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.

Identified Code
$newlastversionid = $DB->get_field_sql(
    'SELECT id FROM {kahoodle_question_versions} WHERE questionid = ? ORDER BY version DESC LIMIT 1',
    [$questionid]
);
Suggested Fix

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.

best practiceLow
Web service `create_instance` accepts any integer for `identitymode`

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.

Risk Assessment

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.

Context

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.

Identified Code
'identitymode' => new external_value(
    PARAM_INT,
    'Identity mode (0 = real name, 1 = optional alias, 2 = required alias, 3 = fully anonymous)',
    VALUE_OPTIONAL
),
Suggested Fix

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.

Additional AI Notes

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.

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