Additional tools library for MuTMS plugins
tool_mulib
A shared utility library plugin (tool_mulib) for the MuTMS plugin suite for Moodle. It provides reusable infrastructure including: external database connectivity via PDO, notification management framework, context map caching for fast permission lookups, SQL builder helpers, autocomplete form elements, modal ajax forms, JSON schema validation, and various utility classes. It acts as a foundation library consumed by sibling plugins (tool_muprog, tool_mucertify, tool_mucatalog, tool_murelation, etc.).
The plugin is well-written by an experienced Moodle developer with strong security practices throughout. All web-facing pages have proper require_login() and appropriate capability checks. Forms use moodleform which enforces sesskey validation automatically. SQL queries use Moodle's $DB parameterized methods or properly cast integer constants.
The most significant finding is the direct PDO database access in the extdb feature, which bypasses Moodle's $DB API. However, this is the explicit, intentional purpose of the feature — providing managed external database connectivity — and it is fully gated behind moodle/site:config (admin only). The PDO queries use prepared statements. Since admins already have unrestricted server access, this does not create a new exploitable attack surface.
Additional low-severity findings include non-portable database-specific SQL for UPSERT operations, direct writes to the core role_assignments table for context map optimization, and duplicated upsert code. The Privacy API is properly implemented, third-party libraries are correctly declared in thirdpartylibs.xml, and the plugin includes both PHPUnit and Behat test suites.
No finding is exploitable by any user below admin level.
Plugin Overview
tool_mulib is a shared utility library for the MuTMS plugin suite. It provides reusable infrastructure for sibling plugins and does not have significant standalone user-facing functionality.
Key Features Reviewed
- External Database (extdb): Admin-only PDO-based connector for querying external databases. Server credentials (DSN, user, password) and SQL queries are stored in the Moodle DB and managed via admin pages.
- Notification Framework: Abstract notification management system used by other MuTMS plugins. Access control is delegated to implementing components via
can_view()/can_manage()abstract methods. - Context Map Cache: Performance optimization for permission lookups. Maintains a denormalized context relationship cache in
tool_mulib_context_mapandtool_mulib_context_parenttables, rebuilt via scheduled task. - SQL Builder: Immutable SQL fragment class with parameter merging and comment-based templating for composing complex queries safely.
- Ajax Forms: Modal dialog form infrastructure used across MuTMS plugins.
- Autocomplete Elements: Base classes for AJAX-powered autocomplete form fields.
Security Architecture
- All extdb management pages require
moodle/site:config - Query listing page requires
tool/mulib:useextdb(manager archetype) - Notification pages delegate access control to component plugins
- All forms use
moodleform(automatic sesskey validation) - SQL queries use parameterized
$DBmethods throughout (except intentional PDO usage for external DBs) - Output is consistently escaped with
s(),format_string(),clean_text()
Third-Party Libraries
Bundles opis/json-schema 2.6.0, opis/string 2.1.0, opis/uri 1.1.0, and Composer 2.8.8 autoloader — all properly declared in thirdpartylibs.xml.
Findings
The pdb class instantiates new PDO() directly to connect to external database servers, bypassing Moodle's $DB database abstraction layer. This is flagged because the review rules list raw PDO connections as a critical-level pattern.
However, this is the explicit, intended purpose of the extdb feature — providing managed connectivity to databases that are external to Moodle, which the $DB API does not support. The feature uses PDO prepared statements for query execution, and all management operations require moodle/site:config (admin only).
Downgraded from critical to medium because:
- Access control: Only admins can configure servers and queries
- Prepared statements: Query parameters use
PDO::prepare()+execute() - No user input in connection strings: DSN, credentials, and SQL are admin-configured
- Admins already have full server access — this feature does not grant new capabilities
Medium risk. The direct PDO usage is intentional and necessary for the feature's purpose. It is fully gated behind the highest Moodle privilege (moodle/site:config). An admin who can configure external database connections already has unrestricted access to the server, so this feature does not expand the attack surface. The main risk scenario is a compromised admin account being used for lateral movement to internal databases — but a compromised admin can already achieve this via other means.
The pdb class is instantiated from query::__construct() which loads server credentials from tool_mulib_extdb_server. The server record is created/updated via server::create() / server::update() which are called from extdb/server_create.php and extdb/server_update.php — both requiring moodle/site:config. The SQL queries are stored in tool_mulib_extdb_query.sqlquery and managed via similar admin-only pages.
An admin navigates to Site administration > Server > External databases > Servers, creates a new server entry with an arbitrary DSN (e.g., pgsql:host=internal-db.local;dbname=sensitive_db), then creates a query with arbitrary SQL (e.g., SELECT * FROM users). This allows querying any database accessible from the Moodle server. However, the admin already has equivalent access via config.php, CLI, and other admin tools.
$this->pdo = new PDO($this->dsn, $this->dbuser, $this->dbpass, $this->dboptions);
This is an intentional design for external database connectivity. No change recommended — the admin-only access control is sufficient. Consider adding a comment documenting the security review rationale.
public function query(string $sql, array $params = []): rs {
$this->require_connection();
$pdoparams = $this->fix_pdo_params($sql, $params);
$statement = $this->pdo->prepare($sql, []);
$statement->execute($pdoparams);
return new rs($statement);
}
The context_map class writes fake role assignment records into the core role_assignments table using a magic user ID constant (-999666). This is used to represent default user roles (from $CFG->defaultuserroleid and $CFG->defaultfrontpageroleid) in the context map permission queries without requiring special SQL branches.
While Moodle core does not provide an API for this specific use case, directly manipulating core tables carries risk if the magic ID convention conflicts with other code or if the cleanup logic fails.
Low risk. The magic user ID -999666 cannot conflict with real user records. The records are tagged with component = 'tool_mulib' for identification and are maintained (created/deleted) on each call. The pattern is a workaround for a missing core API and is a recognized technique in Moodle plugin development. No security impact — this is a code quality observation.
The add_default_role_hacks() method is called from get_contexts_by_capability_join() which builds SQL queries for fast permission lookups. The fake role assignments allow the context map queries to handle default roles uniformly without separate SQL logic. The records use userid = -999666 which cannot collide with real users (Moodle user IDs are positive integers).
protected static function add_default_role_hacks(): void {
global $DB, $CFG;
$expected = [];
if (!empty($CFG->defaultuserroleid)) {
$syscontext = \context_system::instance();
$expected[$syscontext->id] = (int)$CFG->defaultuserroleid;
}
if (!empty($CFG->defaultfrontpageroleid)) {
$frontpagecontxt = \context_course::instance(get_site()->id);
$expected[$frontpagecontxt->id] = (int)$CFG->defaultfrontpageroleid;
}
$hacks = $DB->get_records('role_assignments', ['userid' => self::MAGIC_DEFAULT_USER_ID, 'component' => 'tool_mulib']);
foreach ($hacks as $ra) {
if (!isset($expected[$ra->contextid]) || $expected[$ra->contextid] != $ra->roleid) {
$DB->delete_records('role_assignments', ['id' => $ra->id]);
continue;
}
unset($expected[$ra->contextid]);
}
foreach ($expected as $contextid => $roleid) {
$DB->insert_record('role_assignments', [
'contextid' => $contextid,
'userid' => self::MAGIC_DEFAULT_USER_ID,
'roleid' => $roleid,
'timemodified' => time(),
'component' => 'tool_mulib',
]);
}
}
This is a known pattern when core lacks appropriate APIs. The component field is set to tool_mulib for identification, and cleanup is performed on each invocation. Consider documenting in db/uninstall.php that these records should be removed on plugin uninstall.
Multiple classes use database-engine-specific SQL syntax for UPSERT operations:
- PostgreSQL:
INSERT ... ON CONFLICT (...) DO UPDATE SET ... - MySQL:
INSERT ... ON DUPLICATE KEY UPDATE ...
Additionally, context_map_builder uses engine-specific UPDATE join syntax (MySQL multi-table UPDATE vs PostgreSQL UPDATE...FROM).
Moodle requires cross-database portability. The code does include fallback implementations for unsupported engines (MS SQL Server), so the plugin won't break on those platforms — but the engine-specific paths are preferred for performance.
Low risk. The non-portable SQL is a deliberate performance optimization for concurrent operations. All engine-specific paths have fallback implementations. The plugin explicitly declares incompatibility with unsupported platforms via environment.xml (Oracle check). This is a code quality finding, not a security risk.
The UPSERT operations are used for concurrent-safe inserts in the context map cache. The engine-specific SQL ensures atomicity under concurrent writes, which the generic fallback (SELECT then INSERT/UPDATE) cannot guarantee without explicit locking. The version.php declares $plugin->incompatible = 500 so the plugin targets Moodle 4.5 only.
public static function upsert_record_pgsql(string $table, stdClass $dataobject, array $uniqueindexcolumns, array $insertonlyfields): void {
// ...
$sql = "INSERT INTO {{$table}} ($fields) VALUES ($values)
ON CONFLICT ($constraint) DO UPDATE SET $updates";
$DB->execute($sql, $params);
}
The engine-specific SQL is used for performance in concurrent environments. The fallback path handles unsupported engines. Moodle 4.5+ provides $DB->insert_or_update_record() which could replace this custom implementation.
protected static function upsert_record_mysql(string $table, stdClass $dataobject, array $uniqueindexcolumns, array $insertonlyfields): void {
// ...
$sql = "INSERT INTO {{$table}} ($fields) VALUES ($values)
ON DUPLICATE KEY UPDATE $updates";
$DB->execute($sql, $params);
}
if ($DB->get_dbfamily() === 'mysql') {
$sql = /** @lang=MySQL */
"UPDATE {tool_mulib_context_map} map, {tool_mulib_context_parent} p
SET map.relatedcontextid = p.parentcontextid
WHERE map.contextid = p.contextid AND map.distance = 1
AND map.relatedcontextid <> p.parentcontextid";
} else {
$sql =
"UPDATE {tool_mulib_context_map}
SET relatedcontextid = p.parentcontextid
FROM {tool_mulib_context_parent} p
WHERE {tool_mulib_context_map}.contextid = p.contextid AND {tool_mulib_context_map}.distance = 1
AND {tool_mulib_context_map}.relatedcontextid <> p.parentcontextid";
}
The upsert_record() method and its helper methods (upsert_record_pgsql, upsert_record_mysql, validate_upsert_record_arguments) are duplicated identically across two classes:
tool_mulib\local\mulib— the main utility classtool_mulib\local\mudb— a database-specific helper class
This appears to be a refactoring where the logic was extracted to mudb but the original in mulib was kept for backward compatibility with existing callers in sibling plugins.
Low risk. Code duplication is a maintenance concern, not a security risk. Both copies are functionally identical and use parameterized queries. The risk is that a future bug fix may only be applied to one copy.
Both classes provide identical upsert functionality. The context_map_builder uses mudb::upsert_record() while other code may use mulib::upsert_record(). Having two copies increases maintenance burden — a fix applied to one might not be applied to the other.
Consolidate the upsert logic into mudb and have mulib::upsert_record() delegate to mudb::upsert_record(), or deprecate one of the two copies.
The tool_mulib_extdb_server table stores external database passwords (dbpass field) in plaintext. Anyone with direct database access to the Moodle database can read these credentials.
This is consistent with Moodle's own pattern — config.php stores the main database password in plaintext, and Moodle's $CFG->dbpass is accessible to all PHP code. The extdb feature follows the same security model: the credentials are accessible only to site admins and database administrators.
Low risk. Plaintext credential storage follows Moodle's established pattern. The credentials are only accessible to those who already have Moodle database access (DBAs) or moodle/site:config (admins). This is a best-practice recommendation, not an exploitable vulnerability.
External database server records are created and managed exclusively by admins (moodle/site:config required). The password field is never exposed in reports or web service responses. The server update form (server_update.php) uses a change-password pattern where the existing password is not displayed.
<FIELD NAME="dbpass" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false" COMMENT="DB user password"/>
Consider encrypting stored passwords using encrypt_value() / decrypt_value() from Moodle's encryption API (available since Moodle 3.11). This would protect credentials at rest if the database is compromised independently of the application server.
The plugin writes records into the core role_assignments table (with component = 'tool_mulib' and userid = -999666) for the context map feature. However, there is no db/uninstall.php file to clean up these records when the plugin is uninstalled.
Orphaned role assignment records with a non-existent user ID would remain in the database after uninstallation.
Low risk. Orphaned records with a negative user ID (-999666) would not cause functional issues — they would simply be dead data. However, they could cause confusion during database audits. This is a best-practice violation, not a security risk.
The context_map::add_default_role_hacks() method creates role assignments for a magic user ID. On plugin uninstall, Moodle automatically drops the plugin's own tables but does not clean up records in core tables. Without explicit cleanup in db/uninstall.php, the fake role assignments persist.
| Library | Version | License | Declared |
|---|---|---|---|
opis/json-schema JSON Schema validation used by the json_schema helper class for data validation | 2.6.0 | Apache-2.0 | ✓ |
opis/string Unicode string handling dependency of opis/json-schema | 2.1.0 | Apache-2.0 | ✓ |
opis/uri URI handling dependency of opis/json-schema | 1.1.0 | Apache-2.0 | ✓ |
Composer Autoloading for bundled vendor libraries | 2.8.8 | MIT | ✓ |
The plugin is a shared library (tool_mulib) used by the MuTMS plugin suite. Many of its classes are abstract base classes consumed by sibling plugins. The notification management, autocomplete elements, and ajax form infrastructure are designed to be extended, meaning security in consuming plugins depends on correct implementation of the abstract methods (e.g., can_view(), can_manage()).
The context map feature (context_map, context_map_builder) is an impressive performance optimization that pre-computes context parent relationships into a denormalized cache table. This enables fast SQL-based capability checks using JOINs rather than PHP-based has_capability() calls — critical for pages that need to filter large result sets by permission. The scheduled task rebuilds the cache nightly, and event observers keep it incrementally updated.
The plugin explicitly declares incompatibility with Moodle 5.0+ ($plugin->incompatible = 500) and only supports Moodle 4.5 ($plugin->supported = [405, 405]). The environment.xml file also blocks Oracle databases and warns about Windows. These constraints justify the database-specific SQL usage since the plugin only targets PostgreSQL and MySQL on Moodle 4.5.
The ajax form pattern (ajax_form_trait, AjaxFormModal JS module) is a well-implemented custom alternative to Moodle's core_form\dynamic_form / modalform API. It handles form rendering, submission, and cancellation via AJAX with proper sesskey validation (inherited from moodleform). The pattern works by having the form's PHP script return JSON with status/html/javascript payloads, processed by the JS modal handler.
All notification pages delegate access control to the component's manager class via abstract methods (can_view, can_manage, get_instance_context). This is a clean design that avoids hardcoding access rules in the library. The trade-off is that security depends on correct implementation in each consuming plugin — but this is inherent to any library pattern.