MDL Shield

Profile Field

mod_profilefield

Published by Exputo

Plugin Information

An activity module (mod_profilefield) that lets a course participant update a restricted, administrator-allowlisted set of fields of their own Moodle profile from inside a course — for example an access code, institution or department. Core fields are validated and saved through Moodle's user API with the same rules as the standard profile editor (required names, valid/unique email, allowed domains, the email-change confirmation flow), and custom fields are rendered, validated and saved by their own field type. It adds a custom completion rule that marks the activity complete once every offered field holds a value, so later course content can be gated on it. Ships with events, a Privacy API null-provider, backup/restore and comprehensive PHPUnit coverage.

Version:2026091501
Release:1.1.1
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-09-15
27 files·3,108 lines
Grade Justification

This is an exemplary, security-conscious plugin whose write path — the only sensitive surface — is tightly controlled and faithfully mirrors Moodle core's own profile editor.

Security controls verified against core:

  • The activity writes only to the current user ($USER->id); no user-controllable user id reaches the save path, so cross-user modification is impossible.
  • Editable fields are the intersection of the instance's stored selection and the site-level administrator allowlist, re-evaluated on every request via utility::effective_fields(), so withdrawing a field site-wide takes effect immediately, including for restored activities.
  • Core fields are saved through user_update_user() (which itself validates and cleans the data via core_user::validate/clean_field), and custom fields through each type's edit_save_data(). Validation in validate_core_fields() is a line-for-line reproduction of user_edit_form::validation(), including the email-change confirmation flow.
  • Field locks are enforced at the save layer, not merely frozen in the form: custom-field is_locked() and authentication-plugin field_lock_* settings are honoured, and only holders of moodle/user:update may write past a lock — matching the profile editor exactly.
  • The shared guest account is refused at multiple layers (require_login without guest auto-login, no guest archetype on the view capability, and an explicit isguestuser() guard in both the page and utility::save()), and moodle/user:editownprofile plus the auth plugin's can_edit_profile()/edit_profile_url() are respected.

No injection, XSS, CSRF, superglobal, direct-DB, or direct-filesystem issues were found. Queries are parameterised, output is escaped through format_string/format_module_intro, state changes go through moodleform (automatic sesskey), and schema changes are confined to db/upgrade.php. Coding standards, capability definitions, the Privacy provider, events and backup/restore are all correct, and the security behaviours are backed by thorough unit tests.

The single reported item is an informational observation about a deliberate, clearly documented design choice (offering fields regardless of their profile "visibility" setting), which is administrator-gated and affects only the user's own data. It is not a defect and does not lower the overall assessment.

AI Summary

Overview

mod_profilefield is an activity module that lets a course participant update a small, administrator-allowlisted set of fields on their own Moodle profile from inside a course. Its purpose is to collect or correct profile data (an access code, institution, department, preferred name) at the point a course needs it, and — through a custom completion rule — to gate later content on a field having been filled in.

The entire sensitive surface is a single write path (view.phpprofilefield_formutility::save()), and it is implemented with unusual care.

Security posture

Every security-relevant behaviour was traced and verified against Moodle core (/moodle/public):

  • Self-only writes. utility::save() is always called with $USER->id; there is no request-controllable target user, so a participant can never touch another account.
  • Two-layer allowlist. The fields an activity may offer are the intersection of its stored selection and the live site allowlist (utility::effective_fields()), re-evaluated per request. Core fields are hard-capped to five (firstname, lastname, email, institution, department).
  • Core-API writes. Core fields go through user_update_user() (which validates and cleans via core_user::validate/clean_field); custom fields through their type's edit_save_data(). Email/name/domain validation reproduces user_edit_form::validation() faithfully, including the emailchangeconfirmation pending-change flow.
  • Locks enforced on save. Custom-field locks and authentication-plugin field_lock_* settings are honoured, and only moodle/user:update holders may override them — verified against core's profile_field_base and user/edit.php.
  • Guest and capability gates. Guest is refused in several independent places; moodle/user:editownprofile and the auth plugin's can_edit_profile()/edit_profile_url() are respected (with a documented, on-by-default opt-out for sites that disable self-service editing).

No SQL injection, XSS, CSRF, superglobal use, direct database access, or direct filesystem access was found. State changes use moodleform (automatic sesskey), schema changes live only in db/upgrade.php, and there are no bundled third-party libraries.

Findings

One info-level observation: the participant form renders custom fields via edit_field_add() without core's is_editable()/is_visible() gate, so a custom field set to "Not visible" becomes viewable and editable by its owner once an administrator allowlists it. This is a deliberate, documented design choice (the hidden-access-code use case), gated behind administrator configuration and limited to the user's own data.

Overall

The plugin is exemplary. It mirrors core's profile-edit security semantics closely, defends locks and the guest account at the save layer rather than relying only on the form, uses secure-by-default configuration, and is backed by comprehensive PHPUnit tests covering the security behaviours.

Findings

best practiceInfo
Custom profile fields are offered regardless of their profile "visibility" setting

The participant form builds each custom-field element by calling the field type's edit_field_add() (plus edit_field_set_default()/edit_field_set_required()) directly, rather than going through core's profile_field_base::edit_field(). The core wrapper first calls is_editable(), which in turn calls is_visible(); for a field whose visibility is set to "Not visible" (PROFILE_VISIBLE_NONE), core returns false even for the field's own owner unless they hold moodle/user:viewalldetails. Core's profile editor therefore hides such a field from an ordinary user.

By bypassing that gate, this activity will display and allow editing of a "Not visible" custom field to its owner, provided the field has been placed on the site allowlist by an administrator and added to the activity by a teacher.

This is a deliberate, documented design decision, not an oversight. The README and the admin-settings help text both state that a field's visibility is intentionally not honoured here, because a field such as an access code is often hidden from the profile page on purpose while still needing to be entered through the activity. The point of the observation is to make the security implication explicit: the field's own visibility no longer restricts owner access once it is allowlisted — the administrator's allowlist is the sole authority on what a participant may see and edit.

Risk Assessment

Info — negligible risk, by design. There is no cross-user exposure: a participant only ever sees and edits their own profile value, so this is not an information-disclosure path to third parties, and it grants no privilege beyond editing one's own allowlisted field.

The only scenario is an administrator who allowlists a custom field they had set to "Not visible" intending to keep it hidden from users; those users would then be able to view and change their own copy of it. This is gated behind explicit administrator configuration, is contradicted by the on-screen setting help and the README if the administrator does not intend it, and matches the plugin's stated purpose (letting users enter values, such as access codes, that are hidden from the standard profile page). It is recorded as an informational note so the behavioural difference from the core profile editor is on the record, not because it represents a vulnerability.

Context

utility::custom_field_objects() instantiates a profile_field_base for each allowlisted custom field via profile_get_user_field(), loaded with the current user's data. The form adds every one of them as an element. definition_after_data() then freezes any that are locked (via the field type or the auth plugin), and utility::save() independently skips locked fields on write. The only core check that is not replicated is the visibility gate inside is_editable()/is_visible().

Because the activity only ever operates on the current user ($USER->id), the value shown and saved is always the participant's own data. Reaching a "Not visible" field requires an administrator to add that field to the plugin's allowlist (Site administration › Plugins › Activity modules › Profile Field) and a teacher to include it in an activity.

Identified Code
$this->customfields = utility::custom_field_objects($effective['custom'], $userid);
foreach ($this->customfields as $formfield) {
    $formfield->edit_field_add($mform);
    $formfield->edit_field_set_default($mform);
    $formfield->edit_field_set_required($mform);
}
Suggested Fix

No change is required — this is intended behaviour with a legitimate use case, and it is already documented for administrators.

If stricter parity with the core profile editor were ever desired (so that "Not visible" fields are never exposed to their owner even when allowlisted), the loop could skip fields for which $formfield->is_editable() returns false, and utility::save() / utility::current_values() could apply the same test. Otherwise, the current safeguards — the administrator allowlist, the on-by-default moodle/user:editownprofile requirement, and field locking — remain the controls that matter, and the existing warning in the admin settings UI is the right place to keep reminding administrators that visibility is not enforced here.

Additional AI Notes

Faithful reproduction of core security semantics. The write path deliberately re-implements the relevant parts of user/edit.php and user_edit_form::validation() — required-name and email validation, the case-insensitive duplicate-email check gated on allowaccountssameemail, the bounce-threshold rule, the allowemailaddresses/denyemailaddresses check gated on verifychangedemail and moodle/user:update, and the full emailchangeconfirmation pending-change/email-key flow. Each of these was compared line-for-line with core and matches, and each is covered by a PHPUnit test.

Defence in depth on locks and the guest account. Locked custom fields and authentication-plugin field locks are enforced both in the form (frozen elements) and independently in utility::save(), and the guest account is rejected in require_login (no auto-login), the capability archetypes (no guest), and an explicit isguestuser() guard — so a bypass of any single layer does not open the write path. This is stronger than relying on form freezing alone.

Trivial cleanup: settings.php carries a // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf comment above the if ($ADMIN->fulltree) block, but neither that block nor the enclosing if ($hassiteconfig) block is empty, so the suppression appears unnecessary and can be removed. This has no functional or security impact.

Optional external integration is correctly decoupled. The validation_test exercises an access-code field type (profilefield_accesscode) that is a separate plugin and not a runtime dependency of mod_profilefield; the test skips cleanly when it is not installed. The activity works with any custom profile field type through the standard profile_field_base API, so no bundled or hard dependency exists.

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+