Real time events
tool_realtime
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).
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.
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.phpenforcesrequire_login()+require_sesskey(); external functions rely onvalidate_context()(which enforces login) plus explicit capability checks where appropriate. - Authorization: channels are identified by a secret-salted, unguessable 128-bit hash;
phppollalso 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 SSLsetting defaults to on and warns clearly when disabled. - Hygiene: correct (including nuanced)
MOODLE_INTERNALguard usage, declared third-party libraries, and solid PHPUnit + Behat coverage.
Low-severity findings:
- The Centrifugo backend uses a bundled raw-curl client that bypasses Moodle's proxy settings and
curl_security_helperegress controls (admin-configured URL, TLS intact — not a live exploit). - The admin-only
Use SSLtoggle can send the API key and user JWTs in cleartext when disabled (secure default + explicit warning mitigate this). - The transient
phppollbuffer table is declared as storing no personal data (null_provider) even though event payloads may contain some. - A handful of user-facing strings are hardcoded in English rather than using the string API.
Findings
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->proxyhostetc.), 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.
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.
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.
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);
}
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.
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-Keyheader), 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.
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.
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.
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.
$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')]
));
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.
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';
}
No change required here; the protocol follows the usessl setting. See the setting-level suggestion above.
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.
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.
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.
class provider implements \core_privacy\local\metadata\null_provider {
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.
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.phpreturns{"error": "Invalid channel"}as a literal string in the JSON response consumed by any authenticated user's browser.amd/src/test_settings.jssets 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.
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.
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.
echo json_encode(['error' => 'Invalid channel']);
Return a translatable string, e.g. define a invalidchannel string in lang/en/realtimeplugin_phppoll.php and emit get_string('invalidchannel', 'realtimeplugin_phppoll').
updateStat('receive-status', 'Sending...');
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.
| Library | Version | License | Declared |
|---|---|---|---|
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.1 | MIT | ✓ |
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.3 | MIT | ✓ |
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.