local_unlistedcourses owns a single discoverability decision for each course and each course category — who may learn that it exists — stored in two of the plugin's own tables (local_unlistedcourses_state, local_unlistedcourses_catstate) with three states: listed (default, no row), unlisted, and public. It exposes viewer-aware predicates (access, category_access) that filter course and category listings per user, and viewer-independent fail-closed predicates (discoverability::is_public() / are_public(), category_discoverability::is_public() / are_public()) that gate what an anonymous visitor may be served. The state is written through a single capability-checked boundary (set_state()) reached from the course settings form (via core's after_form_definition / after_form_submission hooks), course backup/restore, and a dedicated category settings page (category.php). The plugin renders nothing on public surfaces itself — a consuming theme does that — and ships a full Privacy API provider, course backup/restore support, and two logged events.
The plugin is an unusually disciplined piece of Moodle engineering. Every database access goes through the $DB API with placeholders; the one place an identifier is interpolated into SQL (discoverability::state_sql()) validates both aliases against a strict identifier regex and is covered by a test that asserts malformed aliases are rejected. The single web entry point, category.php, calls require_login(), resolves the category through core_course_category::get(..., MUST_EXIST) (which enforces viewer visibility and throws otherwise), checks require_capability() on the category context, and performs all state changes through a moodleform (automatic sesskey). The security-sensitive write — entering or leaving the public state that exposes a page to anonymous visitors — is gated by a dedicated RISK_SPAM/RISK_PERSONAL capability inside set_state(), the one boundary every writer (form, web service, tool_uploadcourse, restore) passes through; course restore checks that capability against the restoring user and fails closed, logging the refusal. The anonymous predicates are viewer-independent and fail closed on missing rows, hidden courses/categories, and unknown stored state values. Output is XSS-safe: cohort names pass through format_string(..., 'escape' => false) into a double-stash Mustache slot. The Privacy API provider is complete across metadata, context/user discovery, export, and all three deletion paths. MOODLE_INTERNAL guards are present exactly where file-scope side effects require them and absent where the linter would reject them. The test suite is exceptional — control cases paired with every refusal, query-count budgets, and a mutation-testing harness — and the release zip is kept clean through .gitattributes export-ignore rules. No security vulnerabilities were found. The only issue is a single low-severity code-quality concern: the one-time v1-to-v2 upgrade deletes rows directly from core's customfield_* tables rather than through the customfield API, which couples the plugin to core's current schema and skips the API's own file-area cleanup.
Overview
local_unlistedcourses implements a three-state discoverability model (listed / unlisted / public) for both courses and course categories, stored in two of the plugin's own tables. It is a predicate-and-state library consumed by a site theme; it renders nothing on public-facing pages itself.
Security posture
The plugin is well above the median for Moodle plugins on every axis that matters:
- Database access — Every query uses
$DBwith bound parameters. The single identifier interpolated into SQL, indiscoverability::state_sql(), is validated against`/^[a-z][a-z0-9_]{0,29}$/`for both aliases, with a test asserting that'c d','c;','C','1c'and''are all rejected. No raw driver access anywhere. - The capability boundary — The dangerous transition (into/out of public, which arms a page for anonymous viewing on a
forceloginsite) is gated bylocal/unlistedcourses:publish/publishcategoryinsideset_state(), the single point every writer funnels through. The category-state write additionally requiresmanagecategorystate. Capabilities are declared manager-only, carry appropriateRISK_SPAM/RISK_PERSONALbitmasks, and deliberately omitclonepermissionsfrom. - The web entry point —
category.phpenforcesrequire_login(), category visibility viacore_course_category::get(..., MUST_EXIST),require_capability()on the coursecat context, and CSRF protection throughmoodleform. - Anonymous surface —
are_public()on both classes is viewer-independent and fail-closed: a missing course/category, a hidden course, a hidden or unlisted category anywhere on the path, and an unknown stored state value all yieldfalse. - Output encoding — The only dynamic string rendered (cohort names) uses
format_string(..., 'escape' => false)into a Mustache double-stash, the correct no-double-escape pattern; a test confirmsA & Bis emitted unescaped for Mustache to encode. - Privacy — A complete provider over both tables, keyed per context level, detaching
usermodifiedrather than deleting configuration rows.
Findings
One low code-quality finding: the legacy custom-field cleanup in legacy_field::remove() deletes directly from core's customfield_data, customfield_field and customfield_category tables during the v1→v2 upgrade instead of using the customfield handler/API. This couples the plugin to core's current customfield schema and bypasses the API's own file-area cleanup (api::delete_field_configuration() removes core_customfield description files). It runs once, operates only on the field this plugin provisioned, and uses parameterised deletes, so the real-world impact is limited to possible orphaned description-file rows.
No medium, high, or critical issues were identified.
Findings
legacy_field::remove(), invoked once from db/upgrade.php when upgrading from the first release, retires the unlisted course custom field by issuing raw $DB->delete_records() calls against three core-owned tables — customfield_data, customfield_field and customfield_category — rather than going through core's customfield handler/API (\core_course\customfield\course_handler::create()->delete_field_configuration() and \core_customfield\api::delete_category()).
Two consequences follow from bypassing the API:
- Coupling to core's current schema. The manual deletes enumerate exactly the three tables that exist today. If a future Moodle version adds another related table or changes cascade behaviour for custom fields, these statements will silently leave orphans, whereas the API would stay correct.
- Skipped side-effect cleanup.
\core_customfield\api::delete_field_configuration()also removes the field's file area (get_file_storage()->delete_area_files(..., 'core_customfield', 'description', $field->get('id'))). The direct delete does not, so any file embedded in the retired field's description would be left orphaned in the file store.
This is a code-quality / technical-debt concern, not a security issue: the code runs only during the one-time upgrade from the first release, operates solely on the field this plugin itself provisioned, and uses parameterised deletes keyed by the resolved field id.
Low risk. This is technical debt, not an exploitable condition. It executes only during the one-time upgrade from the first release, under the administrator-driven upgrade process, and only against the field this plugin created. There is no user input involved and the deletes are parameterised. The realistic downside is limited to orphaned core_customfield description file rows (if the retired field ever carried description files) and a latent coupling to core's customfield schema that could leave orphan rows if that schema changes in a future Moodle version. No user data is exposed or lost, and no privilege boundary is crossed.
The plugin's first release stored the discoverability flag in a core course custom field, which it provisioned at install time. The current release moved the state into the plugin's own table; db/upgrade.php calls legacy_field::migrate() (copying ticked checkboxes into local_unlistedcourses_state) and then legacy_field::remove() (deleting the field) in the $oldversion < 2026090200 upgrade step. A fresh install of the reviewed version never provisions the field and never reaches this code. The find() helper resolves the field strictly by the core_course/course handler, the unlisted shortname, the checkbox type, and the remembered category id, so unrelated fields are not touched — a point its test suite verifies.
public static function remove(): void {
global $DB;
$field = self::find();
if ($field) {
$DB->delete_records('customfield_data', ['fieldid' => $field->id]);
$DB->delete_records('customfield_field', ['id' => $field->id]);
if (!$DB->record_exists('customfield_field', ['categoryid' => $field->categoryid])) {
$DB->delete_records('customfield_category', ['id' => $field->categoryid]);
}
}
unset_config(self::CATEGORYID_CONFIG, 'local_unlistedcourses');
}
Prefer the customfield API, which owns the related cleanup and insulates the plugin from schema changes:
$handler = \core_course\customfield\course_handler::create();
$field = self::find();
if ($field) {
$fieldcontroller = \core_customfield\field_controller::create((int) $field->id);
$handler->delete_field_configuration($fieldcontroller);
// Then remove the provisioned category if it is now empty, via api::delete_category().
}
unset_config(self::CATEGORYID_CONFIG, 'local_unlistedcourses');
If the direct DML is retained deliberately — the API triggers field_deleted / category_deleted events, which the author may reasonably wish to avoid firing mid-upgrade — then add an explicit get_file_storage()->delete_area_files(...) call for the core_customfield description area so description files are not orphaned, and add a code comment pinning the set of tables to the core schema version it was written against so the coupling is visible to a future maintainer.
No third-party code is bundled. The repository contains no vendor/, node_modules/, minified assets, or JavaScript, so the absence of a thirdpartylibs.xml is correct. The .phpcsignore and .moodle-plugin-ci.yml entries for vendor/node_modules are defensive only.
The mutations/ directory is development tooling, not shipped code. It holds a custom mutation-testing spec (gates.conf plus per-guard Perl substitution scripts) that breaks one guard at a time to confirm the test suite catches it. It is correctly marked export-ignore in .gitattributes, so git archive strips it from the release zip, as are docs/, CI config, and linter config. Release hygiene is handled explicitly and well, with a note that tests/ is deliberately retained per Moodle convention.
The design places the capability check at the right boundary. set_state() is documented and tested as the single point every writer (course form hook, web service, tool_uploadcourse, course restore) passes through, and the sensitive public-state transition is gated there rather than in the form — so the check cannot be routed around. The listed↔unlisted transition deliberately carries no gate of its own because every caller already sits behind moodle/course:update; this is an intentional, documented trade-off affecting only listing visibility of a course the actor can already fully edit, not a privilege boundary.
The test suite is a model for the kind. Every "hidden" assertion is paired with a control proving the predicate actually ran, viewer-keyed memo caches are tested by switching users without a reset, and several tests measure query counts (e.g. 200 courses must cost the same number of reads as 2, spread across 20 categories to defeat a per-category batching false positive). The privacy provider even re-runs core's component-compliance check inside the plugin's own suite.
One cosmetic nit, not raised as a finding: db/install.xml carries VERSION="2026091300" while version.php is at 2026091301. This is expected — the XMLDB VERSION tracks the last schema change (the r4 release changed no schema), Moodle does not require it to match version.php, and moodle-cs does not flag it.
Published reviews of this plugin