Restriction by language
availability_language
availability_language is a Moodle availability condition that restricts access to course modules and sections based on the user's language. It adds a Language restriction option to the standard "Restrict access" UI so editing teachers can require (or exclude) a specific language for an activity or section. The condition only appears when more than one language pack is installed and the course language is not forced. There are no privacy implications — the plugin stores no personal data.
The plugin is small, focused, and follows the standard core_availability plugin pattern closely. The runtime check in condition::is_available() delegates to well-established core helpers (current_language(), \core_user::get_user(), $CFG->lang), and no SQL, filesystem, network, or shell access is performed by the plugin itself. The privacy provider is correctly declared as a null_provider. Test coverage is strong (PHPUnit + Behat, with a self-reported mutation score around 96%) and CI runs across Postgres, MariaDB, PHP 8.3/8.4 against Moodle 5.1/5.2.
The only issues found are minor: a missing null-check on \core_user::get_user($userid) that would surface only with an invalid user id, an unescaped language-name string injected into the YUI form HTML (the data source is admin-installed language packs, so practical risk is negligible), and an outdated Moodle version requirement in the README.md. None of these are exploitable by ordinary users.
Overview
availability_language is a well-scoped availability condition plugin. The implementation is small — two PHP classes (condition and frontend), one privacy null_provider, one YUI form module, and a strong test suite. All sensitive operations are delegated to core APIs (get_string_manager(), current_language(), \core_user::get_user()), and the plugin neither touches the database, the filesystem, nor the network directly.
Security posture
No exploitable security vulnerabilities were identified. The plugin performs no input handling of its own — the only piece of attacker-influenceable data is the id field of the JSON structure stored when a teacher configures the condition. That field is consumed only as a language code, compared against the values returned by get_string_manager()->get_list_of_translations() and $CFG->lang, and used as a lookup key against the trusted $langs array in get_description(). No SQL, filesystem, network, or shell access occurs.
Code quality
The code is clear, idiomatic for an availability plugin, and mirrors the structure of core conditions such as availability_grouping and availability_profile. The plugin uses match (true) with === comparisons to pick the language source — concise and correct. Tests cover both the front-end and back-end flows, including section restrictions, forced course language, and the single-language-pack edge case.
Minor observations
\core_user::get_user($userid)->langdoes not guard againstget_user()returningfalsefor a non-existent id (the defaultIGNORE_MISSINGstrictness silently returns false).- The YUI form interpolates the language pack display name directly into HTML without escaping, unlike the analogous core
availability_groupingplugin which wraps user-supplied names informat_string(). The data source here (thislanguagefrom each language pack) is admin-controlled, so this is a best-practice issue rather than a real XSS vector. README.mdstill states the plugin requires Moodle 3.9+, whileversion.phpdeclaressupported = [501, 502]and a$requiresvalue matching Moodle 5.1.
Findings
In condition::is_available(), when the supplied $userid is not the current user and is not null, the code resolves the user's language via \core_user::get_user($userid)->lang.
\core_user::get_user() uses the default strictness of IGNORE_MISSING, which returns false (not an object) when the user does not exist. Dereferencing ->lang on false raises a fatal TypeError in PHP 8.
In normal use this branch is reached with valid user ids passed in by core's availability framework, so the failure mode is unlikely. It would surface only in edge cases such as a stale user id supplied by a custom integration, a deleted-but-still-referenced user, or unit tests that pass an arbitrary id. Adding a strictness flag or an explicit guard would make the call robust.
Low risk. This is a robustness issue, not a security issue. There is no input validation problem and no attacker-controlled path. The crash would surface only when the framework is called with a user id that no longer exists in mdl_user, which is unusual in core flows. Using MUST_EXIST would also turn this into a clearer exception with backtrace, aiding debugging.
is_available() is called by core's availability engine when rendering activity/section visibility for a given user. The $userid parameter is typically a valid current id from get_fast_modinfo(). The match (true) chain selects one of three language sources, and the default branch handles "some other authenticated user" — the only path where the user record is loaded from the database.
$language = match (true) {
// Checking the language of the currently logged in user, so do not
// default to the account language, because the session language
// or the language of the current course may be different.
$userid === $USER->id => current_language(),
// When the userid is null then fall back to site language.
is_null($userid) => $CFG->lang,
// Checking access for someone else than the logged in user, so
// use the preferred language of that user account.
// This language is never empty as there is a not-null constraint.
default => \core_user::get_user($userid)->lang,
};
Use MUST_EXIST so a missing user produces a clear dml_missing_record_exception instead of a TypeError, or guard the result explicitly. For example:
default => (\core_user::get_user($userid, 'lang', MUST_EXIST))->lang,
or:
default => (function() use ($userid) {
$user = \core_user::get_user($userid, 'lang');
return $user ? $user->lang : $CFG->lang;
})(),
frontend::get_javascript_init_params() passes the raw output of get_string_manager()->get_list_of_translations() to the YUI form module. The values in that array are the localized thislanguage strings from each language pack (e.g. "English (en)"), and they are then concatenated directly into HTML by form.js without escaping.
The analogous core plugin availability_grouping explicitly wraps the displayed name in format_string() before sending it to JavaScript (and the matching form.js comment notes "String has already been escaped using format_string"). The language plugin does not perform any equivalent escaping.
In practice the data source is admin-controlled — only site administrators can install language packs — so this is a defensive coding gap rather than an exploitable XSS. A language pack whose thislanguage value contained raw HTML or quote characters would be rendered unescaped on the restriction-editing form (visible to editing teachers). The same title attribute is also written without quoting in form.js (title=' + tit + '>), which would break if the title string ever contained spaces or quotes.
Low risk. Only site administrators can install language packs, so the chain of trust prevents lower-privilege users from injecting markup. Core's bundled language packs and the official translation server provide plain-text values. Bringing the code in line with the equivalent availability_grouping plugin (format_string() server-side, and quoted attributes client-side) would close the defensive gap and avoid surprising behaviour if a third-party language pack ever contained unexpected characters.
The frontend supplies a list of language {id, name} objects to a YUI module that builds an <select> of language options on the availability editing form. id is restricted to a PARAM_SAFEDIR-cleaned language code (alphanumeric + underscore) by core, but name is the human-readable thislanguage value from each installed language pack, untouched by format_string() or s(). The page on which this HTML is rendered is the activity/section restriction editor, visible to users with moodle/course:update (editing teachers and above).
protected function get_javascript_init_params($course, ?cm_info $cminfo = null, ?section_info $sectioninfo = null) {
return [self::convert_associative_array_for_js(get_string_manager()->get_list_of_translations(), 'id', 'name')];
}
Pass language names through format_string() with a fixed context before handing them to JavaScript, mirroring the pattern used in availability_grouping/classes/frontend.php:
protected function get_javascript_init_params($course, ?cm_info $cminfo = null, ?section_info $sectioninfo = null) {
$context = \context_system::instance();
$translations = [];
foreach (get_string_manager()->get_list_of_translations() as $id => $name) {
$translations[] = (object)[
'id' => $id,
'name' => format_string($name, true, ['context' => $context]),
];
}
return [$translations];
}
var tit = M.util.get_string('title', 'availability_language');
var html = '<label class="mb-3"><span class="p-r-1">' + tit + '</span>';
html += '<span class="availability-language"><select class="form-select" name="id" title=' + tit + '>';
html += '<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.languages.length; i++) {
var language = this.languages[i];
html += '<option value="' + language.id + '">' + language.name + '</option>';
}
Quote the title attribute, and render text content through DOM APIs (e.g. Y.Node.create with attributes set via setAttribute/set('text', ...)) rather than string concatenation. At a minimum, quote the attribute value and rely on server-side format_string() for the option labels:
html += '<span class="availability-language"><select class="form-select" name="id" title="' + tit + '">';
Note that the corresponding moodle-availability_language-form-debug.js and moodle-availability_language-form-min.js files in yui/build/ are the shifter-generated outputs of yui/src/form/js/form.js and will be regenerated when the source is rebuilt.
The README.md states "This plugin requires Moodle 3.9+", but version.php declares $plugin->requires = 2025100600 and $plugin->supported = [501, 502] — i.e. Moodle 5.1 / 5.2. Anyone consulting the README before installing will be misled into thinking the plugin can be dropped onto a 3.9 site.
Informational. No security or functional impact. Worth correcting to avoid administrator confusion.
Documentation mismatch only — no runtime effect. The actual version requirement is enforced by Moodle's plugin installer using $plugin->requires.
## Requirements
This plugin requires Moodle 3.9+
Update the requirement to match version.php, for example:
## Requirements
This plugin requires Moodle 5.1 or later.
The plugin's privacy implementation is correctly limited to \core_privacy\local\metadata\null_provider with a privacy:metadata language string — appropriate because the plugin only reads transient data (current language) and does not store anything itself.
The YUI form module is the standard pattern for availability plugins; core's bundled availability_grouping, availability_profile, etc. still use YUI for the restriction editor, so this is not a deprecation concern in Moodle 5.2.
The unit test suite uses some test-only patterns that would normally be flagged ($DB->set_field('user', 'lang', ...), mkdir($CFG->dataroot/lang/...)). The accompanying comments explicitly justify them as workarounds for MDL-68333 when a language pack is not actually installed, and they are confined to tests/condition_test.php. They are acceptable for test fixtures.