MDL Shield

This is an older published review of this plugin. View the latest review →

Profile Field

mod_profilefield

Published by Exputo

Plugin Information

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.

Version:2026091500
Release:1.1.0
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-09-15
27 files·2,900 lines
Grade Justification

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.

AI Summary

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's edit_save_data()), never raw SQL or direct table writes.
  • view.php calls require_login($course, false, $cm) (no guest auto-login), require_capability('mod/profilefield:view', ...) and rejects the shared guest account; all form handling is via moodleform, so sesskey/CSRF is covered.
  • The set of editable columns is bounded by a hard-coded CORE_FIELDS allowlist intersected with the site allowlist on every request, so a tampered backup or mis-set config can never turn the activity into an arbitrary user-table writer. This backstop is explicitly unit-tested.
  • No SQL injection (all queries parameterised, get_in_or_equal used), no XSS (format_string()/s()/get_string() throughout), no direct filesystem access; the Privacy API null_provider is 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

securityMedium
Own-profile editing skips core's `editownprofile` capability and auth-plugin field locks
Exploitable by:
student

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:editownprofile is never checked. view.php gates access only with mod/profilefield:view (granted to the user archetype). Core's /user/edit.php additionally requires moodle/user:editownprofile at system context and throws cannotedityourprofile without 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 every user-table column and hardFreeze()s any field the account's auth plugin has locked (field_lock_<field> = locked, or unlockedifempty with a stored value). The plugin honours this for custom fields (it freezes locked ones in definition_after_data() and skips them in utility::save() unless the actor holds moodle/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.

Risk Assessment

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.

Context

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:editownprofile is held at system context. That capability defaults to user => CAP_ALLOW (and guest => 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 every user-table column returned by get_user_fieldnames() and hardFreeze()s any the account's auth plugin has locked via field_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.

Proof of Concept

Auth field-lock bypass (bundled auth_ldap):

  1. As admin, set auth_ldap -> Data mapping -> Department lock to Locked (field_lock_department = locked), and add department to Site administration -> Plugins -> Activity modules -> Profile Field -> Core user profile fields.
  2. As a teacher, add a Profile Field activity and tick Department.
  3. As an LDAP-authenticated student, open the activity, change Department to any value and press Save changes. The value is written to the user table, although /user/edit.php renders the same field frozen/read-only. If a course uses Restrict access by 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.

Affected Code
require_capability('mod/profilefield:view', $modulecontext);
utility::require_editable_user($USER);
Suggested Fix

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);
}
Affected Code
public function definition_after_data() {
    parent::definition_after_data();
    foreach ($this->customfields as $formfield) {
        $formfield->edit_field_set_locked($this->_form);
    }
}
Suggested Fix

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);
    }
}
Affected Code
foreach ($effective['core'] as $name) {
    if (property_exists($formdata, $name)) {
        $usernew->$name = $formdata->$name;
        $result->corefields[] = $name;
    }
}
Suggested Fix

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.

Additional AI Notes

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.

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-09-151.1.1CurrentA+
2026-09-151.1.0B+