MDL Shield

Kahoodle

mod_kahoodle

Print Report
Plugin Information

Kahoodle is a real-time, Kahoot-style quiz activity module for Moodle. A facilitator (teacher) runs live rounds in which participants answer multiple-choice questions simultaneously, earning points for speed and correctness, with a live leaderboard, podium animation and per-round/aggregate reports. It supports question versioning, multiple identity modes (real name, optional/required alias, fully anonymous including guests), avatar selection, backup/restore, the Privacy API and report builder integration. Real-time communication is delivered through the required tool_realtime plugin.

Version:2026100200
Release:4.5.2
Reviewed for:5.3
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-10-02
141 files·30,230 lines
Grade Justification

This is a large, mature and notably security-conscious plugin. Across every externally reachable entry point the same correct pattern is applied: the context is derived from the target entity, validate_context() (which internally calls require_login()) or require_login()/require_course_login() is invoked, and a specific capability is enforced before any privileged action. All AJAX/web-service classes extend external_api and re-check manage_questions/viewresults/addinstance; the custom view.php actions each call require_sesskey(); question editing goes through a dynamic_form with check_access_for_dynamic_submission(); and file serving in kahoodle_pluginfile() performs per-filearea capability checks plus content-hash lookups that prevent cross-context access.

No security vulnerabilities were found. Database access is exclusively through the parameterised $DB API (including report builder entities and the privacy provider); all user-influenced output is escaped via format_string(), format_text(), s() or Mustache auto-escaping, and the triple-mustache occurrences were each confirmed to receive values pre-escaped on the PHP side. File handling uses the File API throughout, and the single outbound HTTP request (downloading the current user's own profile picture) uses the sanctioned download_file_content() wrapper with TLS verification left on and an explicit curl_security_helper block-list check. The Privacy (GDPR) API is fully implemented, and backup/restore, scheduled tasks and report builder integration are all correct.

The only issues are two low-severity items — numerous lib/filelib.php functions are called without an explicit require_once of that library (it is not loaded unconditionally by core, though it is almost always present transitively), and the real-time event handler delegates CSRF/sesskey enforcement to the required but out-of-scope tool_realtime plugin while independently enforcing authentication and authorisation itself — plus one informational hardening note. There are no findings that allow a low-privilege or unauthenticated user to harm other users, and the overall code quality, test coverage and documentation are high.

AI Summary

Overview

mod_kahoodle is a real-time quiz activity (Kahoot-style) with a facilitator screen, participant screens, live leaderboard/podium, question versioning, multiple identity modes, report builder reports, backup/restore and a full Privacy API. Real-time transport is provided by the required tool_realtime dependency.

The plugin is well-engineered and security-conscious. I read every PHP, JavaScript and Mustache template file and cross-checked core behaviour in /moodle.

Security posture (verified)

  • Authorisation is consistent. Every web-service class (classes/external/*) resolves the context from the target entity, calls self::validate_context() and then require_capability() (manage_questions, viewresults, or addinstance via add_moduleinfo). preview_questions and playback_stages gate on the correct capabilities. Cross-module requests are blocked because the capability is always checked against the derived context.
  • State-changing actions are CSRF-protected. view.php actions (start/finish/leave/newround) call require_sesskey(); question add/edit uses a dynamic_form; sort-order/delete/duplicate go through the external API — all of which validate sesskey automatically.
  • Output is escaped. Display names use s(), activity/round names use format_string(), question text uses format_text(). Triple-mustache ({{{ }}}) is used only for values already escaped in PHP. Client-side rendering uses Templates.renderForPromise()/replaceNodeContents() with server-fixed template names.
  • Data access is parameterised throughout, including report builder entities and the privacy provider. No raw SQL concatenation of user input.
  • File handling uses the File API; kahoodle_pluginfile() enforces per-area capabilities and resolves files by content hash, and avatar access is restricted to privileged roles or the participant's own record. The avatar-change path cleans filenames with PARAM_FILE and validates membership in the participant's own candidate folder.
  • Outbound HTTP (save_profile_picture_to_avatar) uses download_file_content() with TLS verification on and a curl_security_helper()->url_is_blocked() check; the URL is the user's own profile-picture URL, not attacker-controlled.
  • Privacy, backup/restore, scheduled task are all implemented correctly and portably (including MSSQL TOP/LIMIT handling in backup SQL).

Findings

Two low findings and one info note:

  1. lib/filelib.php functions (get_file_storage, download_file_content, file_save_draft_area_files, send_stored_file, etc.) are used without an explicit require_once.
  2. The real-time callback relies on the out-of-scope tool_realtime plugin for sesskey/CSRF enforcement (while independently enforcing login and capabilities).
  3. A report column builds a <b> tag by string concatenation (safe today only because the fields are integer-typed).

No high/critical/medium issues were identified. No low-privilege user can read or modify other users' data, change scores, or escalate privileges.

Findings

code qualityLow
File API functions used without requiring lib/filelib.php

The plugin calls many functions that are defined in lib/filelib.php — for example get_file_storage(), download_file_content(), file_save_draft_area_files(), file_prepare_draft_area(), file_get_submitted_draft_itemid(), file_rewrite_pluginfile_urls(), file_mimetype_in_typegroup(), send_stored_file() and send_file() — without ever executing require_once($CFG->libdir . '/filelib.php').

Moodle does not load filelib.php on every request: it is not among the unconditional includes in lib/setup.php, and the web-service dispatcher and cron/ad-hoc bootstraps do not include it at the top level either. It is pulled in transitively by many common operations (for example pluginfile.php, and lazily by format_module_intro(), theme cache helpers, etc.), which is why the code works in practice, but relying on that transitive loading is fragile.

The usage is spread across the file-serving callback, the game logic, the question API, the output classes, the report builder entities and (indirectly) the web-service classes preview_questions and playback_stages, some of which run in contexts (AJAX web service, ad-hoc task) where filelib.php is least likely to be preloaded.

Risk Assessment

Low risk. This is a code-quality/robustness issue, not a security vulnerability. In the overwhelming majority of real request flows filelib.php is already loaded transitively (notably pluginfile.php includes it at the top, which covers the file-serving callback), and the plugin's extensive automated test suite exercises these paths, so breakage is unlikely in a standard install. However, Moodle provides no contract that filelib.php is globally loaded, so explicitly requiring it in each file that depends on it is the correct, defensive practice and avoids latent fatal errors in less common contexts (web services, scheduled tasks).

Context

get_file_storage() and the other helpers live in lib/filelib.php. If that file has not been loaded when one of these calls executes, PHP raises a fatal "call to undefined function" error. The plugin exercises these functions from entry points including kahoodle_pluginfile() (lib.php), the preview_questions/playback_stages web services (via the output classes that read question files), and the auto_archive_round ad-hoc task (via the progress/notification chain).

Identified Code
    $fs = get_file_storage();
    $file = $fs->get_file_by_hash(sha1($fullpath));
Suggested Fix

Add an explicit include near the top of each file that uses File API helpers (or at the top of the relevant functions):

require_once($CFG->libdir . '/filelib.php');

Note that pluginfile.php already loads filelib.php before invoking kahoodle_pluginfile(), so this specific callback is in practice always covered; the gap is more relevant for the web-service and task code paths below.

Identified Code
        $response = download_file_content($url, null, null, true, 5, 3);
Suggested Fix

Ensure require_once($CFG->libdir . '/filelib.php') has run before calling download_file_content() / get_file_storage() in this class.

Identified Code
        $files = get_file_storage()->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'id', false);
Suggested Fix

Require lib/filelib.php before using get_file_storage() and file_save_draft_area_files() in this class.

best practiceLow
Real-time event handler delegates sesskey/CSRF enforcement to the tool_realtime dependency

mod_kahoodle_realtime_event_received() is the server-side handler for all in-game client actions — facilitator actions (advance, get_current, reveal_rank) and participant actions (get_participant_state, answer, get_avatar_candidates, change_avatar). These are state-changing operations.

The handler performs its own authentication and authorisation: it calls \core_external\external_api::validate_context() (which internally calls require_login() against the module's course/cm), rejects rounds that are not in progress, enforces require_capability('mod/kahoodle:facilitate', ...) for facilitator actions, and requires $round->is_participant() for participant actions.

What it does not do itself is validate a sesskey (CSRF token). A code comment states that tool_realtime calls this callback from admin/tool/realtime/push.php only after require_login() and require_sesskey(). tool_realtime is a declared dependency (version.php) but is not present in the review environment, so its sesskey enforcement could not be verified here.

Risk Assessment

Low risk (defence-in-depth). The security-relevant check that is delegated to absent code is CSRF protection; authentication and authorisation are enforced locally and were verified. If tool_realtime failed to validate sesskey, the residual exposure would be cross-site request forgery of in-game actions (e.g. tricking a logged-in facilitator's browser into advancing a stage, or a participant's browser into submitting an answer) — actions scoped to the victim's own role and session with no cross-user data disclosure or modification. Because the behaviour of the dependency cannot be inspected in this environment, this is recorded as a reliance to confirm, not a demonstrated vulnerability, and it does not by itself indicate a flaw in this plugin.

Context

The handler is reached only through tool_realtime's push endpoint (clients call tool_realtime/api.sendToServer('mod_kahoodle', ...)). Because validate_context() enforces require_login() and the handler enforces capability/participant checks, authentication and authorisation are guaranteed by this plugin independently of tool_realtime. The only control the plugin outsources is the CSRF (sesskey) check.

Identified Code
function mod_kahoodle_realtime_event_received($payload): array {
    global $PAGE, $DB;

    $action = (string)($payload['action'] ?? '');
    $roundid = clean_param($payload['roundid'] ?? 0, PARAM_INT);
Suggested Fix

This is a defence-in-depth note rather than a required change. To avoid depending on the transport plugin for CSRF protection, the handler could additionally confirm the request carried a valid session token (for example by having tool_realtime pass through confirmation, or by an explicit require_sesskey() where the calling convention allows it). At minimum, keep the dependency on a tool_realtime version that is known to enforce require_sesskey() before dispatching to component callbacks.

best practiceInfo
Report column builds HTML via string concatenation of a database value

In the round-question report builder entity, the value_or_default() helper returns "<b>" . $value . "</b>" for a TYPE_TEXT column (timing and score columns). Report builder does not HTML-escape the output of a text-column callback, so the helper is responsible for safe output.

This is safe as written because the values involved (questionpreviewduration, questionduration, questionresultsduration, minpoints, maxpoints and their activity defaults) are all integer columns per db/install.xml and are only ever written as integers. There is therefore no injection vector today. It is flagged only as a robustness/best-practice note: building HTML by concatenating a raw DB value is fragile if the column types or callback inputs ever change.

Risk Assessment

Informational. No exploit exists given the integer-typed source columns; this is a defensive-coding suggestion to avoid raw HTML string building around database values.

Context

The callback is used for read-only report columns shown to users with the manage_questions capability. The field values originate from the question form (cast to int in process_dynamic_submission) or the add_questions web service (PARAM_INT) and are stored in integer columns, so no HTML can reach this concatenation.

Identified Code
    protected static function value_or_default($value, $default): string {
        if ($value !== null && $value != $default) {
            return "<b>" . $value . "</b>";
        }
        return (string)$default;
    }
Suggested Fix

Use a helper that makes the intent explicit and is safe regardless of input type, e.g.:

return \html_writer::tag('b', (int)$value);

or cast/format the value before concatenation.

Additional AI Notes

No bundled third-party code. The plugin ships only its own source; amd/build/* are standard Grunt-compiled versions of amd/src/*, pix/* are images, and styles.css contains no external @import/url(http...) references. No thirdpartylibs.xml is required, and the plugin does not duplicate any library shipped by core.

Outbound HTTP is handled correctly. participants.php::save_profile_picture_to_avatar() downloads only the current user's own profile-picture URL, first rejecting it via (new \core\files\curl_security_helper())->url_is_blocked($url) and using download_file_content() with certificate verification left enabled. There is no user-controlled URL and therefore no SSRF or cleartext-credential exposure.

Anonymous/guest mode is handled carefully. In fully-anonymous mode no userid is stored (a per-session participantcode is used instead), join/response events are suppressed or anonymised, and course_module_viewed is emitted with anonymous => 1 to avoid correlating log entries with users.

Minor, non-security observations. db/install.xml sets the kahoodle.lobbyduration default to 60 while constants::DEFAULT_LOBBY_DURATION is 300 (the form applies 300, so this only affects records inserted without the field). The codebase mixes get_string(..., 'kahoodle') and get_string(..., 'mod_kahoodle'); both resolve correctly for an activity module, so this is cosmetic. Neither warrants a code change on its own.

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