Real time events
tool_realtime
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.
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.
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-pollspoll.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.phpenforcesrequire_login()+require_sesskey(); the component name is sanitised withPARAM_COMPONENTand the payload is JSON-decoded before being handed to the callback. The README explicitly warns callbacks to treat the payload as tainted.poll.phpvalidates requested channel hashes against$SESSION->realtimephppollchannelsbefore 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_eventsexternal is gated bymoodle/site:config; burst counts and delays are bounded. - Admin-facing pages use
admin_externalpage_setup()andhtml_writer; no unescaped output was observed. - The Privacy API is implemented via
null_providerin all three plugins. The phppoll event store is ephemeral (cleaned every 5 minutes) and contains no direct user identifiers, sonull_provideris defensible.
Findings
- Low —
realtimeplugin_phppoll\plugin::get_all()has acatch (\moodle_exception)block that dereferences$context->idwhen$contextwas never assigned (becausecontext::instance_by_id()threw). This is a logic bug, not a security issue. - Low — The
realtimeplugin_centrifugo_get_tokenexternal does not checkisloggedin()or honour thetool_realtime/allowguestssetting. Impact is limited because the issued JWT is scoped to$USER->idwith 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
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.
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.
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.
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);
});
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.
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.
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.
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.
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.
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');
}
}
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()];
}
\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.
Informational. No runtime impact; purely a maintenance observation.
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.
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'] ?? '',
);
}
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.
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.
Informational. Minor code-style item; no functional or security impact.
Runs for each event returned to a polling subscriber. The payload is a JSON-encoded array written by notify(), so decoding should normally succeed.
$item->payload = @json_decode($item->payload, true);
Drop the @:
$item->payload = json_decode($item->payload ?? '', true);
| Library | Version | License | Declared |
|---|---|---|---|
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.1 | MIT | ✓ |
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.3 | MIT | ✓ |
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.