A Moodle local plugin that automatically fills or updates user profile fields (both standard user fields and custom profile fields) based on configurable mapping rules. Rules are applied in real time via user_created/user_updated event observers and in bulk via a nightly scheduled task. The plugin provides an admin-only management UI (add/edit/delete/toggle rules, grouped and searchable views) plus CSV import/export of mapping rules.
This is a carefully built plugin with no exploitable security vulnerabilities. Every administrative surface in manage.php is gated by admin_externalpage_setup(), which enforces require_login() and the moodle/site:config capability, and each state-changing GET action (delete, toggle, bulkenable, bulkdisable, bulkdelete) calls require_sesskey(). All mapping values rendered in the UI pass through format_string() (HTML-escaped by default), and database queries use parameter placeholders with sql_like_escape()/sql_compare_text() for portable, injection-safe matching. The event observer only ever mutates the record of the user being created/updated, using values fixed by the administrator, so there is no cross-user or privilege-escalation path reachable by non-admins.
The issues found are all low-severity or informational. The most notable is a latent SQL-identifier interpolation in the scheduled task (u.{$sourcefield}) plus dynamic column names in set_field()/update_record(); these are not exploitable because every write path constrains sourcefield/targetfield to a fixed whitelist and only site administrators can create mappings. The remainder are code-quality and robustness items: writing directly to user/user_info_data instead of the profile/user-update APIs (a data-integrity nuance for non-text field types), a reference to a non-existent core/bootstrap JavaScript module, a few hardcoded English strings, a missing privacy null_provider, a memory-hungry get_records_sql() in the task, and a hand-rolled CSV parser. The plugin ships thorough PHPUnit tests (including regression tests for wildcard matching and LIKE escaping) and a cross-database CI matrix, which materially raises confidence in its correctness.
local_profilefield_autofill lets administrators define rules that copy or set user profile fields (standard or custom) from the value of another field, applied both in real time (event observers on user_created/user_updated) and in bulk (a nightly scheduled task). Configuration is exposed only through an admin-capability-gated management page with CSV import/export.
Security posture is solid. The admin UI is protected end-to-end by admin_externalpage_setup() (login + moodle/site:config), destructive GET actions require a valid sesskey, forms use moodleform (automatic sesskey), rendered values are escaped with format_string(), and DB access is parameterized with proper LIKE escaping. No code path lets a non-administrator reach the configuration surface, and the observer only edits the acting user's own record with admin-defined values, so there is no unauthenticated, student, or teacher exploit path.
Findings are all low/info. They cluster around three themes:
- Latent (non-exploitable) SQL-identifier interpolation — the scheduled task concatenates the mapping's field name into SQL as a column identifier, and the observer/task use the target field as a dynamic column. Safe today only because inputs are whitelist-validated and admin-only.
- Bypassing core write APIs — target values are written straight to
user/user_info_datarather than throughprofile_save_data()/user_update_user(), which can store malformed values for non-text field types. - Housekeeping — a non-existent
core/bootstrapJS module, hardcoded strings, a missing privacy provider, an in-memoryget_records_sql()in the task, and a fragile custom CSV parser.
The plugin is well tested (two comprehensive PHPUnit suites) and CI-covered against PostgreSQL and MariaDB.
Findings
The scheduled task builds its user-selection query by concatenating the mapping's sourcefield straight into the SQL text as a column identifier (u.{$sourcefield}), and both the observer and the task use the mapping's targetfield as a dynamic column name in set_field('user', ...) / update_record('user', ...). Column and table identifiers cannot be bound as SQL parameters, so the safety of these statements depends entirely on the identifier values being constrained elsewhere.
This is a latent SQL-injection pattern rather than a live vulnerability. Every path that persists a mapping restricts these fields to a fixed whitelist:
- The admin form's
validation()callshelper::validate_mapping_data()→validate_source_field()/validate_target_field(), which only accept one of a fixed list of standard user columns orprofile_field_<existing shortname>. - The CSV importer's
validate_csv_row()checks each field againstget_source_field_options()/get_target_field_options()and skips invalid rows;normalize_field_name()additionally forces anything unrecognised into theprofile_field_branch.
On top of that, only users holding moodle/site:config can create mappings at all. The concern is therefore defense-in-depth: the query construction itself is unsafe and would become exploitable if the validation layer were ever weakened or bypassed.
Low risk. There is no path for a student, teacher, manager, or unauthenticated user to influence these identifiers: mapping creation requires moodle/site:config, and the accepted values are constrained to a small fixed set of real column names or profile_field_ shortnames. Because the injectable values are both admin-only and whitelist-validated, this is not currently exploitable and is reported as a hardening/defense-in-depth issue — the query should not rely solely on a distant validation layer for its safety.
build_user_query() (task) and the standard-field branches of observer::process_user_profile_autofill() and apply_mappings::update_user_field() all take the persisted sourcefield/targetfield and place it into SQL where a placeholder cannot be used (a column identifier). The data originates only from the local_profilefield_autofill_mapping table, which is written exclusively by the admin form and the CSV importer, both of which whitelist the field names, and both of which are reachable only with moodle/site:config.
$like = $DB->sql_like($DB->sql_compare_text("u.{$sourcefield}"), ':sourcevalue', false);
$sql = "SELECT DISTINCT u.*, u.{$sourcefield} as source_value
FROM {user} u
WHERE u.deleted = 0
AND u.suspended = 0
AND {$like}";
Do not interpolate the field name. Resolve sourcefield to a column through an explicit allow-list map inside the task, and fail closed on anything unexpected:
$allowed = ['username','email','firstname','lastname','city','country',
'institution','department','phone1','phone2','address','description'];
if (!in_array($sourcefield, $allowed, true)) {
mtrace(" Skipping mapping with unexpected source field '{$sourcefield}'");
return null;
}
This keeps the query safe independently of how the row reached the database.
$DB->set_field('user', $mapping->targetfield, $mapping->targetvalue, ['id' => $userid]);
Validate $mapping->targetfield against the fixed list of updatable standard fields before using it as a column name (the same allow-list used by helper::get_updatable_standard_fields()).
$updateuser = new \stdClass();
$updateuser->id = $user->id;
$updateuser->$targetfield = $targetvalue;
$updateuser->timemodified = time();
$DB->update_record('user', $updateuser);
Re-validate $targetfield against the updatable-standard-field allow-list before assigning it as an object property that becomes a SQL column name.
When applying a mapping, both the observer and the scheduled task write the target value by inserting/updating rows in user_info_data and by calling set_field('user', ...) / update_record('user', ...) directly, rather than going through profile_save_data() / $field->edit_save_data() for custom fields or user_update_user() for standard fields.
Consequences:
- Data integrity for non-text field types.
get_custom_profile_field_options_for_target()offers everyuser_info_fieldregardless of datatype, so an admin can target adatetimeorcheckboxfield. The rawtargetvaluestring is then stored verbatim inuser_info_data.data, which for those datatypes expects a specific internal representation (e.g. a Unix timestamp fordatetime). The profile-field API would normalise this; the direct write does not. - Standard
descriptionfield. Writinguser.descriptionwithout a companiondescriptionformatcan leave the record in an inconsistent formatting state. - No change event. No
user_updatedevent is emitted for the modification. The observer does this deliberately to avoid re-entrancy, but the scheduled task could safely useuser_update_user()and thereby keep logs/caches/downstream observers consistent.
Low risk. This is a correctness/data-integrity concern rather than a security issue. It requires an administrator to configure a mapping onto a non-text profile field (or the description field), and the blast radius is limited to the stored representation of that field for affected users. Text and menu fields — the common case — happen to store the same string the API would, so most real configurations are unaffected.
The plugin's purpose is to overwrite profile fields for the acting user (observer) or for all matching users (task). Because the targets and values are administrator-defined and the offered target list includes all custom-field datatypes, an administrator can inadvertently create a mapping onto a non-text field, at which point the raw-string write stores an unnormalised value.
} else {
$record = new \stdClass();
$record->userid = $userid;
$record->fieldid = $targetfieldinfo->id;
$record->data = $mapping->targetvalue;
$record->dataformat = 0;
$DB->insert_record('user_info_data', $record);
$changed = true;
}
For custom fields, load the field via profile_get_custom_field_data_by_shortname() / build a profile_field_* object and call its edit_save_data(), or assemble a $data object and call profile_save_data(). This applies the datatype-specific conversion that a raw user_info_data write skips.
if ($existing) {
// Update existing record.
$existing->data = $targetvalue;
$DB->update_record('user_info_data', $existing);
} else {
// Create new record.
$record = new \stdClass();
$record->userid = $user->id;
$record->fieldid = $field->id;
$record->data = $targetvalue;
$DB->insert_record('user_info_data', $record);
}
Use the profile-field API to persist custom-field values, and user_update_user() for standard fields, so the value is validated/normalised for its datatype and a user_updated event is emitted for auditing.
manage.php requests the AMD module core/bootstrap:
Moodle 4.5 ships Bootstrap 4.6.2, whose JavaScript is bundled under theme_boost/bootstrap/* and initialised globally by the Boost theme. There is no core/bootstrap AMD module in core, so this call resolves to a missing module and produces a RequireJS load error in the browser console. The collapsible grouped view continues to work anyway, because Bootstrap 4's jQuery data-api binds automatically to the data-toggle="collapse" / data-target markup once the theme has loaded Bootstrap.
The call is therefore both incorrect (references a module that does not exist) and redundant (the behaviour it appears to enable is already provided by the theme).
Low risk. No security impact and no functional breakage of the collapse feature (the theme's global Bootstrap handles it). The visible effect is a JavaScript console error on every load of the management page, which is a code-quality/compatibility blemish.
The management page renders mapping groups as Bootstrap collapse cards. To make the collapse toggles work, the author added an explicit AMD call, but named a module (core/bootstrap) that is not part of Moodle. A full-tree search of /moodle finds Bootstrap AMD only under theme/boost/amd/{src,build}/bootstrap/.
// Ensure Bootstrap JS is loaded for collapse functionality.
$PAGE->requires->js_call_amd('core/bootstrap', 'init');
Remove the call. Bootstrap 4's collapse data-api is active site-wide via the Boost theme, so the data-toggle="collapse" buttons already work without any per-page module. If an explicit dependency is ever required, depend on the real module namespace (theme_boost/bootstrap/collapse) rather than a core/bootstrap entry point that does not exist.
Several user-visible messages are built from hardcoded English literals instead of get_string() calls backed by the language pack, and some concatenate the raw exception message from a caught Exception:
manage.phpbuilds'Error importing CSV: ' . $e->getMessage()and'Error saving mapping: ' . $e->getMessage()for redirect notifications.import_form.phpsets'Error validating file: ' . $e->getMessage()as a form error.helper::validate_dropdown_value()returns English suggestion text such as"Did you mean '{$suggestion}'? Available options: ..."and"For checkbox fields, use: 1, 0, yes, no, ..."that is shown during CSV import.
These break localisation (they cannot be translated) and, in the exception cases, surface internal error text directly to the operator.
Low risk. No security impact beyond minor internal-error disclosure to an already-privileged administrator. The main cost is broken localisation for these messages.
The plugin otherwise has a complete lang/en/local_profilefield_autofill.php file and uses get_string() consistently; these few spots were missed. The exception messages are only ever shown to an administrator (the pages are capability-gated), so any information disclosure is minimal.
redirect($PAGE->url, 'Error importing CSV: ' . $e->getMessage(), null, \core\output\notification::NOTIFY_ERROR);
Add a language string (e.g. $string['errorimportcsv'] = 'Error importing CSV: {$a}';) and call get_string('errorimportcsv', 'local_profilefield_autofill', $e->getMessage()), or emit a generic localised message and log the detail with debugging().
redirect($redirecturl, 'Error saving mapping: ' . $e->getMessage(), null, \core\output\notification::NOTIFY_ERROR);
Move the message into the language file and pass the detail as a placeholder, as above.
$errors['csvfile'] = 'Error validating file: ' . $e->getMessage();
Use a get_string() value for the error and avoid exposing the raw exception message.
'suggestion' => $suggestion
? "Did you mean '{$suggestion}'? Available options: " . implode(', ', $options)
: "Available options: " . implode(', ', $options),
Replace the inline English with get_string() entries (e.g. csvsuggestion, csvavailableoptions) that accept the option list as a placeholder.
The plugin has no classes/privacy/provider.php. Moodle expects every component to declare its privacy posture so the privacy subsystem, data export, and data deletion tooling can account for it.
The plugin's own table local_profilefield_autofill_mapping stores only configuration (source/target field names, match values, an enabled flag and timestamps) and contains no user identifiers or personal data, so a \core_privacy\local\metadata\null_provider is sufficient. It should still be added — without it, the component is treated as not-yet-reviewed for privacy and cannot cleanly assert that it stores no personal data.
Low risk. Compliance gap only, with no runtime or security impact. Because the plugin stores no personal data of its own, remediation is a single small provider class implementing null_provider plus a privacy:metadata language string.
The plugin does write to core user and user_info_data (which are covered by core's own privacy providers), but its own storage is non-personal configuration data. A null_provider is the correct declaration for that shape.
For each enabled mapping, apply_mappings::execute() runs get_records_sql() selecting u.* for every user matching the source condition and holds the entire result set in memory before iterating. A broad wildcard mapping — for example a sourcevalue of *, which the plugin explicitly supports and documents — expands to a LIKE % that matches every non-deleted, non-suspended user. On a large site this loads the whole user table into memory at once.
A memory-bounded iteration with get_recordset_sql() would avoid the spike while preserving behaviour.
Low risk. No security impact. On small and medium sites the current approach is fine; on very large sites a broad mapping could cause high memory use or an out-of-memory failure during the nightly run.
The task runs from cron (system context) once per mapping. It is not reachable by end users, so the concern is scalability/resource use on large installations rather than security.
// Get users that match the source condition.
$users = $DB->get_records_sql($sql, $params);
$usercount = count($users);
Iterate with a recordset instead of materialising all rows:
$rs = $DB->get_recordset_sql($sql, $params);
foreach ($rs as $user) {
if ($this->update_user_field($user, $mapping)) {
$updates++;
}
}
$rs->close();
Select only the needed columns rather than u.* if practical.
helper::parse_csv_content() splits the uploaded file on "\n" with str_getcsv() and then parses each resulting line separately. This mishandles well-formed CSV whose quoted fields contain embedded newlines (a quoted value spanning two lines is split into two broken rows) and is fragile with CRLF line endings. Moodle ships csv_import_reader (in lib/csvlib.class.php) specifically for robust, encoding-aware CSV parsing.
Low risk. No security impact. The practical effect is that certain valid CSV files (quoted multi-line values, some CRLF files) may be parsed into malformed rows and skipped or imported incorrectly.
CSV import is an admin-only action reached from the capability-gated management page, and imported field names/values are re-validated by validate_csv_row() before use. The parser's weakness therefore affects correctness/robustness of imports, not security.
// Parse CSV.
$lines = str_getcsv($content, "\n");
$data = [];
foreach ($lines as $line) {
if (trim($line)) {
$data[] = str_getcsv($line, $delimiter);
}
}
Parse with core's csv_import_reader (require_once($CFG->libdir . '/csvlib.class.php')), which handles quoting, embedded newlines, delimiters and encoding conversion consistently, or at minimum feed the whole content to a single streaming fgetcsv() over a memory stream rather than pre-splitting on \n.
Mappings are applied on the user_updated event for the user being updated, including when a user edits their own profile. If an administrator configures a mapping whose source field is one the user can edit themselves (for example city, department, or an editable custom profile field) and whose target field is used elsewhere for entitlement or access decisions, a user can set their own source field to the trigger value and thereby cause the plugin to write the administrator-defined target value onto their own record.
This is intended behaviour of an auto-fill plugin, not a defect — the user can only ever obtain the exact value the administrator wired to that condition, and standard privilege fields (username, email, roles) are not offered as targets. It is called out so administrators understand the trust boundary: do not drive security-relevant targets from user-editable source fields.
Informational. With sensible mappings (user-editable sources driving only descriptive fields) there is no risk. The note exists so administrators avoid the misconfiguration of letting a user-editable field drive a target that other systems trust for authorisation.
The observer processes only $event->objectid (the acting/updated user), so there is no cross-user impact; the only self-service risk is a user steering their own auto-filled fields. The severity of any real-world consequence is determined by the administrator's mapping configuration, not by the plugin code.
public static function user_updated(\core\event\user_updated $event) {
self::process_user_profile_autofill($event->objectid);
}
Document the trust model in the admin help text, and consider recommending (or enforcing) that mappings used for entitlement decisions read only from administrator-locked profile fields. Optionally allow limiting which source fields may drive mappings.
Strong test coverage. The plugin ships two comprehensive PHPUnit suites (tests/observer_test.php and tests/task/apply_mappings_test.php) that exercise real event triggering, wildcard/pattern matching (including a regression test for the preg_quote()-before-*-expansion bug), and SQL LIKE escaping so that _ and % stay literal. The observer and task are also deliberately reconciled (case-insensitive matching on both paths) and the divergence in suspended/deleted-user handling is pinned by a test. This materially raises confidence in the code's correctness.
Cross-database CI. .github/workflows/moodle-plugin-ci.yml runs the Moodle Plugin CI matrix against both PostgreSQL and MariaDB, which is the right way to catch engine-specific SQL issues; combined with the use of sql_like(), sql_like_escape() and sql_compare_text(), the queries are portable.
CSV download endpoints are acceptable but non-idiomatic. The export and template actions emit CSV via raw header() + echo + exit rather than \core\dataformat. This is admin-only and outputs administrator-entered data, so it is not a security issue, but \core\dataformat::download_data() would be the more standard approach. Note also that exported cells are not formula-escaped (a leading =/+/-/@ could be interpreted as a formula by a spreadsheet app); because the data is admin-authored and admin-exported this is negligible, but worth keeping in mind if untrusted data ever flows into these fields.
Minor Bootstrap utility mismatch. The markup mixes Bootstrap 5 spacing utilities (me-2) with Bootstrap 4 ones (mr-2). Moodle 4.5 ships Bootstrap 4.6.2, where me-*/ms-* are not defined, so those particular spacing classes are no-ops. Purely cosmetic; the data-toggle/badge-success classes used elsewhere are correct for Bootstrap 4.
Published reviews of this plugin