Kahoodle
mod_kahoodle
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.
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.
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, callsself::validate_context()and thenrequire_capability()(manage_questions,viewresults, oraddinstanceviaadd_moduleinfo).preview_questionsandplayback_stagesgate 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.phpactions (start/finish/leave/newround) callrequire_sesskey(); question add/edit uses adynamic_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 useformat_string(), question text usesformat_text(). Triple-mustache ({{{ }}}) is used only for values already escaped in PHP. Client-side rendering usesTemplates.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 withPARAM_FILEand validates membership in the participant's own candidate folder. - Outbound HTTP (
save_profile_picture_to_avatar) usesdownload_file_content()with TLS verification on and acurl_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/LIMIThandling in backup SQL).
Findings
Two low findings and one info note:
lib/filelib.phpfunctions (get_file_storage,download_file_content,file_save_draft_area_files,send_stored_file, etc.) are used without an explicitrequire_once.- The real-time callback relies on the out-of-scope
tool_realtimeplugin for sesskey/CSRF enforcement (while independently enforcing login and capabilities). - 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
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.
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).
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).
$fs = get_file_storage();
$file = $fs->get_file_by_hash(sha1($fullpath));
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.
$response = download_file_content($url, null, null, true, 5, 3);
Ensure require_once($CFG->libdir . '/filelib.php') has run before calling download_file_content() / get_file_storage() in this class.
$files = get_file_storage()->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'id', false);
Require lib/filelib.php before using get_file_storage() and file_save_draft_area_files() in this class.
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.
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.
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.
function mod_kahoodle_realtime_event_received($payload): array {
global $PAGE, $DB;
$action = (string)($payload['action'] ?? '');
$roundid = clean_param($payload['roundid'] ?? 0, PARAM_INT);
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.
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.
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.
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.
protected static function value_or_default($value, $default): string {
if ($value !== null && $value != $default) {
return "<b>" . $value . "</b>";
}
return (string)$default;
}
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.
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.