This is an older published review of this plugin. View the latest review →
An activity module that lets course participants update a small, administrator-allowlisted set of their own Moodle profile fields (the core fields firstname, lastname, email, institution, department, plus selected custom profile fields) from inside a course, without visiting the full profile editor. A site administrator chooses which fields may ever be offered; a teacher picks a subset per activity. Core fields are validated and saved with the profile editor's rules via user_update_user(), custom fields via their own field type, and the module adds view completion and an 'all offered fields filled in' completion rule. Intended for remote/LTI learners who lack access to the standard profile page and for profile-based access restrictions.
The plugin is carefully written and free of the classes of flaw that usually drive a security grade down: there is no direct database or filesystem access, no SQL injection or XSS, require_login/capability/guest checks are in place, and all form handling is via moodleform so CSRF is covered. A hard-coded CORE_FIELDS allowlist, intersected with the site allowlist on every request, prevents the activity from ever writing arbitrary user-table columns even from a tampered backup — a genuinely strong design choice that is directly unit-tested.
The single security finding is a well-mitigated medium: the activity re-implements self-service profile editing but omits core's moodle/user:editownprofile check and its per-field authentication-plugin locks for the five core fields. Exploitation is self-scoped (a user edits only their own account) and requires a specific alignment of administrator allowlisting, teacher activity configuration, and either an auth-level field lock or a withdrawn capability. The most meaningful consequence — self-assigning a locked institution/department that gates a Restrict access rule — is real but sits behind those prerequisites.
With no high or critical issues, one constrained and configuration-gated medium, and otherwise clean, well-tested code, the plugin sits just below the top tier.
mod_profilefield is an activity module that lets a course participant update a constrained, administrator-allowlisted set of their own profile fields (the core fields firstname, lastname, email, institution, department, plus admin-chosen custom profile fields) without visiting the full profile editor.
The plugin is well engineered and shows clear evidence of a prior hardening pass. Security fundamentals are sound:
- Every write goes through core APIs (
user_update_user(), each field type'sedit_save_data()), never raw SQL or direct table writes. view.phpcallsrequire_login($course, false, $cm)(no guest auto-login),require_capability('mod/profilefield:view', ...)and rejects the shared guest account; all form handling is viamoodleform, sosesskey/CSRF is covered.- The set of editable columns is bounded by a hard-coded
CORE_FIELDSallowlist intersected with the site allowlist on every request, so a tampered backup or mis-set config can never turn the activity into an arbitraryuser-table writer. This backstop is explicitly unit-tested. - No SQL injection (all queries parameterised,
get_in_or_equalused), no XSS (format_string()/s()/get_string()throughout), no direct filesystem access; the Privacy APInull_provideris appropriate, and events, backup/restore and completion are correctly implemented.
The one substantive issue is that this self-service editor does not replicate two access controls that core's /user/edit.php enforces for core fields: the moodle/user:editownprofile capability, and authentication-plugin field locks (field_lock_*). Both are self-scoped and only reachable under specific administrator/teacher configuration, so the practical risk is limited — but they can defeat controls a site relies on, most notably on institution/department, which the plugin is explicitly designed to drive access restrictions with.
Findings
The activity re-implements the self-service profile editor (/user/edit.php) but omits two access controls that core enforces before letting a user change their own core profile fields (firstname, lastname, email, institution, department):
moodle/user:editownprofileis never checked.view.phpgates access only withmod/profilefield:view(granted to theuserarchetype). Core's/user/edit.phpadditionally requiresmoodle/user:editownprofileat system context and throwscannotedityourprofilewithout it. An administrator who has removed that capability from the Authenticated user role to disable self-service profile editing is silently overridden by this activity.- Authentication-plugin field locks are ignored for core fields. Core's
user_edit_form::definition_after_data()walks everyuser-table column andhardFreeze()s any field the account's auth plugin has locked (field_lock_<field>=locked, orunlockedifemptywith a stored value). The plugin honours this for custom fields (it freezes locked ones indefinition_after_data()and skips them inutility::save()unless the actor holdsmoodle/user:update) but applies no equivalent check to the five core fields. A field an auth plugin (LDAP, CAS, Shibboleth, OAuth2, DB, ...) marks authoritative and read-only can therefore be rewritten through the activity —user_update_user()itself performs no lock enforcement, so the form-layer freeze is the only thing that normally prevents the write.
Both gaps are self-scoped (a user edits only their own account) and require specific administrator/teacher configuration to be reachable, but they defeat controls the site relies on and contradict the plugin's own claim of parity with the profile editor.
Medium risk. Both omissions are self-scoped — a participant can only rewrite fields of their own account, never another user's — and each needs a specific configuration to become reachable: the field must be on the site allowlist, a teacher must include it in an activity, and either the administrator must have withdrawn moodle/user:editownprofile or the account's auth plugin must lock the field.
The most consequential case is institution/department. The module is explicitly promoted for driving Restrict access rules on profile values; if a site keeps these authoritative through an auth lock (e.g. LDAP) yet also allowlists them here, a student can self-assign a value to satisfy a restriction and reach content intended for another cohort. For email the exposure is smaller — the emailchangeconfirmation flow still applies and an auth plugin that locks email usually re-syncs it on next login — and for the name fields the impact is essentially integrity/identity.
Mitigating factors: the hard-coded CORE_FIELDS allowlist means the bypass can never reach sensitive columns such as auth, password or policyagreed; the blast radius is a single account (the attacker's own); and in a default install (editownprofile granted, no auth locks configured) there is no behavioural difference from core. It is not reachable unauthenticated, and holders of moodle/user:update are legitimately allowed to override locks. That combination keeps it below high despite being a genuine bypass of a security control.
view.php loads the current user's own account and, on submit, calls utility::save($formdata, $effective, $USER->id); $userid is always the viewer, so the activity is strictly a self-service editor. save() writes core fields with user_update_user() and custom fields with each type's edit_save_data().
Core's equivalent page, /user/edit.php, wraps the same writes in two guards this plugin does not reproduce for core fields:
- It refuses self-editing unless
moodle/user:editownprofileis held at system context. That capability defaults touser => CAP_ALLOW(andguest => CAP_PROHIBIT), but an administrator can withdraw it to turn off self-service profile editing site-wide. - In
user_edit_form::definition_after_data()it walks everyuser-table column returned byget_user_fieldnames()andhardFreeze()s any the account's auth plugin has locked viafield_lock_<field>.user_update_user()performs no lock enforcement of its own, so the form-layer freeze is the only barrier.
The plugin reproduces the lock behaviour for custom fields (edit_field_set_locked() in the form and a moodle/user:update check in save()), which is exactly what makes the omission for the five core fields the gap. Those five are precisely the fields exposed for locking: the bundled auth plugins build their field_lock_* settings via display_auth_lock_options() over core_user::AUTHSYNCFIELDS, which contains firstname, lastname, email, institution and department.
Auth field-lock bypass (bundled auth_ldap):
- As admin, set
auth_ldap-> Data mapping -> Department lock to Locked (field_lock_department = locked), and adddepartmentto Site administration -> Plugins -> Activity modules -> Profile Field -> Core user profile fields. - As a teacher, add a Profile Field activity and tick Department.
- As an LDAP-authenticated student, open the activity, change Department to any value and press Save changes. The value is written to the
usertable, although/user/edit.phprenders the same field frozen/read-only. If a course usesRestrict accessby department, the student now satisfies it.
Capability bypass: as admin, remove moodle/user:editownprofile from the Authenticated user role. /user/edit.php then returns cannotedityourprofile, but the Profile Field activity still saves changes for the same student, because it checks only mod/profilefield:view.
require_capability('mod/profilefield:view', $modulecontext);
utility::require_editable_user($USER);
Add the same own-profile capability check core uses, at system context, before building the form:
require_capability('mod/profilefield:view', $modulecontext);
utility::require_editable_user($USER);
require_capability('moodle/user:editownprofile', context_system::instance());
For full parity, also consult the account's auth plugin — as /user/edit.php does — and refuse or redirect when local editing is disallowed:
$userauth = get_auth_plugin($USER->auth);
if (!$userauth->can_edit_profile()) {
throw new \moodle_exception('noprofileedit', 'auth');
}
if ($editurl = $userauth->edit_profile_url()) {
redirect($editurl);
}
public function definition_after_data() {
parent::definition_after_data();
foreach ($this->customfields as $formfield) {
$formfield->edit_field_set_locked($this->_form);
}
}
Freeze locked core fields too, mirroring user_edit_form::definition_after_data(). Load the account's auth plugin once and, for each effective core field, hardFreeze() + setConstant() the element when its field_lock_<field> config is locked (or unlockedifempty and the stored value is non-empty):
$auth = get_auth_plugin($user->auth);
foreach ($this->_customdata['effective']['core'] as $name) {
$lock = 'field_lock_' . $name;
if (isset($auth->config->{$lock})
&& ($auth->config->{$lock} === 'locked'
|| ($auth->config->{$lock} === 'unlockedifempty' && $user->$name !== ''))) {
$mform->hardFreeze($name);
$mform->setConstant($name, $user->$name);
}
}
foreach ($effective['core'] as $name) {
if (property_exists($formdata, $name)) {
$usernew->$name = $formdata->$name;
$result->corefields[] = $name;
}
}
Skip a locked core field on save unless the actor may override locks, exactly as the custom-field branch already does. Load get_auth_plugin($user->auth) once and ignore the submitted value when field_lock_<name> is locked (or unlockedifempty and the current value is non-empty) and the current user lacks moodle/user:update at system context. This server-side check is the authoritative one — freezing the form element alone is not sufficient because the element name is attacker-controllable in a crafted POST.
Documentation version mismatch. README.md opens with Requirements: Moodle 3.9+, but version.php sets $plugin->requires = 2024100700 (Moodle 4.5) and a later README section states Moodle 4.5 LTS or later. Moodle enforces requires, so an install on < 4.5 is refused rather than left broken, but the 3.9+ line is misleading (the code depends on the 4.3+ completion form API and $PAGE->activityheader) and should be corrected to match.
Strong allowlist design worth preserving. The utility::CORE_FIELDS constant, intersected with the site allowlist on every evaluation (site_allowlist() / effective_fields()), guarantees that only firstname, lastname, email, institution, department and administrator-approved custom fields can ever be written — even if a malicious backup declares coreprofilefields = password,auth. This is the control that keeps the self-editing surface safe, and it is directly unit-tested (test_site_allowlist_ignores_unknown_core_columns). It should remain the single choke point for any future field additions.
Auth can_edit_profile() / edit_profile_url() are not consulted. Core's /user/edit.php refuses local editing (or redirects to an external editor) when the account's auth plugin overrides these. None of the auth plugins bundled with Moodle override them, so there is no verifiable impact in a stock install and this is not scored on its own; however, a site running a third-party auth plugin that disables local profile editing would find the activity still writable. Adding these two checks alongside the field-lock fix in finding 1 would restore full parity with the profile editor. Supplying such a third-party auth plugin in a future review environment would let this be assessed directly.