XP Store Availability - By E.P. Studio
availability_xpstore
An availability condition plugin (availability_xpstore) that restricts access to course activities and resources based on whether a student has "purchased" a specific item in the companion local_xpstore plugin. It plugs into Moodle's Restrict access framework: teachers pick a store product from a dropdown, and the condition's is_available() check queries the store's purchase records to decide whether the activity is shown. The plugin ships a YUI form module, English and Spanish language packs, and a null privacy provider.
The plugin is small, focused, and follows most Moodle conventions well: all database access uses parameterised $DB methods, there is no raw SQL, no direct filesystem or HTTP access, no superglobal usage, and no custom web entry points (so require_login/sesskey/capability handling is correctly left to core's availability framework). The class files correctly omit the MOODLE_INTERNAL guard, the privacy null_provider is appropriate, and the YUI integration matches the pattern used by core availability conditions.
The one security-relevant defect is a missing output-escaping issue: product/reward names are rendered into HTML in two places — the YUI restrict-access form (frontend.php → form.js) and, more importantly, the student-facing availability description (condition.php::get_description()) — without ever passing through format_string()/s(). Core's equivalent availability_grade condition escapes these names deliberately, and the availability framework provides description_format_string() specifically for this purpose; the plugin ignores both.
The practical impact is constrained. On the paths that originate from data I could verify (activity names via cm_info->name, grade-item names via grade_items.itemname), Moodle's PARAM_TEXT/PARAM_CLEANHTML input filtering strips tags and scripts on save, neutralising a tag-injection payload. The remaining unescaped path — the store catalog's custom product name read from local_xpstore's catalog_course_X configuration — bypasses that filtering, but whether a script payload can actually be stored there depends on local_xpstore, which is not part of this review. Injecting into the store catalog also requires an elevated role (editing teacher / manager). Because a working end-to-end exploit could not be demonstrated against code available in this review, the finding is rated as a mitigated medium rather than high.
The remaining issues are minor: tight coupling to local_xpstore's internal table/config format with no API abstraction, an implicitly-nullable parameter signature that is deprecated on PHP 8.4 (which Moodle 5.2 supports), and stray .DS_Store files in the package. No high or critical issues were found.
availability_xpstore is a compact, well-structured Restrict access condition that gates activities on a student's purchases in the companion local_xpstore plugin. Overall code quality is good: parameterised DB queries throughout, no raw SQL / filesystem / HTTP access, correct use of the core availability base classes, a correct null_provider privacy implementation, and correct omission of the MOODLE_INTERNAL guard in class files.
The main issue is missing output escaping of product/reward names. Names are concatenated into HTML both in the YUI editing form and in the student-facing availability description (get_description()) without format_string()/s(), whereas core's own availability_grade condition escapes the equivalent values and the framework offers description_format_string() for exactly this case.
- The exposure is partially mitigated: activity and grade-item names (the paths whose data I could verify) are already tag-stripped on input by
PARAM_TEXT/PARAM_CLEANHTML. - The genuinely unfiltered path is the store catalog's custom name, read from
local_xpstoreconfiguration, which is outside this review and requires an editing teacher / manager to populate.
Supporting findings are low severity: direct coupling to another plugin's internal table/config format, an implicitly-nullable parameter deprecated on PHP 8.4, and bundled .DS_Store files. No high or critical vulnerabilities were identified.
Findings
The plugin builds HTML from product/reward names without ever applying Moodle's output-escaping functions (format_string() or s()), in two distinct sinks:
-
Editing form (teacher-facing).
frontend::get_javascript_init_params()assembles a list of{id, name}objects from the store catalog config, grade-item names, and activity names, all raw. These are handed to the YUI module, which inform.jsconcatenatesreward.name(andreward.id) directly into the<option>markup via string building andY.Node.create(). -
Availability description (student-facing).
condition::get_description()embedsget_reward_name()— which returns the rawcatalog_course_Xcustom-name field — into therequires_reward/requires_not_rewardlanguage strings. Those strings contain<strong>{$a}</strong>, andget_string()does not escape{$a}. The availability subsystem renders this description as HTML:core_availability\info::format_info()returns the string unchanged when it contains noAVAILABILITY_*markers, so any markup in the name reaches the browser verbatim.
This contrasts with core's own availability_grade condition, which escapes the grade name with format_string() before passing it to its YUI form (with an explicit code comment), and with the framework helper \core_availability\condition::description_format_string(), which exists specifically so conditions can safely embed dynamic text in descriptions. Neither mechanism is used here.
The missing-escaping defect is fully present and verifiable in this plugin. Its exploitability is throttled by factors described in the risk assessment.
Medium risk. This is a genuine, verifiable output-encoding defect: dynamic names are injected into HTML in both a staff-facing form and a student-facing description with no format_string()/s() anywhere, diverging from the exact pattern core uses to prevent XSS in the same subsystem.
Mitigating factors keep it below high:
- The name paths whose data originates in code I could read (activity names, grade-item names) are tag-stripped on input by
PARAM_TEXT/PARAM_CLEANHTML, so a<script>/onerrorpayload cannot normally survive to these sinks. - The one path that bypasses input filtering (the store catalog custom name) depends on
local_xpstore's storage behaviour, which is outside this review — I could not confirm that a payload is actually storable there, so I cannot demonstrate a complete exploit. - Injecting into the catalog requires an elevated role (editing teacher / manager).
Blast radius if the catalog path is exploitable: the description is shown to students, so this would be a stored XSS reaching ordinary users, with a teacher→manager/admin escalation angle via the editing form. Because that outcome rests on unreviewed code, the finding is priced as a mitigated medium. The fix (escape on output in this plugin) is required regardless of local_xpstore's behaviour, and would also harden against any future input path.
is_available() is the access gate (server-side, called by core) and is safe — it uses a fully parameterised $DB->record_exists(). The XSS concern is purely about how names are displayed.
I verified the data sources against core:
cm_info->name(viaget_name()) returns the raw DB value; the escaped variant isget_formatted_name(). The plugin uses the raw->name.grade_item::get_name(true)appliesformat_string(), but the plugin bypasses it by readinggrade_items.itemnamedirectly with$DB->get_field().- Activity names are saved as
PARAM_TEXT(orPARAM_CLEANHTML), and grade-item names asPARAM_TEXT; both strip HTML tags / scripts on input. This substantially mitigates tag injection through the activity/grade-name paths. - The custom-name path reads
get_config('local_xpstore', 'catalog_course_X'). Config values are not subject toPARAM_TEXT, so this path is the one that can carry raw markup — but it is written bylocal_xpstore, which is not in this review.
The restrict-access editing UI is only rendered for users with moodle/course:manageactivities, so the form sink is staff-facing; the description sink is seen by any user (including students) who encounters the restricted activity.
The payload must enter through local_xpstore's per-course catalog configuration, which is outside this review, so this is a conditional PoC:
-
As an editing teacher/manager, in
local_xpstoreconfigure a course catalog product whose display name (the third:-delimited field stored inconfig_pluginsunderlocal_xpstore/catalog_course_<courseid>) is:U42:100:<img src=x onerror=alert(document.cookie)> -
Add an XP Store restrict-access condition referencing product
U42to an activity in that course. -
Student view: any user who views the restricted activity gets
get_description()output ofYou must purchase <strong><img src=x onerror=alert(document.cookie)></strong> in the XP Store., whichinfo::format_info()passes through to the page unescaped, executing the handler. -
Staff view: opening the activity's Restrict access panel builds
<option ...><img src=x onerror=...></option>from the unescaped name.
Step 1 only yields a working payload if local_xpstore stores the product name without sanitisation; if it strips tags, the description path is not exploitable.
classes/condition.php:94Source link unavailable — plugin was reviewed from zip without a matching git ref public function get_description($full, $not, \core_availability\info $info) {
$rewardname = $this->get_reward_name($info->get_course()->id);
if ($not) {
return get_string('requires_not_reward', 'availability_xpstore', $rewardname);
} else {
return get_string('requires_reward', 'availability_xpstore', $rewardname);
}
}
Wrap the dynamic name with the framework's deferred-escaping helper so it is passed through format_string() at render time:
public function get_description($full, $not, \core_availability\info $info) {
$rewardname = self::description_format_string(
$this->get_reward_name($info->get_course()->id)
);
if ($not) {
return get_string('requires_not_reward', 'availability_xpstore', $rewardname);
} else {
return get_string('requires_reward', 'availability_xpstore', $rewardname);
}
}
classes/condition.php:204Source link unavailable — plugin was reviewed from zip without a matching git ref protected function get_reward_name($courseid) {
$configraw = get_config('local_xpstore', 'catalog_course_' . $courseid) ?: '';
$items = array_filter(explode(',', $configraw));
foreach ($items as $item) {
$parts = explode(':', trim($item));
if (isset($parts[0]) && $parts[0] === $this->productid) {
if (!empty($parts[2])) {
return $parts[2];
}
}
}
return get_string('missing', 'availability_xpstore');
}
This helper returns the raw catalog_course_X custom-name field. Escaping should be applied at the point of HTML output (see the get_description() and frontend.php fixes) rather than here, so the value remains usable in non-HTML contexts. If you prefer to centralise it, return format_string($parts[2], true, ['context' => \context_course::instance($courseid)]).
classes/frontend.php:62Source link unavailable — plugin was reviewed from zip without a matching git ref $productid = $parts[0];
$customname = isset($parts[2]) ? trim($parts[2]) : '';
// If there's no custom name, we could try to resolve the activity name.
if (empty($customname)) {
$tipochar = substr($productid, 0, 1);
$cid = (int)substr($productid, 1);
if ($tipochar === 'M') {
global $DB;
$customname = $DB->get_field('grade_items', 'itemname', ['id' => $cid]);
} else if (isset($cms[$cid])) {
$customname = $cms[$cid]->name;
}
if (empty($customname)) {
$customname = $productid;
}
}
$rewards[] = (object)[
'id' => $productid,
'name' => $customname,
];
Escape each name with format_string() before returning it to JavaScript, mirroring core's availability_grade frontend:
$context = \context_course::instance($course->id);
// ...
$rewards[] = (object)[
'id' => $productid,
'name' => format_string($customname, true, ['context' => $context]),
];
Prefer $cms[$cid]->get_formatted_name() over the raw ->name property, and route the grade name through grade_item::get_name() (which applies format_string) instead of reading grade_items.itemname directly.
yui/src/form/js/form.js:70Source link unavailable — plugin was reviewed from zip without a matching git ref for (var i = 0; i < rewards.length; i++) {
var reward = rewards[i];
html += '<option value="' + reward.id + '">' + reward.name + '</option>';
}
Once the PHP side escapes the names with format_string(), this concatenation is safe (as it is in core's availability_grade). As defence in depth you may also build the option with DOM APIs that set text content rather than innerHTML, e.g. create an <option> node and assign node.set('text', reward.name). The matching built artefacts under yui/build/ must be regenerated with grunt after editing the source.
The plugin reaches directly into the internal storage of the companion local_xpstore plugin rather than through any published API:
is_available()queries the tablelocal_xpstore_gastosby its column names (userid,itemtype,itemid).get_reward_name()andfrontend::get_javascript_init_params()readget_config('local_xpstore', 'catalog_course_X')and parse a bespoke comma/colon-delimited string (productid:price:name).
All of these accesses are parameterised and safe from an injection standpoint, so this is a maintainability / robustness concern rather than a security one. If local_xpstore renames its table, changes column names, or alters the catalog string format, this plugin breaks silently: is_available() would simply report "not purchased" (locking students out of content they paid for), and the reward dropdown/description would show fallback placeholders. The bespoke parser is also fragile — a product name containing : or , would be mis-parsed.
The dependency is declared in version.php as ANY_VERSION, which provides no protection against an incompatible future schema.
Low risk. No security impact — all access is read-only and parameterised. The exposure is to breakage and silent misbehaviour if local_xpstore changes its internals, and to subtle bugs from the hand-rolled delimited-string parser. Blast radius is limited to this plugin's own correctness; it does not affect other users' data or security.
Availability conditions sometimes have to read data owned by other components, and this plugin's whole purpose is to reflect local_xpstore purchases, so some coupling is expected. The concern is that the coupling is to internal implementation details (raw table, raw config string format) duplicated across two files, with a ANY_VERSION dependency that cannot enforce compatibility.
classes/condition.php:73Source link unavailable — plugin was reviewed from zip without a matching git ref $haspurchased = $DB->record_exists('local_xpstore_gastos', [
'userid' => $userid,
'itemtype' => $type,
'itemid' => $itemid,
]);
Depend on a published API from local_xpstore (e.g. a local_xpstore\api::has_purchased($userid, $type, $itemid) method and a catalog accessor) instead of reaching into its table and config string directly. This isolates both plugins from each other's internal storage changes. If no such API exists, consider contributing one to local_xpstore.
classes/condition.php:205Source link unavailable — plugin was reviewed from zip without a matching git ref $configraw = get_config('local_xpstore', 'catalog_course_' . $courseid) ?: '';
$items = array_filter(explode(',', $configraw));
Retrieve the catalog through a local_xpstore accessor that returns structured data, rather than parsing a delimited config string in two separate places.
classes/frontend.php:52Source link unavailable — plugin was reviewed from zip without a matching git ref $configraw = get_config('local_xpstore', 'catalog_course_' . $course->id) ?: '';
$items = array_filter(explode(',', $configraw));
Share a single catalog-parsing routine (ideally provided by local_xpstore) between frontend.php and condition.php to avoid duplicated, drift-prone parsing logic.
frontend::get_javascript_init_params() declares \cm_info $cm = null and \section_info $section = null. Typed parameters given a null default without a leading ? are implicitly nullable, a pattern deprecated as of PHP 8.4. Moodle 5.2 requires PHP 8.3 as a minimum with no upper bound, so sites running PHP 8.4 will emit E_DEPRECATED notices each time the restrict-access JavaScript is prepared.
The core base method this overrides already uses the explicit form (?\cm_info $cm = null, ?\section_info $section = null), so matching it both silences the deprecation and keeps the signature consistent with the parent.
Low risk. Purely a forward-compatibility / code-quality issue with no security impact. It becomes visible as deprecation noise (and potential --fail-on-warning CI breakage) on PHP 8.4, which is within Moodle 5.2's supported range.
get_javascript_init_params() is invoked by core_availability\frontend::include_all_javascript() whenever the restrict-access UI is built for a course module. On PHP 8.4 the implicitly-nullable signature triggers a deprecation notice at that point.
classes/frontend.php:48Source link unavailable — plugin was reviewed from zip without a matching git ref protected function get_javascript_init_params($course, \cm_info $cm = null, \section_info $section = null) {
Mark the nullable parameters explicitly, matching the core base class:
protected function get_javascript_init_params($course, ?\cm_info $cm = null, ?\section_info $section = null) {
The package contains several .DS_Store files — macOS Finder metadata that should not be distributed with a Moodle plugin. They are present in the plugin root and in classes/, lang/, yui/, yui/build/, yui/src/, and yui/src/form/.
These are harmless at runtime but indicate the release was zipped directly from a developer's macOS working copy without cleanup. They add noise, can trip up automated packaging/validation, and should be excluded via .gitignore and the build/release process.
Low risk. No security or functional impact — strictly packaging hygiene. Worth fixing because Moodle's plugin validation and reviewers flag stray non-plugin files.
Found via a filesystem listing of the distributed plugin tree; seven .DS_Store files are present alongside the real source files.
.DS_StoreSource link unavailable — plugin was reviewed from zip without a matching git refRemove all .DS_Store files from the repository and add .DS_Store (and **/.DS_Store) to .gitignore so they are never committed or packaged again.
Spanish language pack is incomplete. lang/es/availability_xpstore.php is missing the requires_not_reward string that exists in lang/en. This is handled gracefully by Moodle's string fallback (Spanish users would see the English text for an inverted condition), so it is a completeness nit rather than a bug — but it is worth adding for parity.
pix/icon.svg is unusually large (~225 KB). I inspected it: it is a valid SVG that embeds base64-encoded JPEG and PNG raster images via <image xlink:href="data:...">, which explains the size. It contains no <script>, event handlers, or javascript: URIs, so it is not an XSS vector. However, a 225 KB icon defeats the purpose of using SVG and bloats the download; consider replacing it with a lightweight hand-authored vector icon.
No automated tests are shipped. The repository has no tests/ directory, yet the CI workflow runs phpunit and behat. For a plugin whose is_available() logic and restore-mapping code are non-trivial, a small PHPUnit test (e.g. around is_available() and update_after_restore()) would catch regressions and justify the configured CI steps.
Correct practices worth acknowledging. The privacy null_provider is appropriate (the plugin stores no personal data of its own and only reads local_xpstore records transiently); the class files correctly omit the MOODLE_INTERNAL guard; all database access is parameterised; the condition constructor type-checks productid as a string; and the YUI form integration correctly extends M.core_availability.plugin, matching the pattern used by core availability conditions.