MDL Shield

Quiz archive report

quiz_archive

Print Report
Plugin Information

Quiz archive is a mod_quiz report sub-plugin (quiz_archive) that renders all students' quiz attempts on a single printable page. Its primary purpose is to give teachers a "print / export as PDF" view of every attempt at once. The plugin has no database tables of its own, stores no personal data, and simply extends the mod_quiz\local\reports\attempts_report base class, reusing the standard quiz review display machinery (display_options, question_engine rendering) to produce a single long report.

Version:2026033000
Release:v5.2-r1
Reviewed for:5.1
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-04-16
11 files·1,030 lines
Grade Justification

The plugin is small, focused, and mostly follows Moodle conventions: it uses parameterised $DB queries, the formslib-derived options form (which carries sesskey automatically), a proper null_provider privacy implementation, and it gates the report behind mod/quiz:grade and a call to the parent init() / print_header_and_tabs().

The principal concern is that quizreportgetstudentandattempts() selects all attempts in the quiz without honouring group separation — in SEPARATEGROUPS mode a teacher who lacks moodle/site:accessallgroups will be shown attempts (names, answers, grades, feedback) belonging to students in other groups, bypassing the group control that the parent attempts_report::init() is explicitly designed to enforce. This is an authenticated, teacher-only information disclosure, which constrains the blast radius considerably.

Secondary issues are code-quality: archive_table.php is entirely dead code, referencing strings from the unrelated feedback component and require_once-ing attemptsreport_table.php which was removed in Moodle 5.1; $plugin->requires is 2017110800 (roughly Moodle 3.4) while the README and $plugin->supported claim a minimum of 3.9; and quiz_archive_options::__construct() overrides the parent constructor without chaining it, skipping the initialisation of usercanseegrades.

AI Summary

quiz_archive is a small mod_quiz report sub-plugin. Its single real job is to render every student's quiz attempt on one page so teachers can print or save an archive. The review found:

  • 1 medium finding — the attempts query ignores group separation, so a teacher without moodle/site:accessallgroups can see attempts from other groups in SEPARATEGROUPS mode.
  • 3 low findings — dead archive_table.php file (with a require_once for a Moodle 5.1-removed file and strings borrowed from mod_feedback); $plugin->requires far older than the supported minimum; bundled Behat steps reference a removed core file path only in a never-taken branch.
  • 1 info findingquiz_archive_options::__construct does not chain to the parent constructor.

Security fundamentals are otherwise in good shape: $DB is used with named placeholders, the options come from a moodleform (automatic sesskey), require_capability('mod/quiz:grade', $context) gates the report, output goes through format_string / fullname / html_writer / Moodle renderers, and no filesystem, shell, or direct-DB escapes are present.

Findings

securityMedium
Attempt query ignores separate-groups access control
Exploitable by:
teacher

quiz_archive_report::quizreportgetstudentandattempts() returns every quiz_attempts row for the current quiz (excluding previews) without any filter on group membership. The parent mod_quiz\local\reports\attempts_report::init() explicitly returns $studentsjoins, $groupstudentsjoins, and $allowedjoins derived from get_enrolled_with_capabilities_join() for exactly this purpose, and report_base::get_current_group() degrades to NO_GROUPS_ALLOWED when the user lacks moodle/site:accessallgroups in SEPARATEGROUPS mode.

This plugin's display() calls $this->init(...) but discards all four return values, and the hand-rolled SQL does not join to groups_members, does not filter by enrolment, and does not consult the current group at all. As a result, in SEPARATEGROUPS mode a teacher (archetype teacher) who has mod/quiz:grade but not moodle/site:accessallgroups — the common split between the non-editing teacher and editing teacher roles — is shown the full archive (names, question text, responses, marks, manual comments, overall feedback) for students in groups they are not a member of.

Compare with quiz_overview_report::display() which forwards $allowedjoins to $table->setup_sql_queries($allowedjoins) so the visible rows respect the same group rule.

Risk Assessment

Medium risk. The attack requires the mod/quiz:grade capability, which is only granted to teacher-class roles, so this is not exploitable by students or unauthenticated users. However, the bypass is unconditional for any SEPARATEGROUPS quiz once those preconditions are met, and the disclosed material (free-text answers, teacher comments, grades) is sensitive personal data. The finding is downgraded from high because (a) an elevated role is required and (b) the exposure is read-only — there is no path to data modification or privilege escalation.

Context

report_base::get_current_group() is already written to force NO_GROUPS_ALLOWED when SEPARATEGROUPS is active and the user lacks moodle/site:accessallgroups, and attempts_report::get_students_joins() builds the matching SQL joins. The plugin reuses the parent's init() (so the joins are computed), then simply ignores them and runs its own unfiltered SELECT from {quiz_attempts}. The output is rendered using the normal quiz review machinery, so every piece of data a student supplied to the quiz is exposed — free-text responses, uploaded essays, attachment content and overall feedback included.

Proof of Concept
  1. Create a course with two groups (A, B) and force group mode Separate groups on the quiz activity.
  2. Enrol teacher_a with the non-editing teacher role (which has mod/quiz:grade but not moodle/site:accessallgroups) and add them only to group A.
  3. Enrol student_a in group A and student_b in group B; let each submit a quiz attempt.
  4. Log in as teacher_a and visit /mod/quiz/report.php?id=<cmid>&mode=archive.
  5. The report renders student_b's full attempt (name, answers, marks, feedback) even though teacher_a is not in group B.
Affected Code
$this->init('archive', 'quiz_archive_settings_form', $quiz, $cm, $course);
Suggested Fix

Capture the joins returned by init() so they can be applied to the attempts query, and honour NO_GROUPS_ALLOWED:

[$currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins] =
    $this->init('archive', 'quiz_archive_settings_form', $quiz, $cm, $course);
Affected Code
protected function quizreportgetstudentandattempts($quiz) {
    global $DB;

    // Construct the SQL.
    $sql = "SELECT DISTINCT quiza.id attemptid, u.id userid, u.firstname, u.lastname FROM {user} u " .
        "LEFT JOIN {quiz_attempts} quiza " .
        "ON quiza.userid = u.id WHERE quiza.quiz = :quizid AND quiza.preview = 0 ORDER BY u.lastname ASC, u.firstname ASC";
    $params = ['quizid' => $this->quizobj->id];
    $results = $DB->get_records_sql($sql, $params);
Suggested Fix

Restrict the query with the joins returned from init(). For example, accept $allowedjoins as an argument and inline its SQL fragments:

protected function quizreportgetstudentandattempts($quiz, \core\dml\sql_join $allowedjoins) {
    global $DB;

    if (empty($allowedjoins->joins)) {
        // No user is visible to the current teacher - bail out.
        return [];
    }

    $sql = "SELECT DISTINCT quiza.id AS attemptid, u.id AS userid, u.firstname, u.lastname
              FROM {user} u
                   {$allowedjoins->joins}
              JOIN {quiz_attempts} quiza ON quiza.userid = u.id
             WHERE {$allowedjoins->wheres}
               AND quiza.quiz = :quizid
               AND quiza.preview = 0
          ORDER BY u.lastname ASC, u.firstname ASC";
    $params = $allowedjoins->params + ['quizid' => $this->quizobj->id];
    return $DB->get_records_sql($sql, $params);
}

Also handle the NO_GROUPS_ALLOWED case returned by get_current_group() by displaying the standard notingroup notification and returning.

code qualityLow
Dead archive_table.php class with stale require_once and wrong language strings

archive_table.php defines quiz_archive_table extends flexible_table but the class is never referenced anywhere in the plugin (verified by grepping the repository — the only occurrence is the class declaration itself). The file still causes two kinds of problems:

  • Its require_once($CFG->dirroot . '/mod/quiz/report/attemptsreport_table.php'); targets a file that was removed in Moodle 5.1 (mod/quiz/UPGRADING.md lists mod/quiz/report/attemptsreport_table.php under final deprecations). Any attempt to include this file on a 5.1+ site will fatal-error with "Failed opening required...". Because the file is unused today this is latent, but it is a trap for any future refactor that decides to pull the class in.
  • The class was clearly copy-pasted from mod_feedback: the columns are named template, it uses get_string('template', 'feedback'), get_string('no_templates_available_yet', 'feedback'), get_string('delete_template', 'feedback') and get_string('confirmdeletetemplate', 'feedback'), it renders a t/delete icon labelled with a feedback-template delete string, and builds URLs with deletetempl — none of which correspond to anything the quiz archive report does.

The file should simply be deleted.

Risk Assessment

Low risk. No security impact — the file is never executed. The risk is forward-compatibility churn (the require_once will fatally fail the day anybody does include it on Moodle 5.1+) and maintenance confusion (the class looks authoritative but has nothing to do with quiz archives).

Context

The plugin's active code paths (report.php, archive_form.php, archive_options.php) have no require_once or new on this class. Even report.php only pulls in archive_options.php and archive_form.php. The presence of the file inflates the release ZIP and provides a wrong example if someone uses this plugin as a template.

Identified Code
require_once($CFG->dirroot . '/mod/quiz/report/attemptsreport_table.php');
Suggested Fix

Delete archive_table.php entirely. It is not referenced anywhere, its class body mixes feedback-module semantics with the quiz-archive namespace, and the require_once at the top points at a file that no longer exists in Moodle 5.1+.

Identified Code
class quiz_archive_table extends flexible_table {
Suggested Fix

Remove the file. The class is dead code copy-pasted from mod_feedback's template table and does not match anything the quiz archive report does.

code qualityLow
$plugin->requires is far older than the real minimum supported version

version.php declares $plugin->requires = 2017110800, which is roughly the Moodle 3.4 release window (November 2017). However $plugin->supported = [39, 502] and the README.md both state that Moodle 3.9 is the minimum. The code backs up 3.9 as the true minimum — report.php calls normalize_version($CFG->release) from environmentlib.php and relies on the class-aliasing dance against \mod_quiz\local\reports\report_base / \mod_quiz_attempts_report_form that has no equivalent on a 3.4 site.

Because Moodle only refuses to install a plugin when $CFG->version is below $plugin->requires, an administrator running Moodle 3.4–3.8 can currently install this plugin; it will then fail at runtime when quiz_create_attempt_handling_errors, quiz_attempt_state, normalize_version, etc. do not behave as expected.

Risk Assessment

Low risk. No security impact. The effect is that someone running an out-of-support Moodle could install the plugin and then see fatal errors from calls the site cannot resolve. It is a release-hygiene issue, not an attack surface.

Context

Moodle's plugin installer uses requires as the hard gate. supported is only used by admin/tool/installaddon to surface warnings; it does not block installation. Leaving requires pinned to 2017 means the advertised support window and the technically enforceable one disagree.

Identified Code
$plugin->version  = 2026033000;
$plugin->requires = 2017110800;
$plugin->component = 'quiz_archive';
$plugin->maturity = MATURITY_STABLE;
$plugin->release = 'v5.2-r1';
$plugin->supported = [39, 502];
Suggested Fix

Raise requires to the version code of the earliest release listed in $plugin->supported. For Moodle 3.9 that is 2020061500:

$plugin->requires = 2020061500; // Moodle 3.9.
best practiceInfo
quiz_archive_options constructor skips parent::__construct

quiz_archive_options::__construct replaces the parent attempts_report_options::__construct wholesale and does not call parent::__construct(). The parent constructor does one thing the override does not — it sets $this->usercanseegrades = quiz_report_should_show_grades($quiz, context_module::instance($cm->id)).

The plugin happens not to consult $usercanseegrades anywhere (it re-derives grade visibility from $options->marks and quiz_has_grades($quiz) inside quiz_report_get_student_attempt), so today there is no functional regression. It is still worth noting because any future rebase onto parent code that reads $options->usercanseegrades (for example, when the attempts_report family is refactored again) will silently misbehave: $usercanseegrades will be null instead of a boolean.

Risk Assessment

Info. Purely a maintenance / contract issue. No user-visible impact on current Moodle releases.

Context

The override was probably written to avoid the quiz_report_should_show_grades call in older Moodle. Today that function is stable and safe, and omitting the parent call is pure drift.

Identified Code
public function __construct($mode, $quiz, $cm, $course) {
    $this->mode   = $mode;
    $this->quiz   = $quiz;
    $this->cm     = $cm;
    $this->course = $course;
}
Suggested Fix

Chain to the parent constructor so usercanseegrades gets populated correctly:

public function __construct($mode, $quiz, $cm, $course) {
    parent::__construct($mode, $quiz, $cm, $course);
}

The whole override can then be deleted — there is no behaviour left to add.

best practiceInfo
No PHPUnit test coverage

The plugin ships Behat coverage (tests/behat/*.feature) but no PHPUnit tests under tests/*.php. The critical piece of logic that would benefit most from unit tests — the attempts query in quizreportgetstudentandattempts — is therefore only covered at the UI level, making regressions like the group-filtering gap in finding #1 easy to miss.

This is purely a best-practice observation; nothing in Moodle's rules mandates PHPUnit tests for a simple report plugin.

Risk Assessment

Info. No direct security impact — a suggestion to improve the long-term maintainability of the report.

Context

The plugin directory layout contains only tests/behat/. A senior Moodle developer would expect at least a smoke-level PHPUnit test that drives quiz_archive_report::display() against a fixture quiz and asserts the shape of the attempts returned.

Additional AI Notes

SQL discipline is good elsewhere. All $DB calls (get_records_sql, insert_record, execute, get_record) use named placeholders or property arrays — no string concatenation of user input into SQL. The single UPDATE in db/upgrade.php is a static literal.

sesskey / CSRF is covered by the framework. The only state the user submits is via quiz_archive_settings_form, which extends moodleform via attempts_report_options_form — the Moodle forms library validates sesskey automatically. There are no manual POST handlers.

Capability gating is correct. require_capability('mod/quiz:grade', $this->context) is called before any data is rendered, and the parent report dispatcher already calls require_login($course, false, $cm) in /mod/quiz/report.php before the plugin's display() runs.

Privacy API is declared correctly. quiz_archive\privacy\provider implements \core_privacy\local\metadata\null_provider and returns a real language string key — appropriate because the plugin has no DB tables of its own.

Output is escaped. Names flow through fullname(), the page title through format_string(), grades through html_writer::tag(), and rendered question content through the core question_engine renderer. No manual string interpolation into HTML was found.

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