MDL Shield

Restriction by installed plugin

availability_plugin

Print Report
Plugin Information

Availability condition (restrict access) plugin that makes an activity or section available depending on whether a given Moodle plugin, identified by its frankenstyle name, is present on the site. It includes an autocomplete list of installed plugins for users who hold availability/plugin:addinstance.

Version:2026100501
Release:v5.3-r2
Reviewed for:5.3
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-10-25
12 files·1,051 lines
Grade Justification

This is a small plugin with little attack surface. It has no pages, endpoints, DB writes, file handling or outbound requests. The one piece of user input, the plugin name stored in the availability JSON, is escaped with s() when the PHP description is rendered and with Y.Escape.html() in the YUI form. It is only ever passed to core_component::get_component_directory(), which resolves names against core's own plugin type map and never builds a filesystem path from the input.

The list of installed plugins is only sent to users who pass the plugin's own capability check. A null Privacy provider is declared, and that is correct because the plugin stores no personal data. Unit and Behat tests cover the main behaviour.

The review found two things, neither of them exploitable for real harm:

  • The addinstance capability only controls the editor UI. An editing teacher without it can still submit the condition through crafted availability JSON, and could use that to probe plugin names, which is minor information disclosure.
  • The condition checks whether the plugin's code is on disk, not whether the plugin is installed or enabled.

There is nothing at medium severity or above.

AI Summary

availability_plugin is a small restrict-access condition: two PHP classes, a null privacy provider, one capability and a YUI form module. It hides or shows course content depending on whether a named Moodle plugin is present. It has no pages, AJAX endpoints, database tables or outbound requests.

The security posture is good. The plugin name is escaped where it is rendered: with s() in get_description() and with Y.Escape.html() in the form. The only place it is used server-side is core_component::get_component_directory(), which looks names up in core's plugin map and never builds a path from them. The list of installed plugins sent to the JavaScript form is limited to users who hold availability/plugin:addinstance. By default nobody but the admin holds it.

Two things are worth knowing:

  • The capability gate exists only in the editor UI. Core's availability API has no server-side hook, so an editing teacher without the capability can still save the condition through crafted availability JSON. The impact is limited to probing which plugin names exist on disk.
  • The condition tests whether the plugin's code is on disk, not whether it is installed or enabled. That can differ from what the strings promise, for example when code has been uploaded but the upgrade has not run yet.

Findings

best practiceLow
addinstance capability only hides the UI; not enforced when saving

The availability/plugin:addinstance capability is checked only in frontend::allow_add() and get_javascript_init_params(), which control whether the condition appears in the Add restriction dialogue and whether the plugin list is sent to the browser. Core's availability API never calls allow_add() when saving. The availabilityconditionsjson value submitted with the activity or section form is accepted as long as each condition type exists. As a result, an editing teacher without the capability can still add a {"type":"plugin",...} condition by changing the hidden JSON field before submitting. The README notes that users without the capability can edit existing conditions, but it does not say that they can add new ones this way.

Risk Assessment

Low risk. The worst outcome is that a teacher can attach the condition to content they already control, and use it to test whether a plugin with a given name exists on the server. Teachers already see much of this through the activity chooser and the site UI. No other users' data is exposed or changed.

Context

core_availability\frontend::include_all_javascript() calls allow_add() only to decide which buttons the YUI dialogue shows. When the edit form is submitted, the tree is parsed and saved without checking the frontend's allow_add() again.

Identified Code
public function allow_add($course, ?\cm_info $cm = null, ?\section_info $section = null) {
    $context = $cm
        ? \context_module::instance($cm->id)
        : \context_course::instance($course->id);

    return has_capability('availability/plugin:addinstance', $context);
}
Suggested Fix

Core offers no server-side validation hook for availability conditions, so the plugin cannot fully enforce this itself. Say in the README and in the capability description that the capability controls the editor UI only, so that admins do not treat it as an access-control boundary.

code qualityInfo
Condition checks code presence on disk, not installed or enabled state

is_available_for_all() treats a plugin as installed if core_component::get_component_directory() returns a directory. That function only tells you the code is present in the code tree. It does not show whether the plugin's version is registered in config_plugins (i.e. the upgrade has run) or whether the plugin is enabled, for example a disabled activity module, auth or enrolment plugin. The language strings tell students 'The plugin X is installed', which may not be accurate in those states. For names without an underscore, core's normalize_component() also resolves bare names to mod_* or core subsystems, so assign and core both count as installed.

Risk Assessment

Informational. This is a functional nuance, not a vulnerability. Content can briefly appear while a newly uploaded plugin is waiting for its upgrade, or while a plugin is disabled.

Context

The result of this check alone decides whether content is shown to every user, because is_available() delegates to is_available_for_all().

Identified Code
$exists = (bool)\core_component::get_component_directory($this->pluginname);
Suggested Fix

If 'installed' should mean installed and upgraded, also check the plugin's version, e.g. get_config($this->pluginname, 'version') !== false, or use core_plugin_manager::instance()->get_plugin_info($this->pluginname) and its is_installed_and_upgraded() / is_enabled() methods. Otherwise, reword the strings to say 'present'.

Additional AI Notes

The plugin-list disclosure in get_javascript_init_params() is handled carefully. Core requests init params for every plugin, whatever allow_add() returns, and the plugin returns an empty list unless the user holds the capability. tests/frontend_test.php covers this.

The capability is declared with contextlevel => CONTEXT_MODULE but is also checked in course context when sections are edited. This works because has_capability() resolves through the context hierarchy. The context level only affects where the capability appears in the role override UI.

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