Vault - Site backup and migration
tool_vault
- #1Config setting name and plugin printed without escaping in the config-overrides report
- #2Backup-derived table name interpolated into ALTER SEQUENCE/ALTER TABLE DDL during restore
- #3Table-preservation check reads a setting that does not exist (restorepreservetables)
- #4Privacy provider declares no local storage while user identifiers are stored in plugin tables
Vault (tool_vault) is an admin tool that performs a full backup, migration and restore of an entire Moodle site to the lmsvault.io cloud service. It exports the whole database, the dataroot directory and the content-addressed file storage, uploads the archives to cloud storage (optionally with client-side/SSE-C encryption), and can restore or migrate a complete site onto another Moodle instance — including replaying intermediate core and standard-plugin upgrade steps so that an older backup can be restored onto a newer Moodle. Backup and restore can be driven from an admin web UI or from CLI scripts.
This is a mature, carefully engineered plugin. By its nature it performs operations that would be alarming almost anywhere else — raw whole-database export/import, direct dataroot manipulation, DDL that replays core and plugin upgrade steps, and outbound HTTP to a cloud API — yet every one of these is confined to a tightly controlled surface. All web entry points dispatch through index.php, which calls admin_externalpage_setup('tool_vault_index', ...); that page is registered with moodle/site:config, so the entire UI is administrator-only (verified against admin_externalpage_setup/admin_externalpage::check_access in core). Every state-changing web action adds require_sesskey()/confirm_sesskey() or is a dynamic_form with require_capability('moodle/site:config'). Restore is disabled by default and additionally gated by the allowrestore setting and API-key registration. Database access consistently uses parameterised $DB methods; file operations use the File API (get_file_storage(), add_file_to_pool, copy_content_to) plus make_backup_temp_directory() for scratch space; outbound requests use core's \curl wrapper with CURLOPT_SSL_VERIFYPEER enabled against a hardcoded/admin-configured endpoint; template output is escaped via s(), format_string-style helpers or Mustache auto-escaping; and a Privacy API provider is present. No SQL injection, XSS, CSRF, SSRF, code-execution, direct-DB-driver or File-API-bypass issue is exploitable by any non-administrator. The findings are all low severity: one output-escaping gap that is only reachable through config.php (server-filesystem) content, one restore-only DDL identifier interpolation that grants nothing beyond the total DB control a restore already confers, a latent reference to an undefined setting, and an incomplete Privacy API declaration. None of these represent a practical security risk, and the overall code quality — tests, coding-style discipline, defensive error handling — is high.
Vault (tool_vault) is a full-site backup, migration and restore tool that talks to the lmsvault.io cloud service. It is a large, professionally maintained plugin (site backup engine, restore engine, cloud API client, DB-schema differ, and verbatim replays of core/standard-plugin upgrade steps for cross-version restores).
Trust model. Everything an attacker might find alarming here is by design and is locked behind administrator access:
- The whole web UI is dispatched through
index.php→admin_externalpage_setup('tool_vault_index'), whose admin page is registered withmoodle/site:config. I verified in core (/moodle/public/lib/adminlib.php) that this callsrequire_login()and enforces the capability viaadmin_externalpage::check_access(). - State-changing actions (
restore_restore,restore_dryrun,restore_resume,restore_updateremote,backup_newcheck,main_forgetapikey,vaulttools, table-exclusion) callrequire_sesskey()/confirm_sesskey(); the modal/AJAX forms aredynamic_formsubclasses that re-checkmoodle/site:config. - Restore is off by default (
allowrestore = 0) and additionally requires a registered API key. progress.phpruns without login but is gated by a 32-characterrandom_string(32)access key.
What I checked and found sound: parameterised $DB access throughout (identifiers quoted with getEncQuoted); File API usage for filedir/backup content rather than touching $CFG->dataroot/filedir directly; temp files via make_backup_temp_directory(); outbound HTTP via core \curl with TLS peer verification on and endpoints that come only from a hardcoded constant or admin config; the S3 helper's ignoresecurity bypass is deliberate and safe (TLS still verified, URLs come from the authenticated cloud API, and is_s3_url() restricts where the encryption key is sent); consistent output escaping (s(), get_string, Mustache); a Privacy API provider; and a well-formed db/install.xml/db/upgrade.php.
Findings (all low):
- Config setting
name/pluginrendered with unescaped{{{ }}}in the config-overrides pre-check report — but the source isconfig.php(server filesystem) and the audience is administrators, so it is not practically exploitable. - A backup-derived table identifier is interpolated into
ALTER SEQUENCE/ALTER TABLEDDL during restore — reachable only by an administrator restoring a backup, which already replaces the entire database; defence-in-depth only. siteinfo::is_table_preserved_in_restore()reads a setting (restorepreservetables) that does not exist (the author's ownTODOconfirms this) — a latent functional bug.- The Privacy provider is a
null_provideryet the plugin stores the operating admin's id/username/full name/email intool_vault_operation— the local storage is undeclared.
No high or critical issues. The plugin is exemplary in its access control and API discipline given how powerful it is.
Findings
The "Config overrides" backup pre-check renders each configuration override in a table. In configoverride::format_setting_value_for_details() the setting value is escaped with s(), but the setting name and plugin are placed into the template array unescaped, and the template outputs all three with the raw/unescaped Mustache triple-brace syntax {{{name}}} / {{{plugin}}}.
The setting keys and plugin identifiers come from $CFG->config_php_settings and $CFG->forced_plugin_settings, i.e. exclusively from the site's config.php. To place HTML/script into one of these keys an actor must already be able to edit config.php, which requires filesystem access to the server (equivalent to full site compromise), and the report is only ever viewed by a site administrator. There is therefore no realistic cross-user exploit, but this is still an output-encoding gap that departs from the escaping the rest of the plugin applies consistently.
Low risk. The only injection vector is the content of config.php, which is writable exclusively by an actor with server filesystem access — a level of access that already implies complete site compromise. The report is viewed only by holders of moodle/site:config. There is no path for a student, teacher, manager or unauthenticated visitor to influence these keys. This is an escaping-hygiene defect rather than a practically exploitable stored-XSS.
The config-overrides pre-check enumerates values set in config.php so the administrator can see which settings will/won't be included in a backup. get_template_data() builds the rows from $CFG->config_php_settings (core overrides) and $CFG->forced_plugin_settings (plugin overrides). The report is rendered only inside the admin-only Vault UI.
Only reproducible by someone who can edit config.php, e.g. adding $CFG->forced_plugin_settings['mod_forum']['<img src=x onerror=alert(1)>'] = 1; and then opening the Config overrides pre-check details as an admin. This requires server filesystem access, which is out of scope of any Moodle role.
protected function format_setting_value_for_details(string $name, ?string $plugin, $value, bool $included): array {
if (!$included) {
$value = '<em>' . get_string('configoverrides_valueredacted', 'tool_vault') . '</em>';
} else {
$value = is_array($value) ? 'Array' : s((string)$value);
}
return ['name' => $name, 'value' => $value, 'plugin' => $plugin];
}
Escape the identifiers at the point they are prepared, e.g.:
return ['name' => s($name), 'value' => $value, 'plugin' => s((string)$plugin)];
Alternatively switch the template to double-brace output ({{name}} / {{plugin}}). Note the value branch intentionally emits <em>…</em> HTML, so that field must stay as {{{value}}}.
<td class="col-3">{{{name}}}</td>
<td class="col-3">{{{plugin}}}</td>
<td class="col-6">{{{value}}}</td>
If the values are escaped in PHP (preferred), change name/plugin to double-brace so escaping is enforced by the renderer regardless of the exporter:
<td class="col-3">{{name}}</td>
<td class="col-3">{{plugin}}</td>
<td class="col-6">{{{value}}}</td>
During a restore, dbtable::get_fix_sequence_sql() builds raw DDL by string-concatenating the table name (and the sequence field name) directly into ALTER SEQUENCE {prefix}{table}_{field}_seq … (Postgres) or ALTER TABLE {prefix}{table} AUTO_INCREMENT = … (MySQL). The $nextid value is an integer, but $tablename and $field originate from the backup's __structure__.xml (parsed in dbstructure::load_definitions_from_backup_xml()), i.e. from data outside the running site. The plugin quotes identifiers with getEncQuoted() in most other places, but not here.
Crucially, this code is reachable only from site_restore::restore_db(), which runs when a site administrator restores a backup. A restore rebuilds the entire database from the backup's contents, so an actor who can supply a malicious backup already controls all restored schema and data (including the config, user and capability tables). Injecting SQL through a table identifier therefore grants nothing beyond what performing the restore already grants. This is a defence-in-depth/code-quality issue, not a privilege escalation.
Low risk. The sink is only reachable by an administrator performing a restore, and a restore inherently replaces the entire database with the backup's contents — anyone able to influence the backup already controls all restored data and schema, so identifier injection is not an escalation. Identifiers also pass through core XMLDB processing (arr2xmldb_table, strtolower/trim) before reaching this point. Worth fixing for robustness and to match the plugin's own use of getEncQuoted() elsewhere, but it is not a practically exploitable vulnerability.
restore_db() iterates the backup's tables, truncates/recreates them, inserts the backup rows, then calls get_fix_sequence_sql() and feeds the result to $DB->change_database_structure(). Table definitions come from the backup archive downloaded from the cloud service tied to the admin's API key. Restore is disabled by default (allowrestore), requires moodle/site:config, and runs with the site in maintenance mode.
$tablename = $this->get_xmldb_table()->getName();
$maxid = $DB->get_field_sql("SELECT MAX($field) FROM {" . $tablename . "}");
if (!$maxid && !$nextvalue) {
return [];
}
$nextid = max($nextvalue, $maxid + 1);
if ($DB->get_dbfamily() === 'postgres') {
return ["ALTER SEQUENCE {$CFG->prefix}{$tablename}_{$field}_seq RESTART WITH $nextid"];
} else {
return ["ALTER TABLE {$CFG->prefix}{$tablename} AUTO_INCREMENT = $nextid"];
}
Validate the identifier against Moodle's XMLDB naming rules before use. Table and field names in Moodle must match ^[a-z][a-z0-9_]*$; reject or skip anything that does not:
if (!preg_match('/^[a-z][a-z0-9_]*$/', $tablename) || !preg_match('/^[a-z][a-z0-9_]*$/', $field)) {
return [];
}
Where possible route identifiers through the schema generator's getEncQuoted() (as done elsewhere in this class) rather than raw interpolation. The same hardening applies to the Postgres sequence-name concatenation in dbstructure::retrieve_sequences_postgres() (… FROM ' . $seqname), although there the value is constrained by a [a-z|_] regex and read from the live catalogue.
siteinfo::is_table_preserved_in_restore() decides, for a table with no schema definition, whether it should be preserved during restore by reading api::get_setting_array('restorepreservetables'). That setting is never defined in settings.php (the comparable backup path uses backupexcludetables), and the author's own inline TODO states "this setting does not exist!". As a result the lookup always returns an empty array, so preservation of definition-less ("extra") tables silently never happens.
This is a latent functional bug rather than a security issue: administrators who expect an undefined custom table to be preserved during restore will find it is dropped/overwritten with no error.
Low risk. No security impact. The consequence is a silent no-op: custom tables without an XMLDB definition are never preserved during restore even if an administrator intended them to be. The condition is self-documented by the author's TODO.
This helper is used by site_restore::restore_db() to skip tables that should be preserved on the destination site. For tables that do have an install.xml definition, preservation is driven by the plugin-exclusion list, which works; only the definition-less branch is affected by the missing setting.
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 add a restorepreservetables admin setting in settings.php (mirroring backupexcludetables) and document it, or remove the dead branch if per-table restore preservation for definition-less tables is not intended. If the intent is to reuse the backup exclusion list, read the correct setting name explicitly and rename accordingly.
The Privacy API provider implements null_provider (whose get_reason() asserts the plugin "stores no data") plus a metadata\provider that declares an external-location transfer to lmsvault.io. However, the plugin does persist personal data of the operating administrator in its own tables: site_backup::schedule() writes usercreated, fullname and email into tool_vault_operation.details, and site_restore::schedule() writes id, username, fullname and email. Operation logs (tool_vault_log) can also contain usernames.
Because null_provider signals that no personal data is stored locally, this local storage is undeclared. The affected data subjects are limited to site administrators (only they can trigger backups/restores), so the exposure is small, but the Privacy API implementation is nonetheless inaccurate/incomplete.
Low risk. This is a GDPR/compliance completeness gap, not a security vulnerability. The stored data (admin id, username, full name, email) concerns only administrators and is not exposed to unprivileged users, but the null_provider declaration is inaccurate and a strict privacy review would expect the local tables to be described.
tool_vault_operation.details is a JSON blob written whenever a backup, restore or dry-run is scheduled; backup_model::get_performedby()/restore_base_model::get_performedby() read fullname/email back for display. These records persist until removed. The Privacy provider is what Moodle's GDPR tooling relies on to describe stored personal data.
class provider implements \core_privacy\local\metadata\provider, null_provider {
Implement a full metadata description of the local storage instead of relying on null_provider. At minimum, describe the tool_vault_operation (and tool_vault_log) tables via $collection->add_database_table(...) listing the stored user fields (userid, username, full name, email). If the data must be retrievable/erasable per user, additionally implement core_userlist_provider/request export & delete; otherwise document why it is retained as operational metadata.
$model->set_status(constants::STATUS_SCHEDULED)->set_details([
'usercreated' => $USER->id,
'description' => substr($params['description'] ?? '', 0, constants::DESCRIPTION_MAX_LENGTH),
'bucket' => $params['bucket'] ?? '',
'expiredays' => $params['expiredays'] ?? '',
'encryptionkey' => $encryptionkey,
'encrypted' => (bool)strlen($encryptionkey),
'fullname' => $USER ? fullname($USER) : '',
'email' => $USER->email ?? '',
])->save();
Declare these fields in the Privacy metadata (see above), or avoid persisting fullname/email and store only userid, resolving names for display when needed.
Outbound HTTP is handled correctly. api::api_call() uses core's \curl wrapper with CURLOPT_SSL_VERIFYPEER => true against https://lmsvault.io (a hardcoded constant, overridable only via admin/plugin config). The tool_vault\local\helpers\curl subclass sets ignoresecurity => true — which bypasses core's curl_security_helper blocked-hosts/ports checks — but only for talking to S3 storage, keeps TLS peer verification enabled, and only ever receives pre-signed URLs returned by the authenticated cloud API; api::is_s3_url() further restricts the destinations to which the customer SSE-C encryption key is sent. The URLs cannot be influenced by any user below site administrator, so this is not an SSRF exposure — it is the intended pattern for reaching cloud storage that a site's egress policy might otherwise block.
Raw database, filesystem and DDL operations are inherent to the tool and appropriately gated. A whole-site backup/migration plugin must read/write the entire database, dataroot and file store, and must replay core/standard-plugin upgrade steps (the restoreactions/upgrade_311|401|402|404 directories are verbatim copies of core upgrade logic). These are confined to admin-initiated, cron/CLI-driven backup and restore flows, with restore additionally disabled by default. The plugin still uses parameterised $DB methods, getEncQuoted() for identifiers in most places, the File API for filedir content, and make_backup_temp_directory() for scratch space — so it does not gratuitously bypass Moodle APIs.
progress.php is intentionally accessible without a login (it runs with NO_MOODLE_COOKIES so it can render while the site is in maintenance mode during a backup/restore), but it is gated by a random_string(32) access key stored on the operation and disclosed only to the admin who launched the operation. All log/status output on that page is escaped via operation_model::format_log_line() (s()), so the design is reasonable.
No bundled third-party libraries were found, so the absence of thirdpartylibs.xml is correct. The AMD build/ files are the compiled counterparts of the plugin's own amd/src/ sources, and the version-specific upgrade scripts are derivative core code rather than an external dependency; there is no vendored Guzzle/PHPMailer/etc. The signon.js post-message handler validates event.origin against the configured Vault frontend host before acting on messages.
The plugin ships PHPUnit and Behat test suites and a Privacy API test, and is disciplined about coding standards (e.g. Mdlcode-disable/phpcs:ignore annotations are used deliberately where raw table names or copied core code are unavoidable), which is consistent with the overall high quality observed during the review.