MDL Shield

Course ratings

tool_courserating

Print Report
Plugin Information

A course rating and review plugin for Moodle that allows students to rate courses with 1-5 stars and optionally leave text reviews. Displays ratings via custom course fields, supports rich text reviews, flagging inappropriate reviews, per-course configuration overrides, Privacy API, and Report Builder integration. Uses Moodle hooks (4.3+) with legacy callback fallbacks.

Version:2026032800
Release:4.5.0
Reviewed for:5.1
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-04-10
64 files·7,851 lines
Grade Justification

The plugin demonstrates generally good security practices throughout: proper use of $DB parameterized queries, capability checks on all entry points, correct use of Moodle's dynamic_form (which handles sesskey automatically), proper clean_param() usage on fragment API arguments, and safe output escaping in Mustache templates.

The grade is driven by two medium-severity findings:

  1. The tool_courserating_course_rating_popup web service (accessible without login) does not call validate_context(), which is a required security step for all Moodle external functions. While the function does perform its own permission checks via require_can_view_ratings(), the missing validate_context() call means session and context security measures are bypassed.

  2. The api::set_rating() method does not validate that the submitted rating value falls within the expected 1–5 range. A student with the tool/courserating:rate capability could submit out-of-range values (e.g., 0, -1, or 99) via a crafted request, corrupting course rating summaries.

Additionally, there are several low-severity issues: a missing global $CFG declaration in two hook callbacks causing potential PHP errors, an incomplete Privacy API implementation (flags not exported), unused class imports, deprecated external API usage, and a memory-inefficient query pattern in the reindex method.

No critical or high-severity vulnerabilities were found. All SQL is properly parameterized, XSS protections are in place, and access controls are consistently applied.

AI Summary

Plugin Overview

tool_courserating is a well-structured Moodle admin tool plugin that adds course rating and review functionality. Key features include:

  • Star ratings (1–5) displayed via custom course fields on course listings
  • Optional text reviews with rich text editor support
  • Review flagging system for reporting inappropriate content
  • Per-course overrides for rating mode and review visibility
  • Report Builder integration with datasource and system reports
  • Privacy API implementation for GDPR compliance
  • Hook-based architecture with legacy callback fallbacks for older Moodle versions

Security Posture

The plugin follows Moodle security conventions well:

  • All database operations use $DB API with parameterized queries
  • Capability checks (tool/courserating:rate, tool/courserating:delete, tool/courserating:reports) are enforced consistently
  • Form handling uses dynamic_form which provides automatic sesskey validation
  • Fragment API callbacks validate parameters with clean_param()
  • File serving checks permissions and validates file area/item IDs
  • Color settings are validated with strict regex before CSS output

Key Concerns

Two medium-severity issues were identified:

  1. A web service missing validate_context() — the standard Moodle security requirement for external functions
  2. Missing server-side validation of rating value range, allowing data corruption via crafted requests

Several low-severity code quality and compliance issues were also found, including missing global declarations in hook callbacks and an incomplete Privacy API implementation.

Findings

securityMedium
Web service missing validate_context() call
Exploitable by:
unauthenticatedgueststudent

The tool_courserating_course_rating_popup external function does not call self::validate_context(), which is a required security step for all Moodle external functions.

Moodle's external API documentation states that every external function must call validate_context() to properly set up the execution context, clean the session, and apply security measures. This web service is declared with loginrequired => false, making it accessible to unauthenticated users.

While the function does perform permission checks via require_can_view_ratings(), the missing validate_context() means Moodle's standard context security setup is skipped.

Risk Assessment

Medium risk. The missing validate_context() means Moodle's standard context security setup (session cleanup, theme initialization, capability context) is not properly initialized. The function does have its own permission checks, which significantly mitigates the risk. The practical impact is limited because:

  • The endpoint only returns HTML/JS for course rating display (read-only)
  • Permission checks prevent access to private course data
  • No state-changing operations are performed

However, this violates a core Moodle security requirement and could interact poorly with future Moodle security changes.

Context

The web service tool_courserating_course_rating_popup is designed to allow viewing course ratings on public course listings without requiring login. It replicates the Fragment API's core_get_fragment pattern but intentionally omits the login requirement. The function internally calls require_can_view_ratings() which checks whether the course is publicly visible or the user is enrolled. However, it skips the standard validate_context() call that Moodle requires for all external functions.

Proof of Concept

An unauthenticated user can call the web service directly:

POST /lib/ajax/service-nologin.php
[{"index":0,"methodname":"tool_courserating_course_rating_popup","args":{"courseid":2}}]

The request is processed without the standard Moodle context validation that validate_context() provides, potentially bypassing session cleanup and context-level security checks.

Affected Code
public static function execute($courseid) {
    global $PAGE, $OUTPUT, $CFG;
    require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/courserating/lib.php');

    // Basically copied from the core_get_fragment WS except for login check.

    // Hack alert: Set a default URL to stop the annoying debug.
    $PAGE->set_url('/');
    // Hack alert: Forcing bootstrap_renderer to initiate moodle page.
    $OUTPUT->header();

    // Overwriting page_requirements_manager with the fragment one so only JS included from
    // this point is returned to the user.
    $PAGE->start_collecting_javascript_requirements();
    $data = tool_courserating_output_fragment_course_ratings_popup(['courseid' => $courseid]);
    $jsfooter = $PAGE->requires->get_end_code();
    $output = ['html' => $data, 'javascript' => $jsfooter];
    return $output;
}
Suggested Fix

Add a validate_context() call at the beginning of the execute method, before any other processing:

public static function execute($courseid) {
    global $PAGE, $OUTPUT, $CFG;
    require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/courserating/lib.php');

    $params = self::validate_parameters(self::execute_parameters(), ['courseid' => $courseid]);
    $context = \context_course::instance($params['courseid'], IGNORE_MISSING) ?? \context_system::instance();
    self::validate_context($context);
    // ... rest of the method
}
securityMedium
Missing server-side rating value range validation
Exploitable by:
student

The api::set_rating() method accepts the rating value from form data without validating it falls within the expected 1–5 range. The source code even contains a TODO comment acknowledging this gap: // TODO validate rating is within limits.

The form validation in addrating.php only checks that the rating is non-empty (empty($data['rating'])) but does not verify the value is between 1 and 5. Since the dynamic_form framework processes form submissions from AJAX, a user can craft a request with an arbitrary integer value.

The rating model defines the field as PARAM_INT, which ensures it is an integer, but values like 0, -5, 99, or 1000 would be accepted and stored.

Risk Assessment

Medium risk. This is a data integrity issue exploitable by any authenticated user with the tool/courserating:rate capability (students by default). The impact includes:

  • Corrupted average ratings displayed on course listings
  • Invalid summary statistics (counts for non-existent star levels)
  • Potential division-by-zero or display issues if rating is 0

Mitigating factors: the attacker must be enrolled in the course, the attack only affects the target course's rating data, and it does not lead to privilege escalation or data exposure.

Context

The set_rating() method is the central API for creating and updating course ratings. It is called from the addrating dynamic form's process_dynamic_submission() method. The form presents radio buttons for values 1–5, but the server-side validation does not enforce this range. The persistent model (rating.php) defines the rating property as PARAM_INT which sanitizes to integer but does not constrain the range. The summary model (summary.php) uses the rating value for calculating averages and per-star counts (e.g., cnt01 through cnt10), meaning out-of-range values would corrupt aggregated data.

Proof of Concept

A student enrolled in a course with the tool/courserating:rate capability can submit a crafted AJAX request to the dynamic_form handler with an arbitrary rating value:

POST /lib/ajax/service.php
[{"index":0,"methodname":"core_form_dynamic_form","args":{"formdata":"courseid=2&rating=99&sesskey=...","form":"tool_courserating%5Cform%5Caddrating"}}]

This would store a rating of 99, corrupting the course's average rating and summary statistics (e.g., an average rating of 50+ stars instead of the expected 1–5 range).

Affected Code
// TODO validate rating is within limits, trim/crop review.
$rating = $data->rating;
Suggested Fix

Add validation before using the rating value:

$rating = (int)$data->rating;
if ($rating < 1 || $rating > 5) {
    throw new \moodle_exception('invalidrating', 'tool_courserating');
}
Affected Code
public function validation($data, $files) {
    $errors = [];
    if (empty($data['rating'])) {
        $errors['ratinggroup'] = get_string('required');
    }
    return $errors;
}
Suggested Fix

Add range validation to the form:

public function validation($data, $files) {
    $errors = [];
    if (empty($data['rating'])) {
        $errors['ratinggroup'] = get_string('required');
    } else if ($data['rating'] < 1 || $data['rating'] > 5) {
        $errors['ratinggroup'] = get_string('required');
    }
    return $errors;
}
code qualityLow
Missing global $CFG declaration in hook callbacks

Two hook callback methods reference $CFG->upgraderunning without declaring global $CFG first. In PHP 8.0+, accessing a property on an undefined variable inside isset() generates a warning and may cause unexpected behavior.

The before_http_headers callback correctly declares global $PAGE, $CFG, but the other two callbacks do not follow the same pattern.

Risk Assessment

Low risk. This is a PHP bug that could cause warnings in PHP 8.0+ logs and means the upgrade guard does not function. During a Moodle upgrade, the plugin hooks would continue to execute when they should be skipped, potentially causing errors if plugin tables or APIs are not yet available. This is not a security vulnerability but could cause upgrade failures.

Context

These hook callbacks are registered in db/hooks.php and execute on every page load in Moodle 4.3+. The early-return guard is meant to prevent the plugin from running during installation or upgrade, but the missing global $CFG means the upgrade check never works correctly. The before_http_headers callback in the same directory correctly declares global $PAGE, $CFG.

Identified Code
public static function callback(\core\hook\output\before_footer_html_generation $hook): void {

    if (during_initial_install() || isset($CFG->upgraderunning)) {
        // Do nothing during installation or upgrade.
        return;
    }

    global $PAGE;
Suggested Fix

Add global $CFG; before the isset() check, or combine it with the existing global $PAGE; declaration:

public static function callback(\core\hook\output\before_footer_html_generation $hook): void {
    global $PAGE, $CFG;

    if (during_initial_install() || isset($CFG->upgraderunning)) {
        return;
    }
Identified Code
public static function callback(\core\hook\output\before_standard_head_html_generation $hook): void {

    if (during_initial_install() || isset($CFG->upgraderunning)) {
        // Do nothing during installation or upgrade.
        return;
    }
Suggested Fix

Add global $CFG; at the beginning of the method:

public static function callback(\core\hook\output\before_standard_head_html_generation $hook): void {
    global $CFG;

    if (during_initial_install() || isset($CFG->upgraderunning)) {
        return;
    }
complianceLow
Incomplete Privacy API: flag data not exported

The Privacy API provider declares metadata for the tool_courserating_flag table and correctly identifies users in contexts via flag records, but the export_user_data() method has a TODO comment acknowledging that flag data is not exported.

This means GDPR/data subject access requests will return incomplete data — a user's flags on other users' reviews will not appear in their data export.

Risk Assessment

Low risk. This is a GDPR compliance gap. While the flag data (a simple record of a user flagging another's review) is relatively low-sensitivity, data subject access requests are legally required to return all personal data. The deletion methods correctly handle flags, so the right to erasure is properly implemented.

Context

The Privacy API provider correctly implements get_metadata() (declaring both tables), get_contexts_for_userid() (querying both ratings and flags), get_users_in_context() (including flag users), and delete_data_for_user() (deleting flag records). Only the export_user_data() method is incomplete, missing the flag export.

Identified Code
        // TODO export flags.
    }
Suggested Fix

Add flag export logic after the ratings export loop. Query the tool_courserating_flag table for the user's flags, join with the rating and course tables to get context, and export each flag record under an appropriate subcontext.

code qualityLow
Unused imports from tool_dataprivacy plugin

The rating_exporter.php file imports two classes from tool_dataprivacy that are never used in the file:

  • tool_dataprivacy\category
  • tool_dataprivacy\context_instance

These imports create an unnecessary dependency on the tool_dataprivacy plugin and could cause autoload errors if that plugin is not installed.

Risk Assessment

Low risk. This is a code quality issue with no security impact. The unused imports do not cause runtime errors but could confuse developers and represent unnecessary coupling to an unrelated plugin.

Context

The rating_exporter class exports individual rating data for display in the ratings popup. It uses classes from core, core_user, tool_courserating namespaces, but the two tool_dataprivacy imports appear to be leftover from development. PHP use statements do not trigger autoloading until the class is actually referenced, so this does not cause runtime errors, but it is misleading dead code.

Identified Code
use tool_dataprivacy\category;
use tool_dataprivacy\context_instance;
Suggested Fix

Remove the unused import statements:

// Remove these two lines:
// use tool_dataprivacy\category;
// use tool_dataprivacy\context_instance;
code qualityLow
Memory-intensive get_records_sql in reindex method

The api::reindex() method loads all courses into memory at once using $DB->get_records_sql() when no specific $courseid is provided. On large Moodle installations with tens of thousands of courses, this could consume excessive memory.

The recommended Moodle pattern for iterating over large result sets is $DB->get_recordset_sql() which streams records one at a time.

Risk Assessment

Low risk. This is a performance/resource concern, not a security issue. The method runs in a cron task context where PHP memory limits are typically higher, but on very large installations (50,000+ courses), it could hit memory limits. The impact is limited to task failure, not data corruption or security compromise.

Context

The reindex() method is called from the reindex adhoc task (scheduled via cron) and also when course settings change. When called without a specific course ID, it processes all courses in the system. The query joins courses with summary, custom field data, and optionally rating mode data.

Identified Code
$records = $DB->get_records_sql($sql, $params);
foreach ($records as $record) {
    $record->actualratingmode = helper::get_setting(constants::SETTING_RATINGMODE);
    if ($percourse && $record->rateby && array_key_exists($record->rateby, constants::rated_courses_options())) {
        $record->actualratingmode = $record->rateby;
    }
    self::reindex_course($record);
}
Suggested Fix

Use get_recordset_sql() to stream records:

$recordset = $DB->get_recordset_sql($sql, $params);
foreach ($recordset as $record) {
    $record->actualratingmode = helper::get_setting(constants::SETTING_RATINGMODE);
    if ($percourse && $record->rateby && array_key_exists($record->rateby, constants::rated_courses_options())) {
        $record->actualratingmode = $record->rateby;
    }
    self::reindex_course($record);
}
$recordset->close();
code qualityLow
Deprecated external API usage

The plugin uses the legacy external API classes (external_api, external_function_parameters, external_single_structure, external_value) and requires externallib.php manually in multiple locations. Since Moodle 4.2, these have been moved to the \core\external\ namespace.

The plugin requires Moodle 4.05+ (version 2024100700), meaning the modern API is always available.

Risk Assessment

Low risk. The deprecated API still works via Moodle's compatibility layer and will continue to work for the foreseeable future. However, relying on deprecated APIs creates technical debt and the require_once calls are unnecessary overhead.

Context

The legacy external API classes are still functional via Moodle's backward-compatibility layer, which maps old class names to new namespaced classes. The require_once calls are necessary for the legacy approach but not needed with the modern namespaced API which uses autoloading.

Identified Code
require_once($CFG->libdir . '/externallib.php');
Suggested Fix

Remove the require_once and update the use statements to use the modern namespaced classes:

use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
Identified Code
require_once($CFG->dirroot . '/lib/externallib.php');
\external_api::validate_context(context_system::instance());
Suggested Fix

Use the namespaced class directly:

\core_external\external_api::validate_context(context_system::instance());
Identified Code
require_once($CFG->libdir . '/externallib.php');
[$text, $format] = external_format_text(
Suggested Fix

Use the namespaced function or the function from the autoloaded core external API.

best practiceInfo
Dead backward-compatibility code in reportbuilder datasource

The courseratings datasource contains a version check for Moodle 4.1 vs 4.0 ($CFG->version > 2022110000), but the plugin's version.php declares $plugin->requires = 2024100700 (Moodle 4.05). The else branch targeting Moodle 4.0 can never execute and is dead code.

Risk Assessment

Informational. Dead code with no functional or security impact. Removing it simplifies maintenance.

Context

The plugin requires Moodle 4.05 (version 2024100700), which is well above the 4.1 threshold (version 2022110000). The old \core_course\local\entities\course_category class path was renamed in Moodle 4.1. Since the plugin cannot be installed on versions older than 4.05, the fallback path is unreachable.

Identified Code
if ($CFG->version > 2022110000) {
    // Course category entity was renamed in 4.1.
    $coursecategoryentity = new \core_course\reportbuilder\local\entities\course_category();
} else {
    $coursecategoryentity = new \core_course\local\entities\course_category();
}
Suggested Fix

Remove the version check and always use the 4.1+ class:

$coursecategoryentity = new \core_course\reportbuilder\local\entities\course_category();
Additional AI Notes

The plugin has a comprehensive test suite including PHPUnit tests (api_test.php, helper_test.php, observer_test.php, permission_test.php, privacy/provider_test.php, reportbuilder/datasource/courseratings_test.php), Behat step definitions, and a test data generator. This is a positive indicator of code quality.

The helper.php file contains a private wordings() method (lines 36–65) with string literals referencing Udemy's UI. This appears to be a developer reference for UI wording inspiration and has no functional impact, but could be cleaned up.

The plugin correctly implements the Moodle hooks system (4.3+) with fallback to legacy callbacks in lib.php. The hook registrations in db/hooks.php correspond to the three legacy callbacks (before_http_headers, before_footer, before_standard_html_head), demonstrating proper forward-compatible architecture.

Color settings (starcolor, ratingcolor) are properly validated with a strict regex pattern (/^#[a-f0-9]{6}$/) before being interpolated into CSS output in get_rating_colour_css(), effectively preventing CSS injection via admin settings.

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