MDL Shield

Vault - Site backup and migration

tool_vault

Print Report
Plugin Information

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.

Version:2026051200
Release:4.5.1
Reviewed for:4.5
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-05-13
263 files·38,655 lines
Grade Justification

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.

AI Summary

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') and dynamic_form::check_access_for_dynamic_submission() re-checks moodle/site:config. State-changing UI actions (main_forgetapikey, backup_newcheck, restore_*, vaulttools) all call require_sesskey(). The progress.php page intentionally bypasses cookies (NO_MOODLE_COOKIES) and is keyed on a random_string(32) accesskey so admins can monitor progress while the site is in maintenance mode.
  • Database access: all DB activity goes through $DB with placeholders. The one exception is cleanup_existing_files.php, which interpolates an int-typed parameter into SQL; safe in practice but worth fixing for style.
  • HTTP/remote API: the plugin uses an extended \curl wrapper that sets ignoresecurity for 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 via s() 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's make_writable_directory/make_unique_writable_directory helpers. Temporary files use make_backup_temp_directory(). Zip extraction goes through Moodle's zip_packer, which sanitises archive paths via clean_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_provider plus an external location link to lmsvault.io with a metadata summary string, and there is a provider_test.php covering it.
  • JavaScript: the embedded iframe used for the API-key handoff with lmsvault.io validates event.origin against the configured frontend URL before forwarding postMessage payloads, 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 into mysqli_native_moodle_database::emulate_bound_params() to estimate query length for max_allowed_packet sizing, and upgrade::disable_caches() swaps cache_factory::$instance for an instance of cache_factory_disabled. These are pragmatic workarounds; they should be revisited if Moodle changes the relevant private/protected surfaces.
  • A task_adhoc record 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

code qualityLow
Restore ID interpolated into SQL instead of using a placeholder

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.

Risk Assessment

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.

Context

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.

Identified Code
$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);
Suggested Fix

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";
Identified Code
$sql = "DELETE FROM {tool_vault_table_files_data}
    WHERE restoreid=$restoreid
    AND contenthash IN (SELECT contenthash FROM {files})";
$DB->execute($sql);
Suggested Fix
$sql = "DELETE FROM {tool_vault_table_files_data}
    WHERE restoreid = ?
    AND contenthash IN (SELECT contenthash FROM {files})";
$DB->execute($sql, [$restoreid]);
best practiceLow
Operation logs rendered with triple-brace mustache and html_writer::span

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() in api::prepare_api_exception().
  • Any output captured from PHP/Moodle during a backup/restore, via log_capture::add_line(), which is then stored verbatim with LOGLEVEL_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.

Risk Assessment

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.

Context

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.

Identified Code
return $usehtml ? \html_writer::span($message, $class) : $message;
Suggested Fix

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;
Identified Code
<pre>
{{{logsshort}}}
</pre>
...
<pre>
{{{logs}}}
</pre>
Suggested Fix

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.

Identified Code
self::$model->add_log($log, constants::LOGLEVEL_UNKNOWN);
Suggested Fix

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.

securityLow
backupkey rendered unescaped in restore details template
Exploitable by:
admin

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.

Risk Assessment

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.

Context

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.

Identified Code
<tr>
    <th>{{#str}} remotebackup, tool_vault {{/str}}</th>
    <td><a href="{{backupdetailsurl}}">{{{backupkey}}}</a></td>
</tr>
Suggested Fix

Switch to double braces so the value is HTML-escaped on output:

<td><a href="{{backupdetailsurl}}">{{backupkey}}</a></td>
securityLow
Error messages and backtraces rendered with triple-brace mustache
Exploitable by:
admin

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, …).

Risk Assessment

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.

Context

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.

Identified Code
<div class="alert alert-danger alert-block fade in ">
    {{{ error }}}
Suggested Fix
<div class="alert alert-danger alert-block fade in ">
    {{ error }}
Identified Code
<div class="backtrace" style="display: none">
    <pre>{{{.}}}</pre>
</div>
Suggested Fix
<div class="backtrace" style="display: none">
    <pre>{{.}}</pre>
</div>
best practiceInfo
Reflection used to access protected core methods and private properties

Two places use reflection to bypass core visibility:

  • classes/local/helpers/dbops.php calls the protected mysqli_native_moodle_database::emulate_bound_params() to compute the exact serialized length of a row, so that bulk inserts can be sized just below max_allowed_packet.
  • classes/local/restoreactions/upgrade.php replaces the private cache_factory::$instance static with a cache_factory_disabled instance during upgrade. The class also defines an enable_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.

Risk Assessment

Informational. No security impact.

Context

Both reflection calls are intentional and well-commented in the source. The risk is purely maintenance/forward-compatibility.

Identified Code
$reflector = new \ReflectionObject($DB);
$method = $reflector->getMethod('emulate_bound_params');
$method->setAccessible(true);
Suggested Fix

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.

Identified Code
$reflection = new \ReflectionProperty(\cache_factory::class, 'instance');
$reflection->setAccessible(true);
$reflection->setValue(null, $object);
Suggested Fix

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.

best practiceInfo
Direct insert into core task_adhoc table during upgrade

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.

Risk Assessment

Informational. Necessary workaround, no security impact.

Context

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.

Identified Code
$DB->insert_record('task_adhoc', $record);
Suggested Fix

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.

complianceInfo
Hardcoded production frontend URL

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.

Risk Assessment

Informational. Disclosed in the privacy metadata; not a vulnerability.

Context

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.

Identified Code
const FRONTENDURL = 'https://lmsvault.io';
Additional AI Notes

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.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.