MDL Shield

Real time events

tool_realtime

Print Report
Plugin Information

An admin tool that provides a framework for real-time server-to-browser communication for other Moodle plugins. It exposes a channel API (subscribe / notify / send-to-server) with pluggable backends delivered as realtimeplugin subplugins: phppoll (long/short polling using a Moodle DB buffer table) and centrifugo (WebSockets via an external Centrifugo server, authenticated with JWT connection tokens).

Version:2026100200
Release:2.1.2
Reviewed for:5.3
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-10-02
56 files·8,704 lines
Grade Justification

This is a carefully engineered plugin with a sound security model and strong supporting tests, so no medium, high, or critical issues were found.

Access control is consistently correct. The state-changing client-to-server endpoint (push.php) enforces both require_login() and require_sesskey(), and dispatches only to genuine installed *_realtime_event_received callbacks via component_callback(); the single in-tree callback requires moodle/site:config. Both external functions call external_api::validate_context() (which invokes require_login() internally, verified in core) and send_test_events additionally enforces moodle/site:config. The phppoll long-poll endpoint deliberately skips require_login() to avoid touching session state but re-implements the equivalent checks (isloggedin(), guest handling, live-session validation).

The channel authorization model is robust. Channel names are a 128-bit sha256 hash salted with a secret, per-site random value, making them unguessable; phppoll additionally validates every requested hash against the subscriber's own session. All database access uses parameterised queries (get_in_or_equal, insert_record, delete_records_select), DDL is confined to db/upgrade.php, and AJAX responses are served as application/json, preventing reflected XSS.

The remaining findings are all low severity: the Centrifugo backend talks to its server with a bundled raw-curl client (admin-configured URL, TLS verification on by default) which bypasses Moodle's egress controls; an admin-only Use SSL toggle permits cleartext transport but defaults to secure and carries an explicit in-product warning; the transient phppoll buffer is declared with a null_provider despite potentially holding personal data in event payloads; and a few user-facing strings are not localised. None of these are exploitable by a low-privilege or unauthenticated user, and the volume of issues is small against otherwise clean, well-tested code.

AI Summary

tool_realtime is a real-time communication framework for Moodle plugins with two backends (phppoll and centrifugo). The codebase is mature and security-aware, and the review found no medium, high, or critical issues.

Strengths observed:

  • Authentication and CSRF: push.php enforces require_login() + require_sesskey(); external functions rely on validate_context() (which enforces login) plus explicit capability checks where appropriate.
  • Authorization: channels are identified by a secret-salted, unguessable 128-bit hash; phppoll also validates requested channels against the user's session.
  • Injection safety: all DB queries are parameterised, schema changes live only in db/upgrade.php, and AJAX output is JSON content-typed.
  • Transport: the Centrifugo client keeps TLS peer/host verification enabled by default; the Use SSL setting defaults to on and warns clearly when disabled.
  • Hygiene: correct (including nuanced) MOODLE_INTERNAL guard usage, declared third-party libraries, and solid PHPUnit + Behat coverage.

Low-severity findings:

  1. The Centrifugo backend uses a bundled raw-curl client that bypasses Moodle's proxy settings and curl_security_helper egress controls (admin-configured URL, TLS intact — not a live exploit).
  2. The admin-only Use SSL toggle can send the API key and user JWTs in cleartext when disabled (secure default + explicit warning mitigate this).
  3. The transient phppoll buffer table is declared as storing no personal data (null_provider) even though event payloads may contain some.
  4. A handful of user-facing strings are hardcoded in English rather than using the string API.

Findings

code qualityLow
Centrifugo server requests use a raw curl client that bypasses Moodle's HTTP egress controls

The Centrifugo backend performs its server-to-server API calls (publishing events, and the connection check in is_set_up()/notify()) through the bundled phpcent\Client, which issues the request with a bare curl_init() rather than through Moodle's HTTP layer.

Routing outbound requests through a raw client means the call does not pass through:

  • the site's proxy configuration ($CFG->proxyhost etc.), and
  • core's curl_security_helper (lib/classes/files/curl_security_helper.php), which enforces the site's blocked-hosts and allowed-ports lists.

In this plugin the Centrifugo host is taken from an admin-only setting, so there is no SSRF exposure — the destination cannot be influenced by users below site administrator. The library also keeps TLS peer and host verification enabled by default ($safety = true), and the plugin never disables it, so transport security is intact. The defect is therefore limited to the lost proxy support, the bypassed egress controls, and the coding-guideline preference for the sanctioned HTTP wrappers.

Risk Assessment

Low risk. Because the destination host is restricted to a site-administrator setting, there is no server-side request forgery vector for lower-privileged users, and TLS verification remains enabled by default. The only practical consequences are that the request ignores the site's proxy configuration and is not subject to core's blocked-host/allowed-port checks. This is a code-quality / defence-in-depth gap rather than an exploitable vulnerability.

Context

get_client() is called by notify() (publishing an event to Centrifugo) and indirectly whenever the server contacts the Centrifugo HTTP API. The target URL is built in get_api_url() from the admin-only host setting, so it is not attacker-controllable. The underlying transport is phpcent\Client::request(), which calls curl_init() directly and sets CURLOPT_SSL_VERIFYPEER = true / CURLOPT_SSL_VERIFYHOST = 2 by default.

Identified Code
    protected function get_client(): \phpcent\Client {
        require_once(__DIR__ . '/../vendor/centrifugal/phpcent/src/Client.php');
        return (new \phpcent\Client($this->get_api_url()))
            ->setConnectTimeoutOption(self::API_CONNECT_TIMEOUT)
            ->setTimeoutOption(self::API_TIMEOUT);
    }
Suggested Fix

Optional hardening. Consider performing the Centrifugo API request through one of Moodle's sanctioned HTTP entry points (the \curl class or \core\http_client) so the request honours the site proxy and curl_security_helper egress controls. The phpcent library builds the request body/headers itself, so this would mean replacing its request() transport (e.g. porting the small POST-to-/api call onto \curl).

If retaining phpcent as-is, no behavioural change is required for correctness — the endpoint is admin-configured and TLS stays on. At minimum, document that the egress controls are intentionally bypassed for this trusted endpoint.

securityLow
Optional 'Use SSL = No' setting permits cleartext transmission of the API key and user JWT tokens

The Centrifugo backend exposes a usessl setting. When it is set to No, the server-to-Centrifugo API URL becomes http:// and the browser WebSocket URL becomes ws://. In that mode:

  • the HTTP API key is sent to Centrifugo in cleartext (as the X-API-Key header), and
  • each user's JWT connection token is sent from the browser to Centrifugo over an unencrypted WebSocket, along with event payloads.

A network attacker positioned between the Moodle server (or a user's browser) and the Centrifugo server could capture the API key — which grants publish rights to every channel — and user connection tokens.

This is strongly mitigated: the setting defaults to SSL enabled (1), and its description contains an explicit warning ("without SSL the HTTP API key and the users' connection tokens are sent unencrypted. Only disable SSL if Centrifugo runs on the same server or in a trusted private network."). The insecure path is therefore an informed, admin-only opt-out intended for same-host / trusted-network deployments.

Risk Assessment

Low risk. The attacker is a network man-in-the-middle, not any Moodle role, and the vulnerable path requires an administrator to disable SSL contrary to the explicit warning shown next to the setting. The default configuration is secure. The option exists for a legitimate use case (Centrifugo co-located on the same host or a trusted private network), so this is best treated as a hardening consideration rather than a live vulnerability.

Context

get_api_url() and get_websocket_url() choose https/wss versus http/ws based solely on the usessl config value. The API key is attached by phpcent\Client::getHeaders(); the user JWT is generated in get_token() and delivered to the browser, which sends it to Centrifugo over the chosen WebSocket scheme. The exposure only exists when an administrator overrides the secure default.

Proof of Concept

With usessl set to No, an attacker on the network path captures the first page's WebSocket handshake to ws://<host>/connection/websocket (revealing the user's JWT) and/or the server's POST http://<host>/api request (revealing the X-API-Key header). The captured API key can then be used to publish arbitrary events to any channel.

Identified Code
    $settings->add(new admin_setting_configselect(
        'realtimeplugin_centrifugo/usessl',
        new lang_string('usessl', 'realtimeplugin_centrifugo'),
        new lang_string('usessl_desc', 'realtimeplugin_centrifugo'),
        1,
        [0 => get_string('no'), 1 => get_string('yes')]
    ));
Suggested Fix

Keep the secure default and the warning. For additional hardening, consider only permitting the non-SSL option when the configured host resolves to a loopback/private address, or surface an admin-notification / site-check warning when SSL is disabled for a non-local host. No change is strictly required given the secure default and the existing in-product warning.

Identified Code
    protected function get_api_url(): string {
        $host = get_config('realtimeplugin_centrifugo', 'host');
        if (empty($host)) {
            return '';
        }
        $protocol = get_config('realtimeplugin_centrifugo', 'usessl') ? 'https://' : 'http://';
        return $protocol . $host . '/api';
    }
Suggested Fix

No change required here; the protocol follows the usessl setting. See the setting-level suggestion above.

complianceLow
phppoll declares no personal data (null_provider) despite buffering event payloads in a Moodle table

The phppoll backend writes every real-time event into its own realtimeplugin_phppoll database table (hash, contextid, component, area, itemid, payload, timestamps) in notify(), keeps it until delivered or until the cleanup task removes it, then deletes it. Its privacy provider, however, implements \core_privacy\local\metadata\null_provider, asserting that the plugin stores no personal data.

The event payload is arbitrary data supplied by the calling component, and the sibling Centrifugo backend's own privacy metadata acknowledges that this payload "may include personal data". The itemid column is also frequently a user id in practice. Declaring null_provider is therefore inconsistent with what the table can contain.

This is mitigated by the data being transient (deleted within minutes by the scheduled cleanup task, which removes rows older than 5 minutes) and by the table having no explicit user-id column, so it is a disclosure-completeness / GDPR-documentation gap rather than a data-exposure bug.

Risk Assessment

Low risk. No personal data is exposed to unauthorised parties — the concern is purely that Moodle's Privacy API metadata understates what the plugin stores. Because the buffer is transient and not keyed by user id, the practical GDPR impact is minimal, but a reviewer or data-protection audit would expect the storage to be declared rather than asserted away with null_provider.

Context

notify() in plugin/phppoll/classes/plugin.php inserts the event (including payload and itemid) via $DB->insert_record(). cleanup_task deletes rows older than 5 minutes every 5 minutes. The language string for the provider already explains the transient storage in prose, but the machine-readable declaration is null_provider.

Identified Code
class provider implements \core_privacy\local\metadata\null_provider {
Suggested Fix

Implement \core_privacy\local\metadata\provider (and the relevant request providers, or at minimum the metadata provider) to declare the realtimeplugin_phppoll table as a short-lived buffer whose payload / itemid may contain personal data, mirroring the approach already taken by the Centrifugo backend's provider. For example, add the table via $collection->add_database_table(...) with string descriptions explaining the transient nature of the storage.

code qualityLow
Some user-facing strings are hardcoded in English instead of using the string API

A number of user-visible strings are emitted directly in English rather than being fetched from a language file via get_string() (PHP) or core/str (JS):

  • poll.php returns {"error": "Invalid channel"} as a literal string in the JSON response consumed by any authenticated user's browser.
  • amd/src/test_settings.js sets numerous status and error strings directly (e.g. 'Sending...', 'Calibrating...', 'Receiving...', 'Complete', 'Connection lost', 'Timeout: ...', and several diagnostic error messages).

Moodle's coding style requires user-facing text to be translatable through the string API; hardcoded English prevents localisation. Most of the JS strings appear only on the admin-only test-settings diagnostics page, which lowers their importance, but poll.php's error is reachable on any page that uses the polling backend.

Risk Assessment

Low risk. This is purely a code-quality / internationalisation issue with no security impact. The main user-reachable instance is a rare JSON error on the polling endpoint; the remainder are confined to an administrator-only diagnostics page.

Context

The Mustache template templates/test_settings.mustache correctly uses {{#str}} for its static labels, but the dynamic status/error text produced by the JavaScript bypasses the string API. The invalid_parameter_exception messages in send_test_events.php are developer-facing (comparable to coding_exception) and are acceptable as-is.

Identified Code
        echo json_encode(['error' => 'Invalid channel']);
Suggested Fix

Return a translatable string, e.g. define a invalidchannel string in lang/en/realtimeplugin_phppoll.php and emit get_string('invalidchannel', 'realtimeplugin_phppoll').

Identified Code
    updateStat('receive-status', 'Sending...');
Suggested Fix

Load the status/error strings through core/str (getString/getStrings) and reference language-file entries, as the Mustache template already does with {{#str}}. This applies to the other hardcoded status and error strings throughout this module as well.

Third-Party Libraries (2)
LibraryVersionLicenseDeclared
phpcent
PHP client for the Centrifugo HTTP API — used to publish real-time events to the Centrifugo server and to generate signed JWT connection tokens. Included directly via require_once (its composer autoloader is intentionally omitted per MDL-89898).
6.0.1MIT✓
centrifuge-js
JavaScript SDK for the Centrifugo real-time messaging server — used in the browser to open the WebSocket connection, refresh tokens, and subscribe to channels. Bundled as amd/src/centrifuge-lazy.js.
5.5.3MIT✓
Additional AI Notes

Channel authorization model. Security rests on the channel hash being unguessable: it is the first 32 hex characters of a SHA-256 over the channel properties plus the site URL and a per-site secret salt generated with random_bytes(32). For phppoll there is defence-in-depth — poll.php additionally checks each requested hash against $SESSION->realtimephppollchannels, which is populated only when the server renders a page that calls subscribe(). For centrifugo there is no server-side session check; subscription authorization depends on the external Centrifugo server's namespace configuration (allow_subscribe_for_client) combined with the secrecy of the hash. The shipped Railway/JSON templates set allow_publish_for_client=false (clients cannot inject events) and allow client subscription, which matches the intended design. Administrators deploying a custom Centrifugo configuration should preserve these settings.

Long-polling resource usage. The phppoll backend holds a PHP worker for the duration of each long-poll request (default 30s), issuing a DB query every poll interval. This is inherent to PHP long polling and is bounded by the admin-configurable requesttimeout (set to 0 for short polling) and a client-side rate limiter. On sites with many concurrent real-time pages this can pressure the PHP worker pool; the documentation already positions Centrifugo as the scalable backend. No code change is needed, but worker-pool sizing should be considered when enabling phppoll at scale.

Positive observations. The plugin demonstrates good security hygiene overall: correct and nuanced MOODLE_INTERNAL guard placement (present only where there are file-scope side effects, e.g. setting_manageplugins.php), parameterised database access throughout, schema changes confined to db/upgrade.php, third-party libraries declared in thirdpartylibs.xml with no duplication of core libraries, and meaningful PHPUnit and Behat coverage including guest-access and not-configured edge cases.

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