Restriction by other restrictions met at least once
availability_oncemet
Availability condition ("Once met") that wraps other availability restrictions and, once a user has fulfilled them, stores a permanent per-user unlock so the activity or course section stays available even if the nested restrictions later stop applying. Includes a per-restriction unlock report where staff can review and reset unlocks.
No security vulnerabilities were found after tracing every entry point.
unlocks.php, the only standalone page, callsrequire_login()with the course and course module before anything else runs. It then checksavailability/oncemet:viewunlocksin the context of the item.- The reset action needs both
require_sesskey()andavailability/oncemet:resetunlock. - The requested instance id must actually exist in the item's stored availability JSON before any data is shown or deleted.
- All SQL is parameterised. Table sorting goes through core's column allow-list in
flexible_table, and every user-derived value is escaped before output.
The findings are limited to:
- one low functional defect: restriction descriptions on the unlock report skip
info::format_info(), so activity and grade item names render wrongly - two info observations: a redundant table drop in
db/uninstall.php, and no audit event for unlock resets
The plugin has a complete Privacy API provider, cleanup observers for every relevant deletion and update event, and extensive PHPUnit and Behat coverage. Overall code quality is high.
availability_oncemet is a mid-sized availability condition plugin with about 1,900 lines of production PHP and YUI JavaScript plus a large test suite. It wraps other restrictions and writes one row per user to its own table when those restrictions are first met. It also provides a staff-only report (unlocks.php) for reviewing and resetting those unlocks.
The security posture is strong.
- The report page calls
require_login()with the course and course module before anything else runs. - Viewing and resetting are guarded by separate capabilities, checked in the item's own context, and the reset also needs
require_sesskey(). - The page only answers for instance ids that are really stored on the requested activity or section, so it cannot be used to probe unlocks elsewhere.
- All database access uses parameterised
$DBcalls, and sort columns go through core'sflexible_tableallow-list. - Output is escaped consistently, including the checkbox labels, which the core template renders unescaped.
- The YUI form builds DOM nodes through
setAttribute/set('text')rather than string HTML for any teacher-controlled values.
The only finding a site should act on is low and functional. On the unlock report, the description of the restriction is printed without passing through \core_availability\info::format_info(). Nested completion restrictions therefore lose their activity names, grade restrictions show a raw grade item id, and group, grouping and profile field names skip format_string() filtering. Two further info-level notes cover a redundant table drop in db/uninstall.php and the absence of a logged event when staff reset unlocks.
Findings
Core availability conditions do not return final HTML from get_description(). They return placeholders such as <AVAILABILITY_CMNAME_n/>, <AVAILABILITY_CALLBACK type="grade"> and <AVAILABILITY_FORMAT_STRING> (see description_cm_name(), description_callback() and description_format_string() in core's availability/classes/condition.php). Those placeholders are only resolved by \core_availability\info::format_info(). unlocks.php echoes $instancedescription straight into the page, so the placeholders reach the browser unresolved. The plugin's own PHPUnit tests always pass descriptions through format_info(), which is why this does not show up in the test suite.
Low risk. This is not a security issue: placeholder payloads are already htmlspecialchars()-encoded by core, so no markup can be injected. The effect is a misleading report for staff:
- activity names disappear from completion conditions
- grade conditions show a bare grade item id instead of the item name
- multilang group, grouping and profile field names are shown unfiltered
That makes it harder to tell which restriction a report is about, which is what the description is there for.
The unlock report shows which Once met restriction it covers by describing the restriction's nested conditions. It reuses condition::get_description(), which delegates to core's tree::get_full_information_recursive() for the nested conditions.
$instancedescription = (new condition($instance))->get_description(true, false, $info);
$instancedescription = \core_availability\info::format_info(
(new condition($instance))->get_description(true, false, $info),
$course
);
echo html_writer::div(
html_writer::tag('p', get_string('unlocks_intro', 'availability_oncemet', $itemname)) . $instancedescription,
'availability-oncemet-unlockrestriction mb-5 border rounded p-3'
);
xmldb_availability_oncemet_uninstall() gets the database manager and explicitly drops availability_oncemet. Core's uninstall_plugin() in lib/adminlib.php already calls drop_plugin_tables() after the plugin's uninstall hook. That function drops every table declared in db/install.xml (delete_tables_from_xmldb_file(), guarded by table_exists()). The explicit drop therefore adds nothing. It also adds a schema operation outside the upgrade path that reviewers and tooling will flag. It is harmless because it only touches the plugin's own table and checks that the table exists first.
Informational. There is no functional or security impact, because the drop is idempotent and limited to the plugin's own table.
The uninstall hook also counts activities and sections that still carry Once met JSON and warns the admin about them. That part is useful and correct.
$dbman = $DB->get_manager();
// If the unlock table still exists.
// Moodle drops the tables from db/install.xml on its own after this function has run, but we drop it
// explicitly to make sure that no unlock record outlives the plugin even if that ever changes.
$table = new xmldb_table('availability_oncemet');
if ($dbman->table_exists($table)) {
// Remove it.
$dbman->drop_table($table);
}
Remove the block and rely on core dropping the tables declared in db/install.xml. If belt-and-braces cleanup is wanted, $DB->delete_records('availability_oncemet') clears the data without any DDL.
unlocks::reset() permanently deletes the unlock records of the selected users, which can take access to an activity or section away from learners. The capability for this action is flagged RISK_DATALOSS. No \core\event\base subclass is triggered, so the action leaves no trace in the standard log. Neither the teacher who performed the reset nor the affected users can be identified afterwards. Defining an event (e.g. \availability_oncemet\event\unlocks_reset) with the context, instance id and affected user ids would give admins an audit trail when learners report lost access.
Informational. This is not exploitable. It is an accountability gap for a destructive, access-affecting staff action.
The reset is reached only from unlocks.php after require_sesskey() and a availability/oncemet:resetunlock capability check. Access control is correct; only auditability is missing.
$removed = $DB->count_records_select('availability_oncemet', $select, $params);
$DB->delete_records_select('availability_oncemet', $select, $params);
After the delete, trigger a plugin event in the item's context (module context for activities, course context for sections), with other carrying instanceid and the reset user ids.
Adapted TinyMCE snippet. yui/src/form/js/form.js contains about 40 lines of UUIDv4 helpers adapted from TinyMCE 8.2.2. TinyMCE is GPL v2+, which is compatible with the plugin's GPL v3+. The snippet carries a clear attribution comment and is intentionally not declared in thirdpartylibs.xml. The comment explains why: declaring a single source file would exclude the whole form module from linting. Given its size and that it is an adaptation rather than a bundled library, this is reasonable.
Uninstall consequence. When the plugin is uninstalled, core's lenient tree decoding silently drops the oncemet conditions that remain in availability JSON, along with everything nested inside them. Affected activities and sections may become available to everyone. The plugin handles this responsibly: db/uninstall.php counts the affected items and shows a warning, and the language string spells out the effect. Admins should still remove these restrictions before uninstalling.
By-design permanence. Unlocks are stored as soon as the nested restrictions evaluate true for a user, including when availability is evaluated on that user's behalf by other code paths. If teachers wrap a condition that learners control themselves (for example a user profile field the learner can edit), one temporary edit grants permanent access. This follows from the plugin's purpose rather than being a defect, but it is worth pointing out to course creators.
Unlock records are not backed up. There is no backup/restore support for unlock records. After a course is restored or copied, learners have to fulfil the nested restrictions again. That is defensible, because restored items get new course-module and section ids, but sites relying on unlocks across course rollovers should know about it.