MDL Shield

Profile Field Auto-fill

local_profilefield_autofill

Published by Exputo

Plugin Information

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.

Version:2026080500
Release:1.2.1
Reviewed for:4.5
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-08-06
19 files·4,079 lines
Grade Justification

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.

AI Summary

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_data rather than through profile_save_data()/user_update_user(), which can store malformed values for non-text field types.
  • Housekeeping — a non-existent core/bootstrap JS module, hardcoded strings, a missing privacy provider, an in-memory get_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

securityLow
Mapping field names interpolated directly into SQL and used as dynamic DB column identifiers

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() calls helper::validate_mapping_data()validate_source_field() / validate_target_field(), which only accept one of a fixed list of standard user columns or profile_field_<existing shortname>.
  • The CSV importer's validate_csv_row() checks each field against get_source_field_options() / get_target_field_options() and skips invalid rows; normalize_field_name() additionally forces anything unrecognised into the profile_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.

Risk Assessment

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.

Context

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.

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

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.

Identified Code
                    $DB->set_field('user', $mapping->targetfield, $mapping->targetvalue, ['id' => $userid]);
Suggested Fix

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()).

Identified Code
                $updateuser = new \stdClass();
                $updateuser->id = $user->id;
                $updateuser->$targetfield = $targetvalue;
                $updateuser->timemodified = time();

                $DB->update_record('user', $updateuser);
Suggested Fix

Re-validate $targetfield against the updatable-standard-field allow-list before assigning it as an object property that becomes a SQL column name.

code qualityLow
Target values written straight to core tables, bypassing the profile-field and user-update APIs

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 every user_info_field regardless of datatype, so an admin can target a datetime or checkbox field. The raw targetvalue string is then stored verbatim in user_info_data.data, which for those datatypes expects a specific internal representation (e.g. a Unix timestamp for datetime). The profile-field API would normalise this; the direct write does not.
  • Standard description field. Writing user.description without a companion descriptionformat can leave the record in an inconsistent formatting state.
  • No change event. No user_updated event is emitted for the modification. The observer does this deliberately to avoid re-entrancy, but the scheduled task could safely use user_update_user() and thereby keep logs/caches/downstream observers consistent.
Risk Assessment

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.

Context

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.

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

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.

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

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.

code qualityLow
Reference to a non-existent `core/bootstrap` JavaScript module

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

Risk Assessment

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.

Context

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/.

Identified Code
// Ensure Bootstrap JS is loaded for collapse functionality.
$PAGE->requires->js_call_amd('core/bootstrap', 'init');
Suggested Fix

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.

code qualityLow
Hardcoded English strings and raw exception text in user-facing messages

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.php builds 'Error importing CSV: ' . $e->getMessage() and 'Error saving mapping: ' . $e->getMessage() for redirect notifications.
  • import_form.php sets '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.

Risk Assessment

Low risk. No security impact beyond minor internal-error disclosure to an already-privileged administrator. The main cost is broken localisation for these messages.

Context

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.

Identified Code
            redirect($PAGE->url, 'Error importing CSV: ' . $e->getMessage(), null, \core\output\notification::NOTIFY_ERROR);
Suggested Fix

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().

Identified Code
            redirect($redirecturl, 'Error saving mapping: ' . $e->getMessage(), null, \core\output\notification::NOTIFY_ERROR);
Suggested Fix

Move the message into the language file and pass the detail as a placeholder, as above.

Identified Code
                $errors['csvfile'] = 'Error validating file: ' . $e->getMessage();
Suggested Fix

Use a get_string() value for the error and avoid exposing the raw exception message.

Identified Code
                    'suggestion' => $suggestion
                        ? "Did you mean '{$suggestion}'? Available options: " . implode(', ', $options)
                        : "Available options: " . implode(', ', $options),
Suggested Fix

Replace the inline English with get_string() entries (e.g. csvsuggestion, csvavailableoptions) that accept the option list as a placeholder.

complianceLow
Missing Privacy (GDPR) provider

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.

Risk Assessment

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.

Context

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.

best practiceLow
Scheduled task loads all matching users into memory with get_records_sql()

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.

Risk Assessment

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.

Context

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.

Identified Code
                // Get users that match the source condition.
                $users = $DB->get_records_sql($sql, $params);
                $usercount = count($users);
Suggested Fix

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.

best practiceLow
Hand-rolled CSV parsing instead of Moodle's CSV import API

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.

Risk Assessment

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.

Context

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.

Identified Code
        // Parse CSV.
        $lines = str_getcsv($content, "\n");
        $data = [];

        foreach ($lines as $line) {
            if (trim($line)) {
                $data[] = str_getcsv($line, $delimiter);
            }
        }
Suggested Fix

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.

best practiceInfo
Auto-fill re-applies on self-service profile edits driven by user-editable source fields

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.

Risk Assessment

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.

Context

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.

Identified Code
    public static function user_updated(\core\event\user_updated $event) {
        self::process_user_profile_autofill($event->objectid);
    }
Suggested Fix

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.

Additional AI Notes

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.

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

Published reviews of this plugin

2026-08-061.2.1CurrentA