Vault - Site backup and migration
tool_vault
A more recent review of this plugin is available. View the latest review →
Vault — Site backup and migration (tool_vault) is an admin tool plugin that integrates with the lmsvault.io cloud service to perform full-site backup and restore. It exports the entire Moodle database (table-by-table JSON dump plus the XMLDB structure), the contents of dataroot, and the file pool (filedir) into encrypted ZIP archives, uploads them to S3 via pre-signed URLs returned by the API, and on a target site downloads, validates and replays the backup back into the database, dataroot and filedir.
The plugin ships its own copies of historical Moodle core/standard-plugin upgrade scripts (3.9 → 3.11.8 → 4.1.2 → 4.2.3) so that a backup taken on an older site can be restored into a newer Moodle and brought up to date in a single step. Restore is disabled by default and must be explicitly enabled via plugin settings or $CFG->forced_plugin_settings. Backup, restore and pre-checks can also be run from CLI (cli/site_backup.php, cli/site_restore.php, cli/list_remote_backups.php).
All site-administration entry points (index.php, settings, fragments, dynamic forms, UI actions) are gated by admin_externalpage_setup / require_capability('moodle/site:config') and require_sesskey(). The progress page (progress.php) is intentionally accessible without a session because the site is in maintenance mode during backup/restore — it is protected by a 32-character random accesskey stored in the operation row.
The codebase is a large, well-architected and idiomatic Moodle plugin. Every web-facing entry point goes through admin_externalpage_setup with moodle/site:config, every state-changing action calls require_sesskey(), and the dynamic form re-checks the capability inside check_access_for_dynamic_submission(). Database access is exclusively through $DB parameterised queries (the few places that interpolate values into SQL — sequence/auto-increment fixes, cleanup_existing_files, the dbstructure helpers — interpolate either integers from the DB or table/field names that have already passed XMLDB validation). HTTP traffic uses Moodle's \curl wrapper extended in local\helpers\curl. Filesystem work uses the standard Moodle helpers (make_backup_temp_directory, make_writable_directory, make_unique_writable_directory); reads/writes to $CFG->dataroot are scoped to the __vault_restore__ working directory and to top-level paths the plugin owns by design. The Privacy API is implemented as a null_provider plus an add_external_location_link declaration for lmsvault.io, which is correct for a plugin that ships data off-site without storing personal data locally. No critical, high or medium findings were identified.
The plugin's threat model — that the API at lmsvault.io and the backups it serves are trusted by the admin who registers the API key — is appropriate and explicitly documented in the README (restores are off by default, can be locked off via $CFG->forced_plugin_settings, and the CLI requires --allow-restore to bypass). Within that trust model, only a small number of low-severity issues remain: a theoretical self-XSS path through unescaped plugin display-names taken from backup metadata, a non-existent restorepreservetables setting referenced by a code path that consequently never matches, and some MySQL/PostgreSQL-specific SQL in the structure helper. None of these meaningfully degrade the security posture for an authenticated admin operating against a legitimate Vault account.
Overview
tool_vault 3.9.17 is a site-level backup and restore tool with a paired SaaS backend (lmsvault.io). It is a substantial codebase — roughly 13k of the ~17k PHP lines are reproductions of historical Moodle core / standard-plugin upgrade scripts kept inside classes/local/restoreactions/upgrade_311|401|402/, used to upgrade an older restored site to a modern version in one go. The remaining ~4k lines are the actual plugin: a clean, modern, namespaced architecture with separate models, operations, checks, restoreactions, tools, uiactions, xmldb and helpers packages.
Security posture
The plugin handles sensitive operations correctly:
- Authorisation:
index.phpcallsadmin_externalpage_setup('tool_vault_index', ..., 'moodle/site:config'), which both verifies the admin session and enforces the capability. The dynamic API-key form revalidates the same capability incheck_access_for_dynamic_submission(). The fragment handler inlib.phpcallsrequire_capability('moodle/site:config', context_system::instance()). The plugin never trusts the URLsection/actionparameters — they are filtered withPARAM_ALPHANUMEXTand resolved by class existence before instantiation inlocal\uiactions\base::get_handler(). - CSRF: every state-changing UI action (
backup_newcheck,backup_startbackup,restore_dryrun,restore_restore,restore_resume,restore_updateremote,vaulttools,main_forgetapikey) callsrequire_sesskey()and addssesskey()to its ownurl(). Form submissions go through Moodle'sdynamic_form/moodleform, which carry sesskey automatically. - Database: all reads/writes go through
$DB.dbops::insert_recordsuses bind parameters; field names are passed through$dbgen->getEncQuoted(); table names originate from the XMLDB layer which validates them. The few raw SQL string concatenations only embed values that are guaranteed integers (e.g.$logger->get_model()->id). - HTTP: requests go through Moodle's
\curl; the local subclass setsignoresecurity = true, which is appropriate because it is only used to talk to pre-signed S3 URLs returned by the trusted API. AWS encryption headers are sent only when the URL matcheshttps://[host].s3.amazonaws.com/. - Filesystem: temp files use
make_backup_temp_directory('tool_vault')/make_unique_writable_directory(). Dataroot writes are confined either to top-level paths owned by the plugin (__vault_restore__) or to relative paths produced by extracting backup archives viazip_packer::extract_to_pathname()(which guards against zip-slip in core). - Cryptography: backup encryption keys are derived as
base64(sha256(passphrase))and used as thex-amz-server-side-encryption-customer-key. Encryption keys are wiped fromtool_vault_operation.detailsafter each operation finishes or fails (set_details(['encryptionkey' => ''])). - Access tokens: the
progress.phpcapability URL is gated by a 32-characteraccesskeygenerated withrandom_string(32)(62^32 entropy). The page deliberately runs without cookies / session because the site is in maintenance mode during a backup or restore.
Trust model and design notes
The README is explicit that Vault restores are off by default and that a malicious or compromised API account would be able to push arbitrary content into a site (database rows, $CFG overrides, dataroot files). This is a property of any whole-site backup/restore tool. The code makes the threat model as narrow as is reasonable:
- Restores require the
allowrestoreplugin setting and a valid API key tied to a remote backup; both can be removed at the host level via$CFG->forced_plugin_settings. - The CLI requires
--allow-restoreeven when the plugin setting is off, ensuring the admin owning the host explicitly opts in. - A
clionlysetting can completely disable the web interface. - Backup/restore acquire a per-operation "in progress" record, switch the site to maintenance mode via the
after_confighook, and refuse to start a second concurrent operation. - During restore, plugins listed in
restorepreservepluginsare protected with carefully-scoped SQL inplugindata::get_sql_for_plugins_data_in_table_to_preserve().
Findings
Nothing reaches medium severity. Three low-severity findings are reported:
- Plugin display-names taken from a remote backup are rendered with mustache triple-stash in the restore pre-check details template — a self-XSS path that requires the admin to restore a hand-crafted malicious backup.
siteinfo::is_table_preserved_in_restore()reads arestorepreservetablesplugin setting that is never registered insettings.php; the wildcard branch is therefore dead code (the file even carries aTODOcomment to that effect).xmldb\dbstructure::retrieve_sequences_*andget_actual_tables_sizes()use MySQL- and PostgreSQL-specific SQL (SHOW TABLE STATUS,set @@information_schema_stats_expiry,pg_total_relation_size,information_schema.constraint_column_usage) that would not work onsqlsrvoroci. The plugin is effectively a MySQL/MariaDB+PostgreSQL plugin in practice.
Tests / docs
The plugin ships PHPUnit tests for the core helpers (dbops, tempfiles, siteinfo, cli_helper, dbstructure, dbtable, operation_model, cleanup_existing_files, the privacy provider, site_backup, site_restore, install) and four behat features (free / light / pro / extended). version.php declares MATURITY_STABLE with supported = [39, 501] and requires = 2020061500 (Moodle 3.9), matching the new versioning scheme described in the changelog.
Findings
In templates/checks/plugins_restore_details_table.mustache, the plugin display-name and frankenstyle-name fields use mustache triple-stash ({{{name}}}, {{{pluginname}}}) instead of the auto-escaping double-stash. The values are produced in plugins_restore::plugin_with_name() from the array returned by consolidate(), where the first element comes from the backup metadata retrieved over the API ($parent->get_metadata()['plugins']) and only the second element is the locally-trusted name from core_plugin_manager.
A backup-side admin who controls a custom plugin's displayname can therefore inject HTML/JavaScript that will render in the restoring site's admin UI when the Add-on plugins pre-check details are viewed.
This is self-XSS: the only viewer is a site administrator who has chosen to register an API key, fetched a specific backup and clicked through to the per-check report. It cannot be triggered by lower-privileged users, by unauthenticated visitors, or by data already on the receiving site. There is no path to escalate beyond the admin's existing privileges.
Low risk. Triggering this requires the admin to (a) be running a Vault account, (b) explicitly initiate a restore pre-check or restore against an attacker-controlled backup, and (c) view the per-plugin breakdown. The result is JavaScript executing in the same admin's session — no privilege escalation, no cross-tenant impact. The cost of escaping the value is zero, so it should still be fixed.
plugins_restore::perform() reads the list of plugins straight out of $parent->get_metadata()['plugins'], where $parent is a dryrun_model whose remotedetails were populated by site_restore_dryrun::execute_prechecks() from __metadata__.json inside the downloaded dbstructure.zip. There is no server-side sanitisation of plugin display-names between the metadata file on disk and the mustache template.
- On a malicious source site, register a local plugin whose
lang/en/local_evil.phpdefines$string['pluginname'] = '<img src=x onerror=alert(1)>';. - Run a Vault backup of that site.
- From a victim site, after registering the same Vault account, open the remote backup and click Run pre-checks — the script runs in the admin's browser when the Add-on plugins check details are rendered.
{{#hasname}}
<div class="displayname">
{{{name}}}
</div>
{{/hasname}}
<div style="color: #6a737b;" class="componentname">
{{{pluginname}}}
</div>
Switch to escaping mustache and (where the value really is meant to contain markup) pre-format with format_string() on the PHP side:
{{#hasname}}
<div class="displayname">{{name}}</div>
{{/hasname}}
<div style="color: #6a737b;" class="componentname">{{pluginname}}</div>
Alternatively, run the metadata-derived name through format_string() / clean_param($name, PARAM_TEXT) in plugins_restore::plugin_with_name() before exporting it for the template.
protected function plugin_with_name(string $pluginname) {
$info = $this->model->get_details()['list'][$pluginname];
$name = $info[1]['name'] ?? $info[0]['name'] ?? null;
return [
'name' => $name,
'hasname' => !empty($name),
'pluginname' => $pluginname,
];
}
protected function plugin_with_name(string $pluginname) {
$info = $this->model->get_details()['list'][$pluginname];
$name = $info[1]['name'] ?? $info[0]['name'] ?? null;
return [
'name' => $name !== null ? format_string($name) : null,
'hasname' => !empty($name),
'pluginname' => $pluginname,
];
}
siteinfo::is_table_preserved_in_restore() reads api::get_setting_array('restorepreservetables') to decide whether a wildcard-matched table should be preserved during restore. The plugin's settings.php registers restorepreservedataroot, restorepreserveplugins and restorepreservepasswords, but not restorepreservetables. The author has flagged this with an inline TODO this setting does not exist! comment.
Functionally this means tables that are not present in any plugin's install.xml ("orphan" tables on the receiving site) cannot be preserved by name during restore — the if (!$deftable) branch is effectively dead. There is no security impact; it is a missing feature / dead code.
Low risk. Pure code-quality / unfinished-feature finding. It does not weaken any security check, it merely means that admins who try to preserve an orphan table by name will silently see the setting ignored.
The companion is_table_excluded_from_backup() reads backupexcludetables, which is registered in settings.php. The intent was clearly symmetric on the restore side but the setting was never wired up.
public static function is_table_preserved_in_restore(string $tablename, ?dbtable $deftable): bool {
global $CFG;
if (!$deftable) {
// This is a table that is not present in the install.xml files of core or any plugins.
// Exclude this table if it's name is in the 'backupexcludetables' setting.
$tables = api::get_setting_array('restorepreservetables');
// TODO this setting does not exist!
if (self::matches_wildcard_pattern($CFG->prefix . $tablename, $tables)) {
return true;
}
} else {
Either register a matching admin_setting_configtextarea for tool_vault/restorepreservetables in settings.php and document it (mirroring backupexcludetables), or remove the dead branch and the misleading comment.
xmldb\dbstructure::retrieve_sequences_mysql(), retrieve_sequences_postgres(), retrieve_primary_keys_postgres() and get_actual_tables_sizes() issue raw, engine-specific SQL:
SHOW TABLE STATUS LIKE ?andset session information_schema_stats_expiry=...are MySQL-only.SELECT data_length FROM information_schema.TABLES WHERE table_schema = ?is MySQL-specific (PostgreSQL exposes the same metadata via different views anddata_lengthis not a column there).pg_total_relation_size(...)and theinformation_schema.constraint_column_usagejoin are PostgreSQL-only.
The code branches correctly between postgres and the default branch (which is implicitly assumed to be MySQL/MariaDB), so the queries that run on the active engine are valid. However, on sqlsrv or oci deployments the MySQL branch will execute and fail. Moodle plugins are expected to run portably across all supported engines or to refuse to install on unsupported ones.
Given the scale of the operation (full-site database backup) it is reasonable that the plugin has chosen to support only the two engines the Vault SaaS backend understands, but that choice is not advertised in version.php or requires — the plugin will install on sqlsrv/oci and only fail at first backup.
Low risk. No security implication. The worst case is that an admin on an unsupported engine sees a dml_exception when running the disk-space pre-check.
Tablename-only metadata lookups (sizes, sequences, primary keys) are not part of Moodle's portable database_manager API, so some engine-specific code is unavoidable. The concern is purely about graceful degradation on sqlsrv/oci.
protected function retrieve_sequences_mysql() {
global $DB, $CFG;
$sequences = [];
$prefix = $CFG->prefix;
try {
$oldval = $DB->get_field_sql('select @@information_schema_stats_expiry');
if ($oldval) {
$DB->execute('set session information_schema_stats_expiry=0');
}
} catch (\Throwable $e) {
$oldval = null;
}
$rs = $DB->get_recordset_sql("SHOW TABLE STATUS LIKE ?", [$prefix . '%']);
Either guard the helper against running on unsupported engines (throw a descriptive moodle_exception when $DB->get_dbfamily() is not mysql or postgres) or generalise the queries via xmldb_structure / database_manager helpers. At minimum, document the supported engines in the README.
public function get_actual_tables_sizes(): array {
global $CFG, $DB;
$sizes = [];
if ($DB->get_dbfamily() === 'postgres') {
foreach ($this->get_tables_actual() as $tablename => $table) {
$sizes[$tablename] =
$DB->get_field_sql(
"SELECT pg_total_relation_size(?)",
[$CFG->prefix . $tablename]
);
}
} else {
$records = $DB->get_records_sql_menu(
"SELECT
table_name AS tablename,
data_length AS tablesize
FROM information_schema.TABLES
WHERE table_schema = ? AND table_name like ?",
[$CFG->dbname, $CFG->prefix . '%']
);
Backup encryption is server-side at AWS S3 (SSE-C), not local. The passphrase entered by the admin is hashed (base64(sha256(passphrase))) and sent in the x-amz-server-side-encryption-customer-key header to S3 alongside the upload. The plugin never stores the unhashed passphrase and explicitly clears the derived key from tool_vault_operation.details once the operation finishes (set_details(['encryptionkey' => ''])). This is the intended design but is worth noting: the security of an encrypted backup against a Vault SaaS compromise depends on the passphrase entropy, since the API server brokers the S3 URL.
The progress.php capability URL is well-designed. It uses random_string(32) (cryptographically secure, 62-char alphabet, ≈189 bits entropy) and is necessary because the page must work while Moodle is in maintenance mode (cookies cannot reach an authenticated session). The page only ever displays the operation log and last-modified timestamp; it does not allow any state changes.
Privacy provider. The plugin correctly implements null_provider and declares lmsvault.io as an external location through add_external_location_link(). Because the plugin's purpose is to ship the entire site database off-site, this is a reasonable Privacy API surface.
Maintenance-mode hook. tool_vault\hook_callbacks::after_config (and the legacy tool_vault_after_config() shim) only block requests when api::is_maintenance_mode() returns true, which itself depends on an active backup/restore record. The hook correctly bypasses CLI scripts and the plugin's own progress.php, so admins are not locked out of the progress monitor while Vault holds the maintenance state.
Reflection on private core APIs. dbops::calculate_row_packet_sizes() uses ReflectionObject to call the protected mysqli_native_moodle_database::emulate_bound_params() method. The restoreactions\upgrade::disable_caches() helper similarly uses reflection to swap the cache_factory singleton for cache_factory_disabled. Both are pragmatic workarounds for the lack of a public API for these tasks during restore — they aren't security issues but they will need maintenance whenever Moodle changes those internal classes.
Self-explanatory plugin pattern (get_handler() dispatch). The pattern tool_vault\local\uiactions\<section>_<action> is dispatched from local\uiactions\base::get_handler() after both section and action are filtered with PARAM_ALPHANUMEXT and the resolved class is verified to exist. This avoids the class_exists() autoloader trap of arbitrary class instantiation.