Forum care
local_forumcare
A Moodle local plugin (local_forumcare) that lets students report problematic mod_forum posts. It provides configurable report reasons (site-wide defaults or per-course overrides), threshold-based auto-moderation (auto-hide a post after N distinct reports, auto-suspend an enrolment or account), a capability-gated teacher review queue with colour-coded moderator actions, per-forum opt-in embedded in the forum's edit-settings page, backup/restore of per-forum settings, event logging, and a complete privacy (GDPR) provider including the userlist interface.
The plugin demonstrates consistently strong security engineering throughout.
- Every web service endpoint calls
validate_parameters()andvalidate_context()(which enforcesrequire_login), checks the relevant capability, and re-validates visibility/ownership before acting. - Every page entry point (
report.php,manage_reasons.php) performsrequire_login, arequire_capability, andrequire_sesskey/confirm_sesskeyon all manual state changes; forms usemoodleformso sesskey is handled automatically. - All database access uses parameterized
$DBmethods; the one place a variable is used as a column name (helper::get_threshold) is constrained to a hardcoded allow-list first, so there is no SQL injection surface. - All user-controlled output is escaped via
s(),format_string(), or Mustache auto-escaping. Thefullname()-in-html_writer::link/html_writer::selectpattern mirrors core's own usage (e.g. the grader report) and is backed by core'sPARAM_NOTAGScleaning of name fields, so it is not an XSS vector.
No exploitable security vulnerabilities were found. Authorization for the high-impact actions (course/site suspension, post hiding) is correctly layered: capability checks on the report's own course context, an additional system-level check for site-wide suspension, and a is_protected_from_suspension() guard that shields admins and course moderators from both automatic thresholds and manual actions.
The only substantive finding is low severity and largely unavoidable: the plugin hides a post by directly overwriting the core forum_posts content (core offers no API to hide a post's body). This is handled with unusual care — a backup table, re-hiding on edit, restore on un-hide, an uninstall hook that restores originals before tables are dropped, and full privacy-provider coverage of the backup. Remaining items are informational (a missing defense-in-depth capability check on the non-sensitive reasons endpoint, and two unused language strings).
Supporting quality is high: a full privacy API with the userlist provider, backup/restore that treats the .mbz as untrusted input, correct conditional use of the MOODLE_INTERNAL guard, and a comprehensive PHPUnit suite covering threshold edge cases, privilege protection, privacy erasure interactions, and tampered-backup cleaning.
local_forumcare is a forum-post moderation plugin: students report posts, configurable thresholds can auto-hide posts or auto-suspend users, and teachers review reports from a capability-gated queue.
Overall the plugin is exemplary from a security standpoint. The review found no exploitable vulnerabilities across the external web services, page entry points, moderation logic, privacy provider, or backup/restore code.
Key strengths:
- Web services (
submit_report,moderate_report,get_reasons,get_post_report_status) all validate parameters and context, and enforce capabilities and per-post visibility before acting. - Authorization is layered and defensive — e.g.
helper::is_protected_from_suspension()prevents students (via thresholds) or non-editing teachers (via the manual action) from suspending admins or course moderators, and site-wide suspension requires a distinct system capability. - No SQL injection — all queries are parameterized; dynamic column names are constrained to a hardcoded allow-list.
- No XSS — output is escaped with
s()/format_string()/Mustache; name rendering follows core's own pattern and relies onPARAM_NOTAGScleaning. - CSRF is handled via sesskey on all manual state changes and by the forms/WS frameworks.
- Privacy, backup/restore, uninstall, and upgrade are all implemented thoroughly and correctly.
Findings are limited to one low-severity, largely unavoidable item (direct overwriting of core forum_posts content as the post-hiding mechanism, carefully managed) and two informational notes.
Findings
To hide a reported post, the plugin overwrites the core forum_posts row's message, messageformat, and messagetrust columns with a placeholder, keeping the only copy of the original in its own local_forumcare_hidden table. The same direct write is used to restore content on un-hide, to re-apply the placeholder after an edit, and in the uninstall hook.
This writes to a core table the plugin does not own, bypassing mod_forum's own update path. There is no core API to hide a forum post's body, so this is largely unavoidable, and the plugin manages it unusually carefully:
- The original
message/format/trustare backed up before being overwritten and restored verbatim (so the post's trust level is preserved, not escalated). - An observer on
\mod_forum\event\post_updatedre-hides a post edited through the normal forum flow. db/uninstall.phprestores every still-hidden post before the backup table is dropped.- The privacy provider covers the backup table in export and erasure.
The residual concern is maintenance/compatibility rather than security: direct writes bypass any side effects mod_forum performs on post updates (search re-indexing, word counts, caches), and are coupled to the forum schema. The placeholder itself is built from a trusted language string and written with messagetrust = 0, so it is not an injection vector.
Low risk. This is a code-quality / maintainability observation, not a security weakness. The writes are parameterized (no SQLi), the placeholder is trusted content written with messagetrust = 0, and the original trust level is restored unchanged, so no privilege escalation of post content occurs. The practical downside is that the plugin is coupled to the forum_posts schema and bypasses any update-time side effects mod_forum performs, which could drift across Moodle versions. The plugin's extensive tests and uninstall/restore handling substantially mitigate the data-loss risk inherent in being the sole holder of the original content.
forum_posts is a mod_forum table, not a local_forumcare table. The hide/un-hide lifecycle is the plugin's central feature and there is no stored_file-style or forum-level API that replaces a post's body while preserving a reversible backup, so the plugin implements it with direct $DB->update_record() calls plus its own local_forumcare_hidden backup table. The surrounding code (observer-driven re-hide, deleted-post guard in unhide_post, uninstall restore) keeps the live post and the backup consistent across edits, deletions, privacy erasure, and plugin removal.
$update = new \stdClass();
$update->id = $postid;
$update->message = $placeholder;
$update->messageformat = FORMAT_HTML;
$update->messagetrust = 0;
$DB->update_record('forum_posts', $update);
No change is strictly required; this is an accepted trade-off given the lack of a core hide API. If mod_forum ever exposes a supported way to hide/replace post content, prefer it. Otherwise, document the coupling and keep the behaviour covered by tests (as it already is).
$update = new \stdClass();
$update->id = $postid;
$update->message = $backup->originalmessage;
$update->messageformat = $backup->originalmessageformat;
$update->messagetrust = $backup->originalmessagetrust;
$DB->update_record('forum_posts', $update);
$DB->update_record('forum_posts', (object) [
'id' => $postid,
'message' => $placeholder,
'messageformat' => FORMAT_HTML,
'messagetrust' => 0,
]);
$DB->update_record('forum_posts', (object) [
'id' => $backup->postid,
'message' => $backup->originalmessage,
'messageformat' => $backup->originalmessageformat,
'messagetrust' => $backup->originalmessagetrust,
]);
The get_reasons external function validates the course context (which enforces require_login and course access) but does not call require_capability('local/forumcare:report', ...), unlike its sibling endpoints submit_report and get_post_report_status, which both require that capability.
The data returned is non-sensitive — it is the list of enabled report-reason names (e.g. "Offensive language"), which is intended to be shown to any potential reporter in the course. So this is not a vulnerability; it is a minor defense-in-depth and consistency observation. A user who can access the course but has been specifically denied local/forumcare:report could still fetch the reason list, with no meaningful impact.
Informational. No sensitive data is exposed and course access is already enforced by validate_context(). The note is purely about aligning this endpoint's access control with its siblings for clarity and robustness; it does not affect the security posture or grade.
The endpoint backs the report modal's reason dropdown (amd/src/report.js → local_forumcare_get_reasons). It is only reachable by logged-in users with access to the given course context. The reason names are deliberately visible to reporters, so there is no information-disclosure concern of substance.
public static function execute(int $courseid): array {
$params = self::validate_parameters(self::execute_parameters(), ['courseid' => $courseid]);
self::validate_context(\context_course::instance($params['courseid']));
$records = helper::get_reasons_for_course($params['courseid']);
return array_map(function ($r) {
return ['id' => (int) $r->id, 'name' => $r->name];
}, $records);
}
For consistency with the other endpoints and defense in depth, add a capability check after validating the context:
$context = \context_course::instance($params['courseid']);
self::validate_context($context);
require_capability('local/forumcare:report', $context);
The capabilities hint is already declared for the other write/read endpoints in db/services.php; adding 'capabilities' => 'local/forumcare:report' there as well would keep the external-service UI accurate.
Two language strings are defined but never referenced anywhere in the plugin's PHP, JavaScript, or templates:
action:deletepost("Delete post content") — the README explicitly states post deletion is intentionally delegated tomod_forum's own flow and not implemented here, so this string is dead.confirmaction("Are you sure you want to perform this action?") — moderator actions are sesskey-protected links without a JS confirmation step, so this is unused.
These are harmless housekeeping leftovers (the Moodle linter does not flag unused strings), but removing them keeps the language file aligned with the code.
Informational. No functional or security impact — purely a tidiness item.
Both strings exist in lang/en/local_forumcare.php but a full-text search of the plugin shows no get_string('action:deletepost', ...) or get_string('confirmaction', ...) usage. The confirmaction string suggests a confirmation step for moderator actions was considered but not implemented (see the related note about high-impact actions lacking a confirm step).
$string['action:deletepost'] = 'Delete post content';
Remove the two unused strings, or wire them up if the corresponding features (post deletion / a confirmation step on moderator actions) are intended.
$string['confirmaction'] = 'Are you sure you want to perform this action?';
Auto-moderation acts on student input — safeguards are present. Submitting a report can auto-hide a post or auto-suspend the author's enrolment/account. This is the intended feature, and the collusion surface is well mitigated: thresholds count distinct reporters (a unique (postid, reporterid) index stops one user inflating a post's count, and COUNT(DISTINCT reporterid) stops one user reaching the suspend threshold alone), privileged users are shielded by is_protected_from_suspension(), reporting is opt-in per forum under a site master switch, site-wide auto-suspension is admin-only and disabled by default, and teachers can undo reviews. No defect here — noted so operators understand that threshold tuning and the per-forum opt-in are the controls that bound a coordinated-reporting (sybil) scenario.
High-impact moderator actions have no confirmation step. On the review page, actions including Suspend in course and Suspend site-wide are triggered by a single sesskey-protected GET link, whereas reason deletion uses an $OUTPUT->confirm() interstitial. CSRF is already prevented by require_sesskey(), so this is not a security issue, but adding a confirmation (or using POST) for the irreversible/high-impact suspend actions would reduce the chance of an accidental click. The confirmaction string already present in the language file suggests this was anticipated.
Review-queue excerpt shows the live post body. report_table::col_post() renders shorten_text(strip_tags($row->message), 100) from the joined forum_posts.message. For a post that is currently hidden, that value is the placeholder, not the original content held in local_forumcare_hidden.originalmessage. The code comment describes it as the "real, unredacted" content, which only holds for non-hidden posts. Worth verifying this matches the intended reviewer experience — a moderator deciding whether to keep a post hidden may want the original excerpt rather than the placeholder.
Strong supporting quality. The plugin ships a complete privacy provider (including core_userlist_provider) with careful handling of shared report rows and hidden-post backups across export, deletion, and forum/course lifecycle events; backup/restore that re-cleans every field from the untrusted .mbz with clean_param(..., PARAM_INT); a correct db/uninstall.php; upgrade steps with savepoints; and a broad PHPUnit suite (threshold edge cases, privilege protection, privacy-erasure interactions, tampered-backup cleaning, cross-version compatibility shims). The conditional MOODLE_INTERNAL guard is applied correctly — present only in files with file-scope side effects (settings.php, db/*, report_table.php, reason_form.php) and correctly omitted from the autoloaded single-class/function-only files and lib.php.
No thirdpartylibs.xml is present, and none is required: the only build artifact (amd/build/report.min.js) is the grunt-compiled output of the plugin's own amd/src/report.js, not a bundled external library. CI workflow and .camp files contain no hardcoded credentials (secrets are referenced via GitHub Actions secrets / OIDC).