🕹️PlayerHUD
block_playerhud
PlayerHUD is a course-level gamification block that layers an RPG-style progression system onto a Moodle course: XP, levels, collectible items with map "drops", a shop/trade economy, quests, RPG classes with karma-driven branching stories, group and individual leaderboards, equippable avatars, and a teacher-facing AI Game Master (item/class/story generation and a chat assistant) that can call Gemini, Groq or any OpenAI-compatible endpoint. It exposes ~19 AJAX web services, a full management panel, a step-by-step gamification wizard with rollback, backup/restore, and Privacy API support.
This plugin is engineered to a consistently high security standard across a large surface area (~19 external/AJAX endpoints, a management panel with dozens of state-changing actions, an AI subsystem making outbound HTTP calls, and backup/restore).
Authorization and access control are handled correctly and uniformly: every entry point calls require_login() and the appropriate require_capability() (view for students, manage for teachers); every state-changing GET action validates confirm_sesskey(); all web services call validate_context() + require_capability(); and cross-instance object access is systematically prevented by scoping every lookup, update and delete to blockinstanceid (frequently via JOINs). A dedicated cross_instance_security_test.php backs this up.
Data-layer hygiene is excellent: all queries use $DB parameterized placeholders (no string concatenation of user data), dynamic ORDER BY columns are allowlisted, dynamic table names come only from trusted modinfo, and no MySQL-only SQL is used. Output encoding relies on format_text()/format_string()/s() throughout, and the JS layer escapes untrusted text via .text()/textContent. Notable defensive highlights include thorough SSRF protection on the teacher-configurable AI endpoint (HTTPS-only, private/reserved-range blocking, DNS-rebinding re-resolution), use of core locking to prevent XP/trade/quest farming races, server-side anti-cheat re-validation of quest and story-choice requirements, a comprehensive Privacy API implementation (including declared external AI transmissions), and exemplary backup/restore with namespaced ID mappings and deferred foreign-key fixups.
Only two low-severity hardening gaps and one informational observation were found. The most notable is an output-escaping inconsistency where the item emoji field is printed unescaped in one student view; it is not reachable through the normal authoring path (which uses PARAM_TEXT) and only via an attacker-controlled AI endpoint a teacher would have to configure, so its real-world exploitability is minimal. The second is that a self-only deadline-extension item does not confirm its target activity belongs to the block's course. Neither allows a regular user to harm other users, and no critical, high, or medium issues were identified.
Overview
block_playerhud is a large, feature-rich gamification block (XP/levels, item drops, a trade economy, quests, RPG classes with branching AI-generated stories, leaderboards, avatars, and a teacher AI "Game Master"). Despite the breadth, the security posture is strong and remarkably consistent.
What was reviewed
Every PHP, JS, and Mustache file was read in full — the block class, lib.php, all page entry points, all 12 controllers, all ~30 classes/external/ web-service classes, the AI subsystem (generator, chat, context_builder), the core domain classes (game, quest, story_manager, trade_manager, utils, drop_guard, instance_cleanup, wizard), all output renderers, forms, templates, the AMD JS, backup/restore, the Privacy provider, and the CLI seed scripts.
Security strengths
- Uniform access control —
require_login,require_capability(view/manage), andconfirm_sesskey/require_sesskeyon every state-changing path; web services usevalidate_context()+ capability checks. - Strict instance scoping — every item/drop/quest/trade/chapter/scene lookup, update, and delete is bound to
blockinstanceid, blocking cross-course tampering. Activity-content mutations additionally requiremoodle/course:manageactivities. - No injection surface — 100% parameterized
$DBqueries; allowlisted dynamicORDER BY; trusted-only dynamic table names; no superglobals; noeval/exec/shell_exec/curl_init/file_get_contents. - SSRF-hardened AI calls — outbound requests go through Moodle's
\curlwrapper; the custom OpenAI-compatible URL is validated (HTTPS-only, private/reserved-range block, DNS-rebinding re-check). - Race-safe economy — collection, trades, and quest claims use
\core\lockplus transactions, with server-side re-validation to prevent farming/replay. - Compliance — a complete Privacy API (metadata for all tables + user preferences + declared external AI transmissions, export and delete), a proper
db/uninstall.php, and thorough backup/restore. - Testing — ~58 PHPUnit/Behat test files, including a dedicated cross-instance security test.
Findings
Three findings, none critical/high/medium:
- Low — The student Collection tab prints the item emoji/icon field unescaped (
{{{image_content}}}) while every other path strips it. Not reachable via thePARAM_TEXTitem form; only via AI-generated content from a teacher-controlled endpoint. Teacher-gated, low real risk. - Low — The
deadline_extensionitem power does not verify that the chosen target activity belongs to the block's own course; impact is self-only (a student extending their own deadline). - Info — Manual
cli/seed.php/cli/seed_pt_br.phpseeding scripts ship in the plugin (well-guarded: CLI-only, dev-site check, mandatory password).
Conclusion
A polished, defensively-written plugin. The findings are minor hardening opportunities rather than exploitable vulnerabilities.
Findings
The student Collection tab builds an image_content template value directly from the item's raw image field (the emoji/icon column) and the view_collection template prints it with a triple-mustache ({{{image_content}}}), i.e. unescaped HTML.
Every other place that renders this same field first neutralises it — the sidebar stash and the AJAX collect response call strip_tags(), and the profile page prints the sibling imagecontent value with an escaped double-mustache. This single view is the exception.
Why the practical risk is low but non-zero:
- The normal authoring path — the item edit form — declares the
imagefield asPARAM_TEXT, which strips HTML tags on input, so a teacher cannot inject markup through the form. - However, the AI generator writes the model's returned
emojivalue verbatim intoitem->imagewith no sanitisation (generator.php). A teacher can point the block at an arbitrary OpenAI-compatible endpoint (configurable per-user in the Config tab or via admin settings), or attempt to prompt-inject a legitimate model, so attacker-influenced markup can reach the raw column.
Because the field is clearly intended to hold a plain emoji (it is PARAM_TEXT on input and stripped everywhere else), this is an output-encoding inconsistency / defense-in-depth gap rather than an intended HTML field.
Low risk. Blast radius is students viewing the Collection tab of an affected block. Exploitation requires the manage capability (already carrying RISK_XSS in Moodle's model) and an attacker-influenced AI response — realistically a teacher deliberately configuring a hostile AI endpoint, or a successful prompt injection. It is not reachable by students or through the normal PARAM_TEXT item form, and there is no privilege escalation. The fix is a one-line escaping change per location, bringing this view in line with the profile view and all other render paths.
get_items_display_data() returns content => $item->image (the raw field) whenever the item has no uploaded file and the value is not an http... URL — i.e. for the emoji case. tab_collection::export_for_template() copies that raw value straight into image_content, which view_collection.mustache prints unescaped. Item images are teacher-controlled data (students cannot set them), so this is a teacher→student stored-XSS vector, not a student-to-student one.
- As a teacher (holds
block/playerhud:manage), open the Config tab and set a custom OpenAI-compatible base URL + key pointing at an endpoint you control (or any endpoint that echoes crafted JSON). - Trigger AI item generation. Have the endpoint return, e.g.:
{"name":"Relic","description":"x","emoji":"<img src=x onerror=alert(document.cookie)>"}. - The generator stores the
emojiverbatim intoblock_playerhud_items.image. - Any student opening the Collection tab renders the item card;
{{{image_content}}}injects the markup and the payload executes in the student's session.
Note: the item edit form cannot be used for this — PARAM_TEXT strips the tags — so exploitation depends on the AI-content path.
$itemobj['image_content'] = $media['is_image'] ? '' : $media['content'];
Strip tags at the renderer (matching the sidebar/collect paths), or print with an escaped double-mustache. For example:
$itemobj['image_content'] = $media['is_image'] ? '' : strip_tags($media['content']);
'image_content' => $media['is_image'] ? '' : $media['content'],
Apply strip_tags() here too:
'image_content' => $media['is_image'] ? '' : strip_tags($media['content']),
<div class="ph-modal-emoji ph-card-emoji" aria-hidden="true">{{{image_content}}}</div>
Since the value is only ever an emoji/plain string, use the escaped form so any residual markup is inert (this mirrors profile_content.mustache, which prints the same data as {{imagecontent}}):
<div class="ph-modal-emoji ph-card-emoji" aria-hidden="true">{{image_content}}</div>
$item->image = $data['emoji'];
Sanitise the AI-supplied emoji before storing, e.g. clean_param((string) $data['emoji'], PARAM_TEXT) or \core_text::substr(strip_tags((string) $data['emoji']), 0, 255), so stored data matches the plain-text expectation of the item form.
The use_item web service consumes a deadline_extension item and writes a local_latepenalty_overrides row for the current user against a course module. When the item does not pin a cmid, the target module id is taken from the client-supplied targetcmid, and the surrounding course is taken from the client-supplied courseid.
The only implicit constraint is that the first override for a given module goes through get_fast_modinfo($courseid)->get_cm($cmid), which throws if the module is not in courseid. There is no check that courseid (and therefore the target module) is the block instance's own course. Capability is verified only on the block context (block/playerhud:view).
As a result, a student can apply a deadline-extension item earned in one course to an activity in a different course they can access, provided that activity has an enabled local_latepenalty rule.
Low risk. Impact is self-only (a student adjusting their own deadline), gated on possessing a teacher-created consumable item and on the target having a late-penalty rule. There is no cross-user impact and no way to affect another student's records. It is a scoping/authorization hardening gap rather than an exploitable privilege escalation.
The effect is confined to the calling user's own local_latepenalty_overrides record and only applies when a late-penalty rule exists for the target module; days is teacher-configured (clamped max(1, ...)) and the item is consumed on use. So the feature works as intended (students spend items to extend their own deadlines) — the gap is purely that the item's course scope is not enforced.
As a student enrolled in Course A (with a PlayerHUD block) who holds an unpinned deadline_extension item, and who can also load get_fast_modinfo for Course B (e.g. also enrolled there), call block_playerhud_use_item with the Course A instanceid, courseid = Course B, and targetcmid = a Course B activity that has an enabled local_latepenalty rule. The student's own late-penalty deadline for that Course B activity is extended by the item's configured days, even though the item belongs to Course A's economy.
$av = !empty($item->action_value) ? json_decode($item->action_value, true) : [];
$days = max(1, (int)($av['days'] ?? 1));
$cmid = !empty($av['cmid']) ? (int)$av['cmid'] : $targetcmid;
Resolve the block instance's own course from the validated block context and require the target module to belong to it, instead of trusting the courseid parameter. For example, derive $blockcourseid = $context->get_course_context()->instanceid; and use it for get_fast_modinfo(...), or explicitly assert $blockcoursectx->instanceid === (int) $courseid before any override is written — the same pattern already used in insert_drop_shortcode/setup_playercoin_drop.
The plugin bundles cli/seed.php and cli/seed_pt_br.php, which create a demo course, demo users with a caller-supplied password, and sample game content for manual testing.
These are appropriately hardened — they define CLI_SCRIPT (so they cannot be invoked over the web), require an explicit --password, and refuse to run on what looks like a production site unless --force is passed. There is no security defect. This is noted only as a packaging/hygiene observation: development-only seeding tooling that creates known-credential accounts is generally best kept out of released packages (e.g. excluded via the build/.gitattributes), so it can never run on a real deployment even if the guard assumptions change.
Informational. No exploit path: the scripts cannot be triggered via HTTP, require a password argument, and self-abort on production-looking sites. The observation is about release hygiene, not a vulnerability.
Both scripts are CLI-only utilities used for local manual testing; running them already requires shell access to the server, which is itself a privileged position.
define('CLI_SCRIPT', true);
require(__DIR__ . '/../../../config.php');
Consider excluding cli/seed*.php from the distributed package, or keeping them in a separate dev branch/tools repository. If retained, the existing CLI-only + dev-site + mandatory-password guards should be preserved.
No third-party libraries are bundled. The plugin ships only its own AMD modules (with compiled amd/build counterparts) and its own CSS; there are no vendor/node_modules directories and nothing that duplicates a Moodle-core library, so the absence of a thirdpartylibs.xml is correct here.
Strong SSRF defense on the AI endpoint. generator::is_safe_url() enforces HTTPS, blocks localhost/loopback, rejects RFC-1918/reserved ranges via FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, and re-resolves all A/AAAA records to defend against DNS rebinding — a level of care well above the norm for teacher-configurable outbound URLs. All outbound calls go through Moodle's \curl wrapper (filelib.php), not raw cURL.
Race conditions are handled deliberately. Item collection, trade execution, and quest claiming each acquire a \core\lock\lock_config lock and re-check limits/cooldowns/one-time/already-claimed state inside the lock and a delegated transaction, closing the check-then-act farming/replay window. XP and karma are also clamped (max(0, ...), [-999, 999]).
Server-side anti-cheat is thorough. story_manager::make_choice() verifies the chosen choice matches the player's current node, rejects choices from already-completed chapters, and re-validates class/karma/item-cost requirements server-side; quest::claim_reward() re-verifies completion server-side before granting. Client-supplied preview mode additionally requires the manage capability.
Privacy and lifecycle are complete. The Privacy API implements metadata (all tables, user preferences, and declared external AI transmissions to Gemini/Groq/OpenAI-compatible), context/user discovery, export, and all delete variants; db/uninstall.php cleans user-preference data core does not; and instance_cleanup plus the block's instance_delete() remove all owned rows on deletion. Minor: the transient block_playerhud_celebration preference is set/cleared during normal use but is not individually declared in privacy metadata (it is swept by db/uninstall.php) — negligible, as it is ephemeral and non-personal.
Backup/restore is exemplary. User data is gated behind the users backup setting, IDs are annotated for GDPR/rollback, files are annotated, and restore uses namespaced mappings (playerhud_item, etc.) plus deferred fix-ups (after_execute/after_restore) to correctly remap self-referential choice targets, trade-typed quest requirements, and activity/course-module references that only exist late in the restore plan.
Extensive automated test coverage (~58 PHPUnit/Behat files) accompanies the plugin, including cross_instance_security_test.php, privacy_provider_test.php, backup_restore_test.php, and per-endpoint external-function tests — a strong signal for maintainability and regression safety.