MDL Shield

Real time events

tool_realtime

Print Report
Plugin Information

tool_realtime is an admin-tool plugin that provides a framework for real-time server-to-client (and optional client-to-server) communication in Moodle plugins. It defines a pluggable realtimeplugin subplugin type with two backends bundled in this repository: realtimeplugin_phppoll (long-polling via a database-backed event store) and realtimeplugin_centrifugo (WebSocket via the Centrifugo messaging server, using JWT authentication). Consumer plugins subscribe a channel in PHP, which embeds a server-generated SHA-256 hash in the page; the browser then polls or opens a WebSocket and receives events as PubSub notifications. Version 2.1.1 (build 2026030601), supporting Moodle 4.5 and 5.1.

Version:2026030601
Release:2.1.1
Reviewed for:5.1
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-04-17
67 files·9,674 lines
Grade Justification

The plugin is well-architected and follows Moodle security conventions consistently. State-changing endpoints enforce require_login(), require_sesskey(), require_capability() and web-service context validation. SQL is parameterised through $DB (including get_in_or_equal()), user input passes through clean_param() with appropriate PARAM_* types, and the channel authorisation model is sound: channel hashes are SHA-256 digests computed from context properties plus a server-generated random 32-byte salt, and the phppoll backend authorises polling against the hash list stored in the user's session. The test-settings echo callback is gated by moodle/site:config and only admins can enqueue burst tasks. The one identified defect is a code-quality bug in realtimeplugin_phppoll\plugin::get_all() where the catch (\moodle_exception) block dereferences an undefined $context variable, which would produce a PHP error if a context has been deleted between notify() and polling. A secondary low-severity observation is that the realtimeplugin_centrifugo_get_token web service does not honour the tool_realtime/allowguests setting, though this is mitigated by the fact that channel hashes are unguessable without the server-side salt, so a stray JWT does not yield useful access. Third-party libraries (phpcent, centrifuge-js, composer) are declared in thirdpartylibs.xml.

AI Summary

Overview

tool_realtime is a framework plugin that exposes a channel-based real-time messaging API to other Moodle components. A PHP-side \tool_realtime\channel object is subscribed during page rendering; a SHA-256 hash of its properties (with a random site-wide salt) is embedded in the page and used by the browser to authenticate polling or WebSocket connections. Two backend subplugins are bundled:

  • realtimeplugin_phppoll — long-polls poll.php; events are inserted into a dedicated table and cleaned every 5 minutes.
  • realtimeplugin_centrifugo — connects the browser to an admin-configured Centrifugo server using a short-lived JWT issued by Moodle.

Client-to-server events flow through push.php (AJAX with require_sesskey) and are dispatched to the target component's {component}_realtime_event_received() callback in its lib.php.

Security posture

  • push.php enforces require_login() + require_sesskey(); the component name is sanitised with PARAM_COMPONENT and the payload is JSON-decoded before being handed to the callback. The README explicitly warns callbacks to treat the payload as tainted.
  • poll.php validates requested channel hashes against $SESSION->realtimephppollchannels before returning any events — users cannot poll channels they were not subscribed to on the server.
  • SQL is parameterised (get_in_or_equal, named placeholders).
  • The channel salt is generated with random_bytes(32) on first use and stored in plugin config.
  • The tool_realtime_send_test_events external is gated by moodle/site:config; burst counts and delays are bounded.
  • Admin-facing pages use admin_externalpage_setup() and html_writer; no unescaped output was observed.
  • The Privacy API is implemented via null_provider in all three plugins. The phppoll event store is ephemeral (cleaned every 5 minutes) and contains no direct user identifiers, so null_provider is defensible.

Findings

  • Lowrealtimeplugin_phppoll\plugin::get_all() has a catch (\moodle_exception) block that dereferences $context->id when $context was never assigned (because context::instance_by_id() threw). This is a logic bug, not a security issue.
  • Low — The realtimeplugin_centrifugo_get_token external does not check isloggedin() or honour the tool_realtime/allowguests setting. Impact is limited because the issued JWT is scoped to $USER->id with an empty channel list, and subscribing still requires an unguessable server-side hash.
  • Info — Dead code: \tool_realtime\channel::create_from_properties() is unreferenced.

Third-party libraries

All three are declared in plugin/centrifugo/thirdpartylibs.xml: phpcent 6.0.1 (PHP), centrifuge-js 5.5.3 (JS), and the composer autoloader. phpcent uses raw curl_* calls internally rather than Moodle's curl wrapper — acceptable since the library itself is vendored third-party code and the target host is admin-configured.

Findings

code qualityLow
Undefined `$context` in catch block of `realtimeplugin_phppoll\plugin::get_all()`

The array_walk callback in realtimeplugin_phppoll\plugin::get_all() resolves each event's contextid to a full context via \context::instance_by_id(). If that call throws (for example dml_missing_record_exception, which extends moodle_exception), the catch (\moodle_exception $e) branch runs and builds a fallback context array using $context->id — but $context was never assigned because the call threw before the assignment.

Under PHP 8+ this produces an Undefined variable $context warning followed by a TypeError (Attempt to read property "id" on null), which will bubble out of get_all() and break the poll response for the affected user. The scenario is realistic: if a subscribed context (course module, activity) is deleted between a notify() and the next poll, the stored event still carries the old contextid.

This is a code-quality defect rather than a security issue, but it can cause user-visible errors and is trivial to fix.

Risk Assessment

Low risk. The code path is a graceful-degradation branch that is rarely exercised (only triggered when a context is deleted between notify() and polling). When triggered it produces a PHP error rather than a security issue — there is no information disclosure, privilege escalation, or data modification. Fix priority is low but the change is one-line.

Context

get_all() is called from poll.php for every long-poll cycle. It resolves stored event rows to a compact shape before returning them as JSON. The catch block was presumably intended to handle the case where a context was deleted after the event was stored, but the fallback references a variable that only exists in the success path.

Identified Code
array_walk($events, function (&$item) {
    $item->payload = @json_decode($item->payload, true);
    try {
        $context = \context::instance_by_id($item->contextid);
        $item->context = ['id' => $context->id, 'contextlevel' => $context->contextlevel,
            'instanceid' => $context->instanceid];
    } catch (\moodle_exception $e) {
        $item->context = ['id' => $context->id];
    }
    unset($item->contextid);
});
Suggested Fix

Use the original contextid from the record (which is still in scope as $item->contextid) and drop the property reads on the undefined variable:

array_walk($events, function (&$item) {
    $item->payload = json_decode($item->payload ?? '', true);
    try {
        $context = \context::instance_by_id($item->contextid);
        $item->context = [
            'id' => $context->id,
            'contextlevel' => $context->contextlevel,
            'instanceid' => $context->instanceid,
        ];
    } catch (\moodle_exception $e) {
        $item->context = ['id' => $item->contextid];
    }
    unset($item->contextid);
});

Also consider dropping the @ operator on json_decode() — it does not raise warnings in normal use, so the suppression is unnecessary.

securityLow
`realtimeplugin_centrifugo_get_token` does not enforce `allowguests`
Exploitable by:
guest

The get_token external function calls self::validate_context(\context_system::instance()) and then returns a Centrifugo JWT minted for $USER->id. It does not mirror the guest/loggedin check that the plugin's own init() method performs:

  • \realtimeplugin_centrifugo\plugin::init() short-circuits when !isloggedin() || (isguestuser() && !$this->allow_guests()).
  • get_token::execute() has no equivalent guard.

A guest user (or a site where tool_realtime/allowguests is off) can therefore still obtain a JWT by calling the web service directly through core/ajax. The JWT is scoped to the guest's own $USER->id and carries an empty channel list, so it cannot be used to auto-subscribe to any channel — but it does allow connecting to the configured Centrifugo server, which is a check the admin opted out of via the allowguests setting.

Risk Assessment

Low risk. The misconfiguration lets a guest obtain a JWT that allows a bare connection to Centrifugo, but not subscription to any meaningful channel (channel hashes are unguessable SHA-256 digests salted with a server-side random 32-byte value). The blast radius is limited to the administrator's intent for the allowguests setting being silently bypassed for this one web-service call. Recommend tightening the check for defence in depth.

Context

The token is consumed by the centrifuge-js client to authenticate its WebSocket connection. Without a valid channel hash (which is derived on the server from a random salt), a connected client cannot subscribe to anything useful — server-side subscribe() is the only path by which hashes reach the browser, and that path does enforce the guest check.

Proof of Concept

As a guest user, while tool_realtime/allowguests is disabled, call the external via core/ajax:

Ajax.call([{methodname: 'realtimeplugin_centrifugo_get_token', args: {}}])[0]
  .then(r => console.log(r.token));

The server returns a valid HS256-signed JWT with sub = <guest user id> even though allowguests is off.

Identified Code
public static function execute() {
    $context = \context_system::instance();
    self::validate_context($context);

    $plugin = \tool_realtime\manager::get_plugin();
    if ($plugin && $plugin instanceof \realtimeplugin_centrifugo\plugin && $plugin->is_set_up()) {
        return ['token' => $plugin->get_token()];
    } else {
        throw new \moodle_exception('Centrifugo plugin is not enabled');
    }
}
Suggested Fix

Mirror the guard in plugin::init() before returning a token:

public static function execute() {
    $context = \context_system::instance();
    self::validate_context($context);

    $plugin = \tool_realtime\manager::get_plugin();
    if (!$plugin || !($plugin instanceof \realtimeplugin_centrifugo\plugin) || !$plugin->is_set_up()) {
        throw new \moodle_exception('Centrifugo plugin is not enabled');
    }
    if (isguestuser() && !$plugin->allow_guests()) {
        throw new \moodle_exception('noguest');
    }
    return ['token' => $plugin->get_token()];
}
code qualityInfo
Dead code: `\tool_realtime\channel::create_from_properties()`

\tool_realtime\channel::create_from_properties() is a public static factory that reconstructs a channel from an associative array. It is not referenced anywhere in the plugin or in the bundled subplugins. Dead APIs tend to drift out of sync with the real constructor, so either document it as a public extension point or drop it.

Risk Assessment

Informational. No runtime impact; purely a maintenance observation.

Context

channel::get_properties() exposes the channel's shape as an array; create_from_properties() is the inverse. Neither the core plugin, the phppoll backend, nor the centrifugo backend round-trips a channel through this pair.

Identified Code
public static function create_from_properties(array $properties) {
    $context = context::instance_by_id(clean_param($properties['contextid'], PARAM_INT));
    return new self(
        $context,
        $properties['component'],
        $properties['area'],
        clean_param($properties['itemid'], PARAM_INT),
        $properties['channeldetails'] ?? '',
    );
}
Suggested Fix

Remove the method if it is genuinely unused, or add a PHPDoc note indicating it is part of the public API for consumers. If retained, also clean_param the component and area values (PARAM_COMPONENT / PARAM_AREA) so the factory is safe to call with externally-supplied arrays.

code qualityInfo
Unnecessary `@` error suppression on `json_decode()`

realtimeplugin_phppoll\plugin::get_all() uses @json_decode($item->payload, true). json_decode() does not emit warnings on invalid input — it returns null and sets json_last_error(). The @ is noise and would also suppress any unrelated notice raised during the call, which is generally discouraged by Moodle's coding style.

Risk Assessment

Informational. Minor code-style item; no functional or security impact.

Context

Runs for each event returned to a polling subscriber. The payload is a JSON-encoded array written by notify(), so decoding should normally succeed.

Identified Code
$item->payload = @json_decode($item->payload, true);
Suggested Fix

Drop the @:

$item->payload = json_decode($item->payload ?? '', true);
Third-Party Libraries (3)
LibraryVersionLicenseDeclared
phpcent
PHP client for the Centrifugo HTTP API — used by `realtimeplugin_centrifugo` to publish events (`publish`) and to mint JWT connection tokens for end users (`generateConnectionToken`). Bundled under `plugin/centrifugo/vendor/centrifugal/phpcent`.
6.0.1MIT
Composer
Composer autoloader (`vendor/autoload.php`, `vendor/composer/*`) — loaded at the top of `realtimeplugin_centrifugo\plugin` to expose the phpcent classes.
Expat
centrifuge-js
JavaScript SDK for Centrifugo — bundled at `plugin/centrifugo/amd/src/centrifuge-lazy.js` and loaded by the `realtimeplugin_centrifugo/realtime` AMD module to open the WebSocket and manage subscriptions/token refresh.
5.5.3MIT
Additional AI Notes

Channel authorisation model is sound. Hashes are substr(hash('sha256', json_encode(props + siteurl + salt)), 0, 32) — 128 bits of entropy, the salt is a one-time random_bytes(32) stored in plugin config, and phppoll/poll.php gates every polled hash against $SESSION->realtimephppollchannels. Centrifugo relies on the same unguessability property (since the issued JWT carries an empty channel allowlist, subscription depends entirely on the client knowing a hash the server produced).

push.php correctly delegates payload validation to the consumer plugin. The manager sanitises only the component name (PARAM_COMPONENT) and passes the raw decoded payload to {component}_realtime_event_received(). The README explicitly documents this contract. The in-tree test callback (tool_realtime_realtime_event_received in lib.php) is gated by moodle/site:config, so its echo behaviour is only reachable by admins.

poll.php handles long-polling cleanly. It closes the session lock with \core\session\manager::write_close() before entering the wait loop and re-validates the session via get_session_by_sid() each iteration, so a logged-out user's outstanding poll will terminate rather than continue returning data.

phpcent uses raw curl_* calls rather than Moodle's curl wrapper. Strictly this bypasses $CFG->curlsecurityblockedhosts and site proxy settings, but the target URL is admin-configured (the Centrifugo host) rather than user-supplied, and the library is declared in thirdpartylibs.xml. No finding raised — noted for awareness if the plugin is ever refactored to send requests to user-controlled hosts.

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