MDL Shield

XP Store Availability - By E.P. Studio

availability_xpstore

Print Report
Plugin Information

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.

Privacy API
Unit Tests
Behat Tests
Reviewed:2026-10-02
10 files·836 lines
Grade Justification

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.

AI Summary

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_xpstore configuration, 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

securityMedium
Product/reward names rendered into HTML without output escaping (stored XSS vector)
Exploitable by:
editingteachermanager

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 in form.js concatenates reward.name (and reward.id) directly into the <option> markup via string building and Y.Node.create().

  • Availability description (student-facing). condition::get_description() embeds get_reward_name() — which returns the raw catalog_course_X custom-name field — into the requires_reward / requires_not_reward language strings. Those strings contain <strong>{$a}</strong>, and get_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 no AVAILABILITY_* 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.

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>/onerror payload 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.

Context

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 (via get_name()) returns the raw DB value; the escaped variant is get_formatted_name(). The plugin uses the raw ->name.
  • grade_item::get_name(true) applies format_string(), but the plugin bypasses it by reading grade_items.itemname directly with $DB->get_field().
  • Activity names are saved as PARAM_TEXT (or PARAM_CLEANHTML), and grade-item names as PARAM_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 to PARAM_TEXT, so this path is the one that can carry raw markup — but it is written by local_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.

Proof of Concept

The payload must enter through local_xpstore's per-course catalog configuration, which is outside this review, so this is a conditional PoC:

  1. As an editing teacher/manager, in local_xpstore configure a course catalog product whose display name (the third :-delimited field stored in config_plugins under local_xpstore / catalog_course_<courseid>) is:

    U42:100:<img src=x onerror=alert(document.cookie)>

  2. Add an XP Store restrict-access condition referencing product U42 to an activity in that course.

  3. Student view: any user who views the restricted activity gets get_description() output of You must purchase <strong><img src=x onerror=alert(document.cookie)></strong> in the XP Store., which info::format_info() passes through to the page unescaped, executing the handler.

  4. 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
Affected Code
    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);
        }
    }
Suggested Fix

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
Affected Code
    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');
    }
Suggested Fix

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
Affected Code
                $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,
                ];
Suggested Fix

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
Affected Code
            for (var i = 0; i < rewards.length; i++) {
                var reward = rewards[i];
                html += '<option value="' + reward.id + '">' + reward.name + '</option>';
            }
Suggested Fix

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.

best practiceLow
Tight coupling to local_xpstore's internal table and config format with no API abstraction

The plugin reaches directly into the internal storage of the companion local_xpstore plugin rather than through any published API:

  • is_available() queries the table local_xpstore_gastos by its column names (userid, itemtype, itemid).
  • get_reward_name() and frontend::get_javascript_init_params() read get_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.

Risk Assessment

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.

Context

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
Identified Code
        $haspurchased = $DB->record_exists('local_xpstore_gastos', [
            'userid' => $userid,
            'itemtype' => $type,
            'itemid' => $itemid,
        ]);
Suggested Fix

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
Identified Code
        $configraw = get_config('local_xpstore', 'catalog_course_' . $courseid) ?: '';
        $items = array_filter(explode(',', $configraw));
Suggested Fix

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
Identified Code
        $configraw = get_config('local_xpstore', 'catalog_course_' . $course->id) ?: '';
        $items = array_filter(explode(',', $configraw));
Suggested Fix

Share a single catalog-parsing routine (ideally provided by local_xpstore) between frontend.php and condition.php to avoid duplicated, drift-prone parsing logic.

code qualityLow
Implicitly nullable parameter types deprecated on PHP 8.4

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.

Risk Assessment

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.

Context

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
Identified Code
    protected function get_javascript_init_params($course, \cm_info $cm = null, \section_info $section = null) {
Suggested Fix

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) {
code qualityLow
macOS .DS_Store files bundled in the plugin package

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.

Risk Assessment

Low risk. No security or functional impact — strictly packaging hygiene. Worth fixing because Moodle's plugin validation and reviewers flag stray non-plugin files.

Context

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 ref
Suggested Fix

Remove all .DS_Store files from the repository and add .DS_Store (and **/.DS_Store) to .gitignore so they are never committed or packaged again.

Additional AI Notes

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.

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