MDL Shield

🕹️PlayerHUD

block_playerhud

Print Report
Plugin Information

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.

Version:2026070701
Release:v1.7.1
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-07-07
248 files·64,798 lines
Grade Justification

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.

AI Summary

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 controlrequire_login, require_capability (view/manage), and confirm_sesskey/require_sesskey on every state-changing path; web services use validate_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 require moodle/course:manageactivities.
  • No injection surface — 100% parameterized $DB queries; allowlisted dynamic ORDER BY; trusted-only dynamic table names; no superglobals; no eval/exec/shell_exec/curl_init/file_get_contents.
  • SSRF-hardened AI calls — outbound requests go through Moodle's \curl wrapper; 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\lock plus 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:

  1. Low — The student Collection tab prints the item emoji/icon field unescaped ({{{image_content}}}) while every other path strips it. Not reachable via the PARAM_TEXT item form; only via AI-generated content from a teacher-controlled endpoint. Teacher-gated, low real risk.
  2. Low — The deadline_extension item 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).
  3. Info — Manual cli/seed.php/cli/seed_pt_br.php seeding 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

securityLow
Item emoji/icon rendered without output escaping in the student Collection view
Exploitable by:
teacher

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 image field as PARAM_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 emoji value verbatim into item->image with 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.

Risk Assessment

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.

Context

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.

Proof of Concept
  1. 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).
  2. Trigger AI item generation. Have the endpoint return, e.g.: {"name":"Relic","description":"x","emoji":"<img src=x onerror=alert(document.cookie)>"}.
  3. The generator stores the emoji verbatim into block_playerhud_items.image.
  4. 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.

Identified Code
$itemobj['image_content'] = $media['is_image'] ? '' : $media['content'];
Suggested Fix

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']);
Identified Code
'image_content' => $media['is_image'] ? '' : $media['content'],
Suggested Fix

Apply strip_tags() here too:

'image_content' => $media['is_image'] ? '' : strip_tags($media['content']),
Identified Code
<div class="ph-modal-emoji ph-card-emoji" aria-hidden="true">{{{image_content}}}</div>
Suggested Fix

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>
Identified Code
$item->image = $data['emoji'];
Suggested Fix

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.

securityLow
deadline_extension item power does not confirm the target activity belongs to the block's course
Exploitable by:
student

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.

Risk Assessment

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.

Context

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.

Proof of Concept

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.

Identified Code
$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;
Suggested Fix

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.

best practiceInfo
Manual seeding CLI scripts are shipped inside the plugin

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.

Risk Assessment

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.

Context

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.

Identified Code
define('CLI_SCRIPT', true);
require(__DIR__ . '/../../../config.php');
Suggested Fix

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.

Additional AI Notes

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.

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