MDL Shield

Late Penalty

local_latepenalty

Print Report
Plugin Information

A local plugin that applies progressive, configurable late-submission grade penalties to any gradable Moodle activity by observing the gradebook user_graded event. It adds per-activity penalty rules (daily rate + maximum cap), per-user and per-group overrides, a deadline priority chain (completionexpected, module-native user/group overrides, module deadline field), a course-level penalty report with CSV/Excel export, automatic recalculation when rules change, activity/course badges, and full backup/restore, Privacy API and deletion-cleanup support. Ships English and Brazilian Portuguese language packs.

Version:2026081108
Release:v1.1.0
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-09-07
49 files·12,183 lines
Grade Justification

The plugin contains no security vulnerabilities and demonstrates security engineering well above the norm for a Moodle local plugin.

Every user-facing entry point (overrides.php, report.php, report_export.php) pairs require_login() with the correct require_capability()local/latepenalty:manageoverrides at module level and local/latepenalty:viewreport at course level. State-changing deletes call require_sesskey() after a data_submitted()/confirm gate, while add/edit go through moodleform, which enforces sesskey automatically. All database access uses $DB with named placeholders and get_in_or_equal(); the only interpolated identifiers are module table/field names drawn from hardcoded whitelists. Output is escaped through Mustache auto-escaping and format_string()/fullname(), and the two AMD modules inject exclusively via textContent/setAttribute.

Most notably, the plugin rigorously enforces the separate-groups boundary and blocks insecure direct object references: existing overrides are always fetched through fetch_scoped_override() and a dedicated group_scope resolver, so a group-restricted teacher cannot read, edit, or delete an override outside their scope even by guessing an ID — all backed by an extensive regression suite. Privacy API (export, deletion, userlist), backup/restore with a userinfo gate, and cleanup on both the module-deletion and whole-course-deletion paths are implemented and tested.

The single reported issue is low severity: a teacher-only, non-identifying aggregate 'pending students' count that does not honour the same separate-groups boundary the rest of the plugin enforces. It exposes no personal data. The absence of a db/upgrade.php is noted as a non-security observation, acceptable only if the schema has been stable across releases.

AI Summary

local_latepenalty is a mature, carefully engineered plugin that applies progressive late-submission penalties to any gradable Moodle activity by observing the gradebook user_graded event. Across the controllers, helper classes, two AMD modules, four templates, and an unusually thorough PHPUnit/Behat suite, the security fundamentals are consistently correct.

What was verified:

  • Access controloverrides.php, report.php, and report_export.php each call require_login() and the appropriate require_capability() (manageoverrides at CONTEXT_MODULE, viewreport at CONTEXT_COURSE).
  • CSRF — deletes use require_sesskey() behind a data_submitted()/confirm gate; the $OUTPUT->confirm() continue button POSTs with sesskey (verified against core single_button); add/edit use moodleform.
  • SQL injection — every query is parameterised; dynamic table/field names come only from static whitelists ($deadlinefields, module maps).
  • XSS — Mustache {{ }} escaping plus format_string()/fullname(); AMD writes via textContent and setAttribute, never innerHTML.
  • IDOR / separate groups — override reads funnel through fetch_scoped_override() and the group_scope resolver; the report and override pages confine a group-restricted teacher to their own group, with dedicated regression tests for edit/delete-by-ID out-of-scope attempts.
  • Lifecycle — Privacy API (export + deletion + userlist), backup/restore gated on userinfo, and orphan cleanup on both course_module_deleted and before_course_deleted.

Findings: a single low-severity observation — the teacher-facing badge's 'pending students' count is computed course-wide and does not apply the plugin's own separate-groups scoping, so a group-restricted non-editing teacher sees an aggregate (non-identifying) count spanning other groups. No exploitable vulnerability was identified.

Findings

securityLow
Teacher penalty badge reports a course-wide pending-student count that ignores separate-groups scoping
Exploitable by:
teacher

The teacher-facing late-penalty badges compute a "N pending" student count over every enrolled student in the course, with no group filter. In a course (or activity) that uses separate groups, a non-editing teacher who is otherwise confined to their own group — as the report and override management pages correctly enforce via group_scope and resolve_group_restriction() — still sees a count that includes students from groups they cannot otherwise see.

The count is an aggregate integer only: no student names, identities, or per-student rows are exposed. What leaks is the existence and size of activity that belongs to other groups (for example, that some students outside the caller's group have not yet submitted).

This is the one place in the plugin where the separate-groups boundary it enforces everywhere else is not applied. It is worth aligning for consistency, not because it exposes sensitive data.

Risk Assessment

Low risk. The exposed data is a single non-identifying aggregate (a count of students who have not completed an activity), shown only to a user who already holds local/latepenalty:viewreport — i.e. a teacher, a trusted role. No names, grades, or per-student records cross the group boundary, and aggregate class-size figures of this kind are commonly visible to teachers in stock Moodle.

The blast radius is limited to non-editing teachers in courses/activities configured for separate groups; editing teachers and managers legitimately hold accessallgroups and are meant to see everything. The practical impact is that such a teacher can infer that other groups exist and roughly how many of their members are pending — a minor confidentiality inconsistency rather than an exploitable disclosure of protected data. It is reported chiefly because the plugin enforces the separate-groups boundary meticulously everywhere else, making this the odd path out.

Context

The plugin injects late-penalty UI in two hook callbacks in classes/hook_listener.php: inject_course_notices() (course-page badges, via load_pending_counts()) and inject_activity_notice() (activity-header notice, via count_pending_students()). Both take the teacher path only when has_capability('local/latepenalty:viewreport', context_course) is true, which by default includes the non-editing teacher archetype.

The count helpers count role_assignments at the course context with role archetype student and subtract those with completionstate >= 1. Neither query joins groups_members or otherwise filters by the caller's groups.

By contrast, report\controller::resolve_group_restriction() and group_scope::resolve_activity_restriction() deliberately confine a caller without accessallgroups to their own group's data on the report and override pages, and this behaviour is covered by regression tests (report/controller_test.php, group_scope_test.php). The badge count is the lone code path that does not participate in that model.

Proof of Concept
  1. Create a course, set group mode = Separate groups and force it.
  2. Create groups A and B; enrol students into each; enrol a non-editing teacher (teacher archetype, which lacks moodle/site:accessallgroups) into group A only.
  3. Add an activity with a late-penalty rule enabled and an overdue deadline (completionexpected in the past). Leave students in both groups without a completion mark.
  4. Log in as the group-A teacher and open the course page (or the activity page).
  5. The badge reads e.g. "Penalty: X% · N pending", where N counts pending students from both groups A and B — even though the same teacher, on the penalty report for the same activity, is correctly restricted to group A's rows only.
Identified Code
        $total = (int) $DB->count_records_sql(
            "SELECT COUNT(DISTINCT ra.userid)
               FROM {role_assignments} ra
               JOIN {role} r ON r.id = ra.roleid AND r.archetype = 'student'
              WHERE ra.contextid = :contextid",
            ['contextid' => $contextid]
        );
Suggested Fix

When the caller lacks moodle/site:accessallgroups in a separate-groups context, confine both the total and the completed counts to members of the caller's own group(s), reusing the group-membership logic already implemented in group_scope::resolve_activity_restriction() / report\controller::resolve_group_restriction().

For example, resolve the caller's allowed group IDs once and add JOIN {groups_members} gm ON gm.userid = ra.userid AND gm.groupid {IN :groups} to both count queries.

Alternatively, if a course-wide aggregate is considered acceptable for teachers, document explicitly that this badge count is intentionally not group-scoped, so the inconsistency with the report and override pages is a deliberate choice rather than an oversight.

Identified Code
        $total = (int) $DB->count_records_sql(
            "SELECT COUNT(DISTINCT ra.userid)
               FROM {role_assignments} ra
               JOIN {role} r ON r.id = ra.roleid AND r.archetype = 'student'
              WHERE ra.contextid = :contextid",
            ['contextid' => $contextid]
        );
Suggested Fix

Same fix as count_pending_students()load_pending_counts() (the bulk variant used by the course-page badge path in inject_course_notices()) shares the identical unscoped count and should apply the same group restriction.

Additional AI Notes

No db/upgrade.php is present. This is only correct if the database schema in db/install.xml has never changed across released versions. The plugin has shipped several releases (v1.0.0 → v1.1.0 per CHANGES.md) and db/install.xml includes fields such as recalc_on_deadline and recalc_on_rate. If any of these columns (or the local_latepenalty_group_overrides table) were introduced after an initial public release, sites upgrading from that release would be missing the columns and the plugin would fail at runtime, because a schema change must be paired with a db/upgrade.php step and a version.php bump. This could not be confirmed as a defect from the source snapshot alone (no VCS history was inspected), so it is recorded as an observation rather than a finding — but the maintainer should confirm that every schema field has existed since the first published version.

No db/uninstall.php, and none is required. The plugin's three tables are dropped automatically by Moodle on uninstall. Its only writes outside its own tables are legitimate gradebook mutations via grade_item::update_final_grade() and grade_grades_history rows tagged source = 'local_latepenalty'; those are real grades/history that should not be reverted on uninstall, so there is no orphaned-data cleanup obligation here.

Packaging is clean. .gitattributes marks docs/ and .github/ as export-ignore, so the bundled Jekyll documentation site and CI workflows are stripped from the released package. No third-party libraries are bundled: amd/build/*.min.js are the plugin's own compiled AMD sources, and courseinfo.js imports Bootstrap's tooltip from core (theme_boost/bootstrap/tooltip) rather than shipping its own copy. No thirdpartylibs.xml is therefore needed.

Strong defensive posture and test coverage. The codebase shows evidence of prior security hardening (per CHANGES.md v1.1.0: a fixed backup data leak, orphaned-row cleanup, a hidden-activity information leak, and group scoping across the report and override pages). The accompanying PHPUnit suite exercises the real observer/grade chain, IDOR/out-of-scope edit and delete attempts, the Privacy API, and the backup/restore cycle including the userinfo gate — a level of coverage that materially raises confidence in the review.

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