HTML5 canvas sketch game
mod_gcanvas
Canvas game (mod_gcanvas) is a Moodle activity module in which students create freehand HTML5 canvas sketches using the bundled Fabric.js library. Students can add shapes, text, their own uploaded images and emoji, then save, restore, download and delete their drawings; teachers can set a background image, add reusable toolbar images and edit rich-text help. The module ships a Privacy (GDPR) provider and full backup/restore support, and has no grading.
The plugin is well engineered and has clearly been through prior security hardening (the code carries LS-4198/LS-4206 fix markers and ships PHPUnit tests that assert the access-control paths). Across every entry point I found no critical or high issues: all database access uses parameterised $DB APIs (no raw SQL, no superglobals), there is no shell/eval/server-side HTTP usage, no schema changes outside db/upgrade.php, and file uploads are constrained to web_image types and served force-download.
Authorization is generally solid. The AJAX router enforces require_login() + require_sesskey() and only dispatches to whitelisted callable_* methods; state-changing form posts call confirm_sesskey(); gcanvas_pluginfile() applies per-user ownership checks so a co-enrolled student cannot read another user's attempt image or uploaded picture; and rich-text help is rendered through format_text() with cleaning enabled. A complete Privacy provider and backup/restore implementation are present.
The issues identified are all low severity: the interactive view.php/ajax.php entry points rely on require_login() and never call require_capability('mod/gcanvas:view') (a defense-in-depth gap that is inert on default installs); attempt json_data is stored without validation or a size cap (self-scoped, so no cross-user impact); the Privacy metadata declaration does not match the stored columns; and db/install.xml retains a copied header from mod_folder. None of these expose one user's data to another or permit privilege escalation, so the overall risk is minimal.
Overview
mod_gcanvas ("Canvas game") is a Moodle activity where students draw on an HTML5 canvas with the bundled Fabric.js library — placing shapes, text, uploaded images and emoji — then save, restore, download and delete their sketches. Teachers can set a background image, add reusable toolbar images, and edit rich-text help. There is no grading.
Security posture
The codebase is clean and has evidently already been through security review. Key positives verified across every file:
- Database access is entirely through parameterised
$DBmethods — no raw SQL, no string-concatenated queries, no superglobals ($_GET/$_POST/etc.), no direct filesystem or DB connections. - No dangerous execution surfaces: no
eval/exec/shell_exec, no server-side outbound HTTP (all image loading is client-side in Fabric.js), no schema operations outsidedb/upgrade.php. - CSRF/auth:
ajax.phpenforcesrequire_login()+require_sesskey()and dispatches only to whitelistedcallable_*methods; the manual upload post inview.phpcallsconfirm_sesskey(); theintroeditor action is gated byrequire_capability('mod/gcanvas:teacher'). - IDOR protection:
gcanvas_pluginfile()applies per-user ownership checks to theattemptandstudent_imageareas (a co-enrolled student cannot fetch another user's file), attempt queries are scoped to$USER->id, and files are served force-download. - XSS: help text is rendered via
format_text()with cleaning enabled (noclean => false); triple-mustache output is either pre-sanitised HTML or server-generated form markup; userjson_datais only ever deserialised by its own author. - Uploads are limited to the
web_imagetype group with capability checks per file area. - Compliance/robustness: a complete Privacy (GDPR) provider, full backup/restore, and PHPUnit tests that specifically assert the deny paths of
gcanvas_pluginfile().
Findings
Four low-severity issues were identified, none of which expose other users' data or allow privilege escalation:
view.phpandajax.phpgate access withrequire_login()but never enforce the plugin's ownmod/gcanvas:viewcapability (defense-in-depth gap; inert on default installs).callable_save_canvas()stores attemptjson_datawith no validation or size limit (self-scoped; mild storage-abuse vector).- The Privacy metadata declaration lists a
gcanvasfield (actual column isgcanvas_id) and omitsuser_id/status. db/install.xmlkeeps a header (VERSION/COMMENT) copied frommod_folder.
Third-party libraries
Fabric.js 2.4.2, Spectrum 1.8.0 (JS + CSS) and Twitter Twemoji 11.2 are bundled and correctly declared in thirdpartylibs.xml.
Findings
The plugin defines a mod/gcanvas:view capability and enforces it in gcanvas_pluginfile(), but the two interactive entry points — the main activity page (view.php) and the AJAX router (ajax.php) — gate access only with require_login($course, true, $cm) (plus require_sesskey() on the AJAX side).
Core's require_login() verifies login/enrolment and the activity's availability ($cm->uservisible), but it does not evaluate a module's own :view capability. Consequently, if a site administrator removes mod/gcanvas:view from a role via a role override — the normal way to hide an activity from a group of users — that override is honoured for direct file downloads (which do check the capability) but ignored for the interactive page and every AJAX action.
The result is an inconsistent, incompletely enforced access rule rather than a single clear gate.
Low risk. On a default installation mod/gcanvas:view is granted to the user archetype (CAP_ALLOW), so every authenticated user who can reach the activity already holds it and the missing check has no observable effect. The gap only matters where a site deliberately overrides the capability to Prevent, and even then the exposure is limited to viewing the activity UI and the user's own attempts — cross-user data is protected independently by ownership checks in gcanvas_pluginfile() and by the $USER->id-scoped AJAX queries. No other user's data is disclosed and no privilege escalation is possible, so this is a defense-in-depth/consistency improvement rather than an exploitable vulnerability.
Access control in view.php consists of require_login($course, true, $cm) plus a teacher-only require_capability() for the intro editing action; ajax.php uses require_login() + require_sesskey(). This was confirmed against core require_login() in lib/moodlelib.php, which checks $cm->uservisible and core capabilities such as moodle/course:viewhiddencourses but never a module's :view capability. The plugin does check mod/gcanvas:view in gcanvas_pluginfile() (lib.php), which is what makes the omission on the page/AJAX paths an inconsistency.
As a site administrator, add a role override on a Canvas game activity that sets mod/gcanvas:view to Prevent for the Student role. A student in that role can still open view.php?id=<cmid> and drive the AJAX endpoints (load_history, save_canvas, get_attempt, ...) because require_login() does not consult the plugin capability — only direct pluginfile.php downloads are blocked.
view.php:51Source link unavailable — plugin was reviewed from zip without a matching git refrequire_login($course, true, $cm);
$modulecontext = context_module::instance($cm->id);
Add an explicit capability check once the module context is available:
require_login($course, true, $cm);
$modulecontext = context_module::instance($cm->id);
require_capability('mod/gcanvas:view', $modulecontext);
ajax.php:47Source link unavailable — plugin was reviewed from zip without a matching git ref// First validation access.
require_login($course, true, $cm);
$PAGE->set_course($course);
// Confirm session.
require_sesskey();
Enforce the view capability alongside the existing login/sesskey checks:
require_login($course, true, $cm);
require_capability('mod/gcanvas:view', context_module::instance($cm->id));
require_sesskey();
In callable_save_canvas() the rasterised PNG (canvas_data) is validated thoroughly — it is base64-decoded, checked against the maximum upload size, and confirmed to be a genuine image with getimagesizefromstring(). The companion json_data field, however, is taken straight from the decoded request body and inserted verbatim, with no check that it is well-formed JSON and no upper bound on its length.
On retrieval, callable_get_attempt() returns this value (owner-only) and the browser feeds it to canvas.loadFromJSON() to rebuild the sketch.
Low risk. Because the field is owner-scoped on every read, the loadFromJSON() deserialisation is self-only — a user could at most affect their own browser session, so this is not a cross-user stored-XSS vector. There is no SQL injection (the insert is parameterised). The practical concern is integrity/abuse: an authenticated user can persist arbitrarily large or malformed payloads, a mild storage/denial-of-service vector scoped to their own records. Adding a structural check and a length cap brings this path in line with the validation already applied to the image content.
json_data is the Fabric.js serialisation of the canvas. Every read path is owner-scoped: callable_get_attempt() filters by user_id = $USER->id, and the attempts overview (output_canvas_attempts) renders only the generated PNG (src), never json_data. It is therefore only ever deserialised by the same user who created it; teachers can view attempt images via gcanvas_pluginfile() but not the raw json_data.
Send an authenticated save_canvas AJAX request (valid sesskey) whose data.json_data is a multi-megabyte string of arbitrary text and a small valid canvas_data PNG. The image passes validation and the oversized json_data is stored unchanged; repeating this grows the gcanvas_attempt table without bound.
classes/ajax.php:107Source link unavailable — plugin was reviewed from zip without a matching git ref$attemptid = $DB->insert_record('gcanvas_attempt', (object) [
'status' => $status,
'user_id' => $USER->id,
'gcanvas_id' => $cobject->cm->instance,
'json_data' => $this->data->json_data,
'added_on' => time(),
]);
Validate structure and cap the size before storing, mirroring the care already taken for the image payload:
$json = $this->data->json_data ?? '';
if (!is_string($json) || strlen($json) > 1024 * 1024 || json_decode($json) === null) {
return ['success' => false];
}
// ... use $json for the 'json_data' column
provider::get_metadata() describes the gcanvas_attempt table but the declaration does not match the actual schema:
- It declares a field named
gcanvas, whereas the real column isgcanvas_id. - It omits the
user_idcolumn — the identifier that links each attempt to a specific person and therefore the key piece of personal data. - It omits the
statuscolumn.
The metadata surface that tells users and administrators "here is the personal data this plugin stores" is thus inaccurate and incomplete, even though the export/delete logic itself is correct.
Low risk. This is a GDPR documentation-accuracy issue, not a data-handling defect — the plugin does correctly export and erase user attempts and their files. The impact is confined to the "what we store" disclosure presented in the site's privacy registry, which under-describes (omits user_id) and mislabels (gcanvas vs gcanvas_id) the stored data.
The remainder of the provider is sound: get_contexts_for_userid(), export_user_data(), delete_data_for_all_users_in_context(), delete_data_for_user(), get_users_in_context() and delete_data_for_users() all use correctly parameterised SQL and act on the right records and files. Only the descriptive get_metadata() collection is out of step with the table definition in db/install.xml.
classes/privacy/provider.php:59Source link unavailable — plugin was reviewed from zip without a matching git ref$collection->add_database_table('gcanvas_attempt', [
'gcanvas' => 'privacy:metadata:attempt:gcanvas',
'json_data' => 'privacy:metadata:attempt:json_data',
'added_on' => 'privacy:metadata:attempt:added_on',
], 'privacy:metadata:attempt');
Align the declared field names with the schema and add the missing columns (with matching language strings):
$collection->add_database_table('gcanvas_attempt', [
'gcanvas_id' => 'privacy:metadata:attempt:gcanvas',
'user_id' => 'privacy:metadata:attempt:user_id',
'json_data' => 'privacy:metadata:attempt:json_data',
'status' => 'privacy:metadata:attempt:status',
'added_on' => 'privacy:metadata:attempt:added_on',
], 'privacy:metadata:attempt');
The db/install.xml root element carries VERSION="20130407" and COMMENT="XMLDB file for Folder module", evidently copied from mod_folder when the plugin was scaffolded. The gcanvas_attempt table comment ("gcanvas_attempt table retrofitted from MySQL") is similarly stale. These do not describe this plugin.
Low risk. Purely cosmetic with no functional consequence. It is worth correcting only because it signals copy-paste scaffolding and can mislead a future maintainer about which component the schema belongs to.
The XMLDB VERSION and COMMENT attributes are documentation metadata within the schema file; Moodle tracks the installed schema through version.php and the concrete <TABLE> definitions, which are correct here.
db/install.xml:2Source link unavailable — plugin was reviewed from zip without a matching git ref<XMLDB PATH="mod/gcanvas/db" VERSION="20130407" COMMENT="XMLDB file for Folder module"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
>
Open the file in the XMLDB editor (Site administration ▸ Development ▸ XMLDB editor) and set the COMMENT to describe mod_gcanvas; refresh the VERSION. This is purely descriptive and has no runtime effect.
| Library | Version | License | Declared |
|---|---|---|---|
fabric.js Core HTML5 canvas engine — renders shapes, text, images and emoji and serialises/deserialises the sketch (loadFromJSON / toDataURL). | 2.4.2 | MIT | ✓ |
spectrum JavaScript colour-picker used to set the fill colour of the currently selected canvas object. | 1.8.0 | MIT | ✓ |
spectrum.css Stylesheet for the Spectrum colour picker. | 1.8.0 | MIT | ✓ |
twitter/twemoji Emoji graphics (PNG/SVG) shown in the emoji picker and placed onto the canvas. | 11.2 | CC-BY 4.0 | ✓ |
Prior security hardening is evident and validated by tests. The code carries LS-4198 and LS-4206 fix markers (also documented in CHANGELOG.md) covering a stored-XSS in the help text, an IDOR on attempt/student-image files, per-user itemid scoping, guest rejection on save, and backup/restore file-area fixes. Crucially these are backed by regression tests: tests/lib_test.php asserts that gcanvas_pluginfile() denies another user's attempt and student_image files, and tests/backup_restore_test.php checks the four teacher file areas survive a duplicate. This is a materially stronger position than most plugins of this size.
Minor dead code in output_canvas::export_for_template(). $object->data = array_values($data) is built from a $data array that is initialised to [] and never populated, so it is always an empty array. Harmless, but it can be removed for clarity.
Fabric.js 2.4.2 is several major releases behind upstream (current is the 5.x/6.x line). Version currency and known-vulnerability status are assessed by external tooling, but a maintenance bump is worth planning since the library is central to the module and is loaded on the student-facing page.
callable_upload_images() / helper::upload_file() handle the user-supplied filearea safely. The value is validated through a switch that maps background/toolbar_shape to a mod/gcanvas:teacher check and student_image to mod/gcanvas:student_image, throwing for anything else, and the file-area id is type-cast to int. A student who requests filearea=background is correctly rejected with a capability exception, so there is no privilege-escalation path here.
Uploaded files cannot be used for inline XSS. Although the web_image type group includes SVG (confirmed in core lib/classes/filetypes.php), gcanvas_pluginfile() serves every file with send_stored_file(..., $forcedownload = true) and the client only ever consumes these URLs as <img>/Fabric images, so embedded SVG scripting does not execute.