MDL Shield

Kahoodle

mod_kahoodle

Print Report
Plugin Information

Kahoodle is a real-time, game-style quiz activity for Moodle in which all participants answer the same questions simultaneously. The facilitator drives the game (lobby → question preview → question → results → leaderboard → revision) using realtime channels provided by the `tool_realtime` plugin (a hard dependency). Players join via the activity page or a generated QR code, optionally choose a display name and avatar (with `realname`, `optional`, `alias`, and `anonymous` identity modes), score points based on correctness and response time, and see live leaderboards and a podium animation at the end. Question content is versioned so that a question can be edited without altering the historical record of completed rounds. Web services exist for AI-assisted content generation (create instance, add questions, etc.) and for the JavaScript front-end (sortorder, delete, duplicate, preview, playback). The plugin ships with a Privacy API provider, backup/restore, system reports for participants and questions, an ad-hoc auto-archive task, and a single built-in question type (`multichoice`).

Version:2026030600
Release:1.1.0
Reviewed for:5.1
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-04-17
140 files·29,285 lines
Grade Justification

The plugin demonstrates a high overall standard of Moodle engineering. Capability checks, validate_context calls, sesskey enforcement on state-changing GET handlers, parameterised SQL throughout, use of the File API for stored content, clean_text/clean_param/format_string at output and input boundaries, a complete Privacy API implementation, backup/restore, report builder integration, and curl_security_helper checks on the avatar download path are all in place.

The most significant finding is a single medium-severity privilege escalation in the mod_kahoodle_duplicate_question web service, which validates capability only on the source round's context and never on the target round, allowing a teacher in one course to inject questions into a kahoodle round in another course (constrained to rounds in the preparation stage and to question content that has been HTML-cleaned). The remaining issues are code-quality observations: non-portable LIMIT 1 SQL in the backup step, one hardcoded English string in a template, and the manage_questions capability using a noclean editor without declaring RISK_XSS. None of these is exploitable by an unauthenticated or low-privileged user against other users.

AI Summary

mod_kahoodle 1.1.0 is a Kahoot-style real-time quiz activity for Moodle 4.5 / 5.0 / 5.1, depending on the tool_realtime plugin for messaging. The codebase is modern, namespaced, well-structured, and follows Moodle conventions throughout.

Strengths

  • Consistent use of require_login() / require_course_login(), require_sesskey() on state-changing GET handlers (view.php start/finish/leave/newround), and require_capability on every privileged operation.
  • All web service classes call external_api::validate_context() and require_capability() before mutating state.
  • 100% parameterised database access via $DB; no string concatenation of user input into SQL.
  • File handling uses the File API (file_storage, stored_file, make_pluginfile_url, send_stored_file); QR codes are generated through core_qrcode and served via send_file.
  • The avatar download from a profile-picture URL is filtered through core\files\curl_security_helper to block SSRF, and the rest of the request goes through Moodle's download_file_content() wrapper instead of raw cURL.
  • Privacy API: full provider with metadata, contextlist, userlist, export, delete-for-context, delete-for-user, and delete-for-users implementations; even handles avatar files.
  • Backup/restore with both userinfo=1 and userinfo=0 paths, file annotations, and round-state reset on restore-without-userinfo.
  • Output classes use format_string / format_text with explicit context, and templates use s()-prepared values with triple-stash {{{ }}} to avoid double-escaping.
  • Anonymous mode is implemented carefully with a deterministic participantcode = md5(USER->id . sesskey() . roundid) so a session can be resumed but is not predictable from the outside.
  • Comprehensive test suite (PHPUnit + Behat) under tests/.

Issues found

  • Medium (security): mod_kahoodle_duplicate_question validates the source round's context only, allowing cross-course question injection into any kahoodle round in preparation stage by a teacher who has manage_questions in some course.
  • Low (code quality): Backup set_source_sql uses raw LIMIT 1, which is not portable to Oracle/MSSQL.
  • Low (compliance): A hardcoded Score label in templates/participant/common/participantinfo.mustache.
  • Low (best practice): mod/kahoodle:manage_questions uses an editor with noclean => true and writes back into a context where it is then cleaned by clean_text, but the capability is not flagged with RISK_XSS in db/access.php.

No SQL injection, no XSS reachable by participants, no direct database/filesystem/HTTP API misuse, and no exposed unauthenticated state-changing endpoints were identified.

Findings

securityMedium
duplicate_question web service does not validate target round's context
Exploitable by:
teachereditingteachermanager

The mod_kahoodle_duplicate_question external function accepts both a roundquestionid and an optional targetroundid. It validates validate_context() and require_capability('mod/kahoodle:manage_questions', ...) on the source round's context only. The target round, when supplied, is loaded with round::create_from_id($targetroundid) and passed straight through to \mod_kahoodle\local\game\questions::duplicate_question() without any further capability or context check.

A user who has mod/kahoodle:manage_questions in any course can therefore call this web service with a roundquestionid from their own course and a targetroundid from a kahoodle activity in another course where they have no permissions, provided the target round is still in the preparation stage. The result is a new kahoodle_questions record plus a kahoodle_round_questions row inside the target round.

This is a cross-course privilege escalation: a teacher can spam other teachers' rounds with arbitrary questions. Because question text is HTML-cleaned via clean_text before storage, the issue cannot be pivoted into XSS, but it does break the trust boundary between courses and pollutes another course's content.

A secondary integrity bug in questions::duplicate_question() makes the impact more visible: the new kahoodle_questions record copies kahoodleid = $data->kahoodleid from the source question, so the inserted row references the wrong kahoodle activity and shows up as orphaned data when reports query kahoodle_questions.kahoodleid.

Risk Assessment

Medium risk. The attacker must already be an authenticated teacher (with manage_questions) somewhere on the site, and the target round must be in the preparation stage. The injected content is HTML-cleaned via clean_text before storage, so XSS cannot be pivoted from this. However, this is a real cross-tenant boundary violation: another course's content can be modified, leading to spam, confusion, and orphaned kahoodle_questions records (because of the kahoodleid mismatch bug). Detection by the victim is likely (a strange new question simply appears in their round), but cleanup is required and trust between courses is broken.

Context

The web service is registered as 'ajax' => true in db/services.php with no capabilities declared (capability enforcement is left to the class, which is fine in itself). Inside questions::duplicate_question(), the only target-round check is is_fully_editable(), which is a state check, not a permission check. The frontend (amd/src/questions.js) only ever uses this to copy across rounds within the same kahoodle, so under normal use the bug is invisible — but the API is callable directly.

Proof of Concept
  1. Authenticate as a teacher in course A who has mod/kahoodle:manage_questions on a kahoodle activity K1.
  2. From a different course B, identify a kahoodle activity K2 that has a round in the preparation stage — e.g. by browsing or guessing the round id (or by knowing it from a prior interaction).
  3. From the JavaScript console on any page where the user is logged in, call:
require(['core/ajax'], function(Ajax) {
    Ajax.call([{
        methodname: 'mod_kahoodle_duplicate_question',
        args: {
            roundquestionid: <my_roundquestionid_in_K1>,
            targetroundid: <preparation_roundid_in_K2>
        }
    }])[0].then(r => console.log(r));
});

The call succeeds and a new question appears in the target round inside course B's kahoodle, even though the caller has no role in course B.

Affected Code
public static function execute(int $roundquestionid, int $targetroundid = 0): array {
    // Parameter validation.
    ['roundquestionid' => $roundquestionid, 'targetroundid' => $targetroundid] = self::validate_parameters(
        self::execute_parameters(),
        ['roundquestionid' => $roundquestionid, 'targetroundid' => $targetroundid]
    );

    $roundquestion = round_question::create_from_round_question_id($roundquestionid);

    // Validate context and capability.
    $context = $roundquestion->get_round()->get_context();
    self::validate_context($context);
    require_capability('mod/kahoodle:manage_questions', $context);

    // Resolve target round if specified.
    $targetround = null;
    if ($targetroundid) {
        $targetround = round::create_from_id($targetroundid);
    }

    // Duplicate the question.
    $newroundquestion = questions::duplicate_question($roundquestion, $targetround);

    return ['roundquestionid' => $newroundquestion->get_id()];
}
Suggested Fix

When a targetroundid is supplied, validate the target context as well and require manage_questions there. For example:

$targetround = null;
if ($targetroundid) {
    $targetround = round::create_from_id($targetroundid);
    $targetcontext = $targetround->get_context();
    self::validate_context($targetcontext);
    require_capability('mod/kahoodle:manage_questions', $targetcontext);
}

When source and target belong to different kahoodle activities, the operation should arguably be rejected outright (it is a data-integrity error), since kahoodle_questions.kahoodleid is then guaranteed to mismatch the round's activity.

Affected Code
public static function duplicate_question(round_question $roundquestion, ?round $targetround = null): round_question {
    global $DB;

    $round = $targetround ?? $roundquestion->get_round();
    if (!$round->is_fully_editable()) {
        throw new \moodle_exception('noeditableround', 'mod_kahoodle');
    }

    $data = $roundquestion->get_data();
    $time = time();

    // Create a new question record.
    $question = new \stdClass();
    $question->kahoodleid = $data->kahoodleid;
    $question->questiontype = $data->questiontype;
Suggested Fix

Set $question->kahoodleid = $round->get_kahoodleid(); so the new question record always belongs to the kahoodle that owns the target round. Combined with the capability fix above, also assert that $data->kahoodleid === $round->get_kahoodleid() and refuse the operation otherwise (or implement a deliberate cross-activity copy with file area handling).

code qualityLow
Non-portable `LIMIT 1` in backup `set_source_sql`

The backup step uses raw SQL with a LIMIT 1 clause inside set_source_sql() for the round source and inside a correlated sub-query for the question source. LIMIT is MySQL/PostgreSQL/SQLite syntax and is not portable to Oracle (ROWNUM/OFFSET..FETCH) or MSSQL (TOP). Moodle officially supports all four engines; the backup framework calls $DB->get_recordset_sql() directly on this string without applying any $limitnum translation, so the SQL will fail at backup time on Oracle and MSSQL.

This only affects the userinfo=0 (no user data) backup path — the userinfo=1 path uses set_source_table() which is portable.

Risk Assessment

Low risk. Pure portability/code-quality issue. No security impact; the failure is loud (backup process raises a SQL error on incompatible engines).

Context

Activated by if ($userinfo) / else in define_structure(). Backup is normally invoked by admins or teachers via the standard Moodle backup UI; the failure mode is a backup error on Oracle/MSSQL deployments rather than corrupted data.

Identified Code
$question->set_source_sql(
    "SELECT DISTINCT q.*
       FROM {kahoodle_questions} q
       JOIN {kahoodle_question_versions} qv ON qv.questionid = q.id
       JOIN {kahoodle_round_questions} rq ON rq.questionversionid = qv.id
       JOIN {kahoodle_rounds} r ON r.id = rq.roundid
      WHERE q.kahoodleid = ?
        AND r.id = (
            SELECT r2.id FROM {kahoodle_rounds} r2
             WHERE r2.kahoodleid = q.kahoodleid
             ORDER BY CASE WHEN r2.currentstage = 'preparation' THEN 0 ELSE 1 END,
                      r2.timecreated DESC, r2.id DESC
             LIMIT 1
        )
      ORDER BY q.id ASC",
    [backup::VAR_PARENTID]
);
Suggested Fix

Replace the correlated LIMIT 1 sub-query with an exists/join pattern that does not require row-limiting in the SQL, for example by selecting the chosen round id beforehand into a backup setting (->set_source_array() or computed in PHP) and substituting it via ? parameters. Alternatively use $DB->get_records_sql($sql, $params, 0, 1) outside the backup step to obtain the target round id and then use set_source_table() / set_source_sql() without LIMIT.

Identified Code
// Only the last round.
$round->set_source_sql(
    "SELECT r.*
       FROM {kahoodle_rounds} r
      WHERE r.kahoodleid = ?
      ORDER BY CASE WHEN r.currentstage = 'preparation' THEN 0 ELSE 1 END,
               r.timecreated DESC, r.id DESC
      LIMIT 1",
    [backup::VAR_PARENTID]
);
Suggested Fix

Same pattern: avoid LIMIT 1 in raw backup SQL. Pre-compute the round id outside the backup framework and supply it as a parameter, or filter by a flag column added to mark “this is the most recent round”.

complianceLow
Hardcoded English `Score` label in mustache template

The participant info partial renders a hardcoded English label Score instead of resolving it through the language pack. The plugin already defines $string['score'] = 'Score' in lang/en/kahoodle.php, so the fix is mechanical, but as it stands the label is not translatable into other languages.

Risk Assessment

Low risk. Pure i18n / coding-standard regression. No security impact.

Context

The label is shown in the bottom bar of every participant screen during gameplay. Visible to anyone who joins a round.

Identified Code
<div class="mod_kahoodle-participant-result-total">
    <span class="mod_kahoodle-participant-result-total-label">Score</span>
    <span class="mod_kahoodle-participant-result-total-value">{{{totalscore}}}</span>
</div>
Suggested Fix

Use the existing language string:

<span class="mod_kahoodle-participant-result-total-label">{{#str}}score, mod_kahoodle{{/str}}</span>
best practiceLow
`mod/kahoodle:manage_questions` does not declare RISK_XSS despite `noclean => true` editor

The question editing form (classes/form/question.php) registers the rich-text question editor with 'noclean' => true. The plugin then sanitises the value at the API layer in \mod_kahoodle\local\questiontypes\base::sanitize_data() via clean_text(... FORMAT_HTML) before storing it, so in practice raw HTML cannot be persisted. Nevertheless, the capability that grants editing rights, mod/kahoodle:manage_questions, only declares RISK_SPAM | RISK_DATALOSS in db/access.php. By Moodle convention, capabilities that allow users to author HTML rendered to other users (whether or not server-side cleaning is in place) should also flag RISK_XSS, so that administrators can see the risk surface in the role definition UI and treat the capability accordingly when defining custom roles.

Risk Assessment

Low risk. Server-side cleaning means a real XSS is not exploitable today, but the missing flag is a coding-standard / compliance gap that affects how administrators reason about role permissions.

Context

Question text is stored in kahoodle_question_versions.questiontext and rendered to participants via format_text(... FORMAT_HTML). Because all storage paths go through sanitize_data()clean_text(), raw <script> injection is rejected. The risk flag is mostly an administrative-transparency concern.

Identified Code
'mod/kahoodle:manage_questions' => [
    'captype' => 'write',
    'riskbitmask' => RISK_SPAM | RISK_DATALOSS,
    'contextlevel' => CONTEXT_MODULE,
    'archetypes' => [
        'editingteacher' => CAP_ALLOW,
        'manager' => CAP_ALLOW,
    ],
],
Suggested Fix

Add RISK_XSS to the bitmask:

'riskbitmask' => RISK_SPAM | RISK_DATALOSS | RISK_XSS,
Identified Code
if ($questionformat == constants::QUESTIONFORMAT_RICHTEXT) {
    // Rich text mode - use editor.
    $mform->addElement('editor', 'questiontext_editor', get_string('questiontext', 'mod_kahoodle'), null, [
        'maxfiles' => EDITOR_UNLIMITED_FILES,
        'noclean' => true,
        'context' => $round->get_context(),
    ]);
Suggested Fix

No change required here — the cleaning is already deferred to sanitize_data(). Just make sure the capability change above is applied so the role-definition UI accurately reflects the editor surface.

Additional AI Notes

mod_kahoodle_realtime_event_received callback in lib.php is the central WebSocket-style entry point and is reached via tool_realtime's web service. It does the right thing: it pulls roundid with clean_param(..., PARAM_INT), instantiates the round entity, calls external_api::validate_context() (which performs an internal require_login), gates facilitator-only actions behind require_capability('mod/kahoodle:facilitate', ...), and gates participant-only actions behind is_participant() (which itself checks mod/kahoodle:participate or guest-with-anonymous-mode). All sub-actions go through clean_param for their string inputs.

Anonymous identity mode uses participantcode = md5($USER->id . sesskey() . $roundid) as the per-session participant identifier. This is a deliberate trade-off: the identity cannot be guessed by another user (sesskey is server-only), and the same user resuming the round in the same session lands on the same record — but a re-login produces a new participantcode and the previous anonymous score becomes unreachable. This is documented in the function's docblock.

Profile-picture-to-avatar download path in participants::save_profile_picture_to_avatar() is implemented carefully: it first tries to copy the user's stored f3.png/f3.jpg from user/icon directly out of the file storage, only falling back to an HTTP fetch when there is no stored icon (i.e. gravatar or generated). The fallback compares against the default URL, runs the URL through \core\files\curl_security_helper::url_is_blocked() to prevent SSRF to internal hosts, and uses Moodle's download_file_content() wrapper rather than raw cURL.

QR code (\mod_kahoodle\local\entities\round::serve_qrcode) is generated on the fly from the round's view URL using core_qrcode::getBarcodeSVGcode() and served with send_file(..., 'image/svg+xml'). Although the file area qrcode exists, the file is not persisted in the file store; it is recreated per request. The pluginfile callback gates QR access on mod/kahoodle:facilitate or mod/kahoodle:viewresults, which is the intended behaviour (the QR is shown by the facilitator on a shared screen, not fetched directly by participants).

Privacy provider is fully implemented (metadata, contextlist, userlist, export, delete-for-context, delete-for-user, delete-for-users) and even cleans up stored avatar files alongside the participant rows. This is one of the more thorough provider implementations seen in third-party modules.

Test coverage is good: PHPUnit tests under tests/ cover entities, external functions, output classes, the privacy provider, and the auto-archive ad-hoc task; Behat features cover gameplay flows, joining, identity modes, and richtext questions.

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