Vault - Site backup and migration
tool_vault
Vault (tool_vault) is a site backup and migration tool developed by Marina Glancy / lmsvault.io. It allows a Moodle administrator to back up the entire site (database, dataroot, file storage) to a remote SaaS service (lmsvault.io) and restore from those backups onto the same or a different site. Backups can be triggered from the admin UI or the bundled CLI scripts (cli/site_backup.php, cli/site_restore.php, cli/list_remote_backups.php). The plugin handles cross-version restores by carrying snapshot copies of Moodle's own historical upgrade scripts (3.11, 4.01, 4.02, 4.04). All operations are gated behind the moodle/site:config capability; a scheduled cron_task picks up queued operations and the captured logs are exposed to admins via a progress.php page that uses a random accesskey for unauthenticated polling.
The plugin is a high-trust administrative tool that handles sensitive site data, and overall it implements Moodle security practices well. All UI entry points enforce moodle/site:config (via admin_externalpage_setup, require_capability on dynamic forms, and require_sesskey() on state-changing actions). User input is routed through required_param/optional_param with appropriate PARAM_* types, database access goes through the $DB API with placeholder binding in almost all cases, HTTP requests use Moodle's curl wrapper, and the privacy API is implemented. No critical, high, or medium issues were identified. The findings are limited to a few low-severity defense-in-depth and code-quality issues: SQL fragments built with string interpolation of an int parameter in cleanup_existing_files.php, log/error/backupkey values rendered with triple-brace mustache without being explicitly escaped at the template layer, and use of reflection to reach protected core APIs.
Overview
tool_vault is a sizeable admin tool plugin (~260 PHP files, plus AMD JS and mustache templates) that performs full-site backup and restore by talking to a remote SaaS service. The codebase is well-structured around clear domain objects (operation_model, backup_model, restore_model, dryrun_model, check_model, tool_model), separate operation classes (site_backup, site_restore, site_restore_dryrun, site_backup_dryrun), pre/post restore actions (restoreactions/*), and per-version upgrade scripts (restoreactions/upgrade_311, upgrade_401, upgrade_402, upgrade_404).
Security posture
- Access control: every UI page is reached via
admin_externalpage_setup('tool_vault_index', ..., 'moodle/site:config')anddynamic_form::check_access_for_dynamic_submission()re-checksmoodle/site:config. State-changing UI actions (main_forgetapikey,backup_newcheck,restore_*,vaulttools) all callrequire_sesskey(). Theprogress.phppage intentionally bypasses cookies (NO_MOODLE_COOKIES) and is keyed on arandom_string(32)accesskeyso admins can monitor progress while the site is in maintenance mode. - Database access: all DB activity goes through
$DBwith placeholders. The one exception iscleanup_existing_files.php, which interpolates anint-typed parameter into SQL; safe in practice but worth fixing for style. - HTTP/remote API: the plugin uses an extended
\curlwrapper that setsignoresecurityfor S3 pre-signed URLs only; AWS pre-signed URLs are validated with a strict regex (https://[^/]+\.s3\.amazonaws\.com/) before any encryption key is forwarded. API responses are JSON-decoded and HTTP error bodies are sanitized vias()before being included in exceptions. - File system access: backup reads use the File API (
get_file_storage(),stored_file::copy_content_to). Direct dataroot reads/writes are unavoidable for a full-site backup tool; the plugin keeps these under a dedicated__vault_restore__/subdirectory and uses Moodle'smake_writable_directory/make_unique_writable_directoryhelpers. Temporary files usemake_backup_temp_directory(). Zip extraction goes through Moodle'szip_packer, which sanitises archive paths viaclean_param(PARAM_PATH), so a malicious archive cannot escape the target directory via..traversal. - Encryption: optional passphrase encryption uses SHA-256 of the passphrase as the SSE-C key. The encryption key is forwarded only when the pre-signed URL host matches AWS S3.
- Privacy: implements
core_privacy\local\metadata\null_providerplus an external location link tolmsvault.iowith a metadata summary string, and there is aprovider_test.phpcovering it. - JavaScript: the embedded iframe used for the API-key handoff with
lmsvault.iovalidatesevent.originagainst the configured frontend URL before forwardingpostMessagepayloads, preventing cross-origin injection.
Notable design choices
- The plugin ships its own copies of Moodle core upgrade routines for branches 3.11 → 4.4 so that a backup made on a much older Moodle can be upgraded after restore without needing the intermediate-version source tree.
- The plugin uses reflection in two places:
dbops::calculate_row_packet_sizes()reaches intomysqli_native_moodle_database::emulate_bound_params()to estimate query length formax_allowed_packetsizing, andupgrade::disable_caches()swapscache_factory::$instancefor an instance ofcache_factory_disabled. These are pragmatic workarounds; they should be revisited if Moodle changes the relevant private/protected surfaces. - A
task_adhocrecord is inserted directly during upgrade because\core\task\manager::queue_adhoc_task()is not safe to call mid-upgrade. The plugin documents this in a comment.
Findings summary
No critical, high, or medium findings. Several low-severity defense-in-depth and style findings around SQL interpolation and template escaping, plus a few informational notes on architecture.
Findings
In classes/local/restoreactions/cleanup_existing_files.php the $restoreid variable is embedded directly into the SQL string for both the INSERT ... SELECT and DELETE statements rather than being bound as a placeholder.
The function is typed int $restoreid so PHP will coerce the value at the function boundary, which prevents SQL injection in practice. However, this style is inconsistent with Moodle conventions: the rest of the plugin uses $DB->execute($sql, $params) with ?/:name placeholders, and Moodle expects all dynamic values to be parameterised so that a future signature change (e.g. relaxing the int constraint, or adding a wrapper that forwards strings) cannot silently introduce a SQLi vector.
The surrounding code already builds a $params array from $DB->get_in_or_equal(...), so adding one more bound parameter is trivial.
Low risk. The PHP int type declaration coerces any caller value before SQL is built, so this is not exploitable today. The finding is purely about following Moodle's parameterised-SQL convention and removing the implicit assumption that the typing contract holds forever.
cleanup_existing_files snapshots the set of file content hashes before a restore and removes orphaned hashes afterwards. Both call sites receive $restoreid via $logger->get_model()->id, which is always an integer from the tool_vault_operation table.
$sql = "insert into {tool_vault_table_files_data} (restoreid, contenthash)
select distinct $restoreid AS restoreid, contenthash
from {files} where contenthash not in (
select contenthash
from {files}
where component $sqlcomponent
) and referencefileid is null";
$DB->delete_records_select('tool_vault_table_files_data', 'restoreid = ?', [$restoreid]);
$DB->execute($sql, $params);
Bind $restoreid as a placeholder and pass it via $params:
$params['restoreid'] = $restoreid;
$sql = "insert into {tool_vault_table_files_data} (restoreid, contenthash)
select distinct :restoreid AS restoreid, contenthash
from {files} where contenthash not in (
select contenthash
from {files}
where component $sqlcomponent
) and referencefileid is null";
$sql = "DELETE FROM {tool_vault_table_files_data}
WHERE restoreid=$restoreid
AND contenthash IN (SELECT contenthash FROM {files})";
$DB->execute($sql);
$sql = "DELETE FROM {tool_vault_table_files_data}
WHERE restoreid = ?
AND contenthash IN (SELECT contenthash FROM {files})";
$DB->execute($sql, [$restoreid]);
Operation logs are constructed in operation_model::format_log_line() and wrapped in html_writer::span($message, $class). html_writer::span() (and html_writer::tag() underneath it) does not HTML-escape its first argument, and the templates that display logs use triple-brace mustache placeholders ({{{logs}}}, {{{logsshort}}}) inside <pre> blocks.
The log content includes:
- Strings the plugin itself writes (safe — internal constants).
- API error messages — already sanitised with
s()inapi::prepare_api_exception(). - Any output captured from PHP/Moodle during a backup/restore, via
log_capture::add_line(), which is then stored verbatim withLOGLEVEL_UNKNOWN.
The last category is the concern: Moodle's debugging output and other mtrace/echo calls during a long-running backup/restore can include HTML (e.g. debugging() messages contain anchor tags). Anything rendered raw inside <pre> is still interpreted as HTML by the browser, so a debug message containing <script>...</script> would execute when an admin views the log.
Logs are admin-restricted via the main pages (moodle/site:config) but progress.php also exposes them to anyone with the accesskey (a 32-char random string typically copied from the admin UI). In practice the risk is low because the captured stream is server-controlled — but escaping at the template layer (or in format_log_line()) would close the gap and make the path defence-in-depth safe.
Low risk. The log content originates almost entirely from server-side code; there is no obvious path by which a non-admin (or any user other than the operator) can inject content into a log entry. The exposure is realistic only if an attacker can already cause arbitrary HTML to be echod during a backup/restore, which requires another vulnerability or a malicious plugin already installed by an admin.
Logs are written from many paths (add_to_log, API exception handlers, log_capture output buffering) and displayed in backup_details, restore_details, dryrun, vaulttools_details, and progress.php templates. The capture is enabled while a backup/restore is in progress so that any unexpected PHP/Moodle output is preserved for diagnosis.
return $usehtml ? \html_writer::span($message, $class) : $message;
Either escape $message before wrapping it, or render the value as text and apply the class via a class-only span:
return $usehtml ? \html_writer::span(s($message), $class) : $message;
<pre>
{{{logsshort}}}
</pre>
...
<pre>
{{{logs}}}
</pre>
If format_log_line() is updated to escape content, the triple-brace placeholders here are acceptable because the only HTML in the value is the trusted <span> wrapper. Otherwise switch to double braces and emit the class wrapper from the template instead.
self::$model->add_log($log, constants::LOGLEVEL_UNKNOWN);
Captured stdout/stderr is the main untrusted-shape source. Either filter through strip_tags() before storing, or accept that everything in the logs is HTML and let the renderer escape it.
In templates/restore_details.mustache the backup key value is rendered with triple-brace mustache, which disables HTML escaping:
- Line 73:
<td><a href="{{backupdetailsurl}}">{{{backupkey}}}</a></td>
The value originates from restore_details.php:
'backupkey' => $this->restore->backupkey,
which is the raw string stored in the tool_vault_operation.backupkey column. The DB schema allows up to 120 characters and the value is what the remote lmsvault.io API returned in response to PUT /backups. The plugin itself does not sanitise it before storing or rendering.
Other templates (logs.mustache, section_restore.mustache, dryrun.mustache) correctly use the double-brace {{backupkey}} form. This inconsistency makes it likely that the triple-brace use here is unintentional rather than a deliberate choice to allow markup.
Low risk. Exploitation would require the remote lmsvault.io service (operated by the plugin authors) to return malicious HTML in the backupkey field of an API response, and the resulting markup would only execute for the admin viewing the restore details page. Useful defence-in-depth fix.
The backupkey is treated as alphanumeric in most code paths (PARAM_ALPHANUMEXT on form input), but the value the plugin actually stores is whatever the remote API returns and there is no client-side validation on the incoming key. This finding is purely about defending against a compromised or misbehaving upstream API.
<tr>
<th>{{#str}} remotebackup, tool_vault {{/str}}</th>
<td><a href="{{backupdetailsurl}}">{{{backupkey}}}</a></td>
</tr>
Switch to double braces so the value is HTML-escaped on output:
<td><a href="{{backupdetailsurl}}">{{backupkey}}</a></td>
templates/error_with_backtrace.mustache renders the error string and PHP backtrace without HTML escaping:
- Line 30:
{{{ error }}} - Line 38:
<pre>{{{.}}}</pre>(backtrace)
These values come from error_with_backtrace::create_from_model() / create_from_exception(), which pull $model->get_details()['error'] (originally $t->getMessage()) and $t->getTraceAsString() directly. Exception messages can include arbitrary strings — for instance moodle_exception messages embed the $a placeholder content, and dml_exception::$debuginfo can include parts of failing SQL — which may contain <, >, or quote characters that the template will interpret as HTML.
The backtrace block is only displayed in debugging() mode, but the inline error is shown to every admin who hits a failure page (backup_details, restore_details, dryrun, check_summary, vaulttools_details, the remote-backups list, …).
Low risk. Exploitation requires that an attacker can influence the message of an exception thrown during a backup or restore. Reachable paths are limited to admin-controlled actions and remote-API failures (which are already sanitised via s() in prepare_api_exception()). Worth fixing as defence-in-depth.
Errors are surfaced to admins viewing backup/restore details. The current behaviour relies on the fact that internal Moodle exception messages do not typically contain raw HTML, but there is no enforcement of that assumption.
<div class="alert alert-danger alert-block fade in ">
{{{ error }}}
<div class="alert alert-danger alert-block fade in ">
{{ error }}
<div class="backtrace" style="display: none">
<pre>{{{.}}}</pre>
</div>
<div class="backtrace" style="display: none">
<pre>{{.}}</pre>
</div>
Two places use reflection to bypass core visibility:
classes/local/helpers/dbops.phpcalls the protectedmysqli_native_moodle_database::emulate_bound_params()to compute the exact serialized length of a row, so that bulk inserts can be sized just belowmax_allowed_packet.classes/local/restoreactions/upgrade.phpreplaces the privatecache_factory::$instancestatic with acache_factory_disabledinstance during upgrade. The class also defines anenable_caches()method that is not currently invoked, so the disabled factory remains in place until the request ends.
These are pragmatic workarounds (core does not expose equivalents) but they are fragile: a future renaming, signature change, or removal of those internals in Moodle/PHP can break the plugin silently.
Informational. No security impact.
Both reflection calls are intentional and well-commented in the source. The risk is purely maintenance/forward-compatibility.
$reflector = new \ReflectionObject($DB);
$method = $reflector->getMethod('emulate_bound_params');
$method->setAccessible(true);
Long-term, consider proposing a public helper in core (e.g. a way to obtain the bound-statement length) and gracefully degrading to a conservative batch size if reflection fails.
$reflection = new \ReflectionProperty(\cache_factory::class, 'instance');
$reflection->setAccessible(true);
$reflection->setValue(null, $object);
Verify that enable_caches() is invoked once the upgrade phase finishes (it currently is not called from execute()), and consider documenting the lifecycle so the next maintainer does not accidentally drop the reset path.
classes/task/after_upgrade_task::queue() inserts a record directly into task_adhoc rather than using \core\task\manager::queue_adhoc_task(). A comment explains that this is needed because the standard queue helper depends on services that are not safely callable mid-upgrade.
The pattern is unavoidable given the constraint, but it bypasses any future hardening or validation added to queue_adhoc_task and so deserves a callout.
Informational. Necessary workaround, no security impact.
This helper is used to schedule replacements for adhoc tasks that were removed from core/standard plugins between the version in the backup and the running version, so that the restored site still gets the work done after upgrade.
$DB->insert_record('task_adhoc', $record);
When core exposes an upgrade-safe API for queuing adhoc tasks, switch to it; until then keep the comment explaining why the direct insert is necessary.
classes/api.php defines const FRONTENDURL = 'https://lmsvault.io';. The plugin is purpose-built for this SaaS, and the value can be overridden via tool_vault/frontendurl (and tool_vault/apiurl), so the constant only sets the default.
This is flagged purely so the reviewer of the published plugin is aware that the plugin sends API key, site id, and site URL to a third party as a normal part of operation. The privacy provider exposes this fact to the GDPR subsystem.
Informational. Disclosed in the privacy metadata; not a vulnerability.
The plugin's design is to back up to the operator's SaaS. The external location and its data classification is declared in classes/privacy/provider.php.
const FRONTENDURL = 'https://lmsvault.io';
The plugin's progress.php is intentionally unauthenticated (NO_MOODLE_COOKIES) and gated on a random_string(32) accesskey stored in tool_vault_operation.accesskey. The page is the only way to observe a long-running backup/restore once the site is in maintenance mode. Treat the access key as a secret; if it leaks, the corresponding operation's logs are exposed.
The CLI restore script honours --allow-restore which sets $CFG->forced_plugin_settings['tool_vault']['allowrestore'] = 1 for the duration of the run, allowing a restore even when the GUI setting is disabled. Anyone with shell access to the Moodle host can use this; this is intentional but worth noting in operator documentation.
Encryption of backups is opt-in (the admin enters a passphrase). The plugin computes the SSE-C key client-side as base64(sha256(passphrase)) and forwards it only to URLs matching ^https://[^/]+\.s3\.amazonaws\.com/. If the remote API returns a pre-signed URL on a non-S3 host the plugin throws error_invaliduploadlink/error_invaliddownloadlink and refuses to send the key. Good defence against API-server compromise leaking customer encryption keys.
siteinfo::is_table_preserved_in_restore() checks the setting restorepreservetables, but the plugin's settings.php only defines restorepreservedataroot, restorepreserveplugins, and restorepreservepasswords. The TODO comment in the source confirms this. As a result, the wildcard pattern branch in that function is dead code — not a security issue but a feature gap that the team may want to clean up.