Logstore xAPI
logstore_xapi
A tool_log logstore subplugin that transforms Moodle log events into xAPI statements and delivers them to an admin-configured Learning Record Store (LRS). It provides background/batched processing via scheduled tasks, retry-on-failure with progressively smaller batches, an admin error log and historic-event report with per-event and bulk resending, failure-notification emails to selected cohorts, and a full Privacy (GDPR) provider.
The plugin is well engineered and demonstrably security-conscious. Every web entry point (report.php and both AJAX endpoints) enforces require_login(), an appropriate require_capability() check against the system context, and — for state-changing AJAX actions — require_sesskey(). All database access uses parameterised DML ($DB placeholders, get_in_or_equal, sql_like_escape); no raw SQL string interpolation of user input was found anywhere, including the report filter that reconstructs state from a user-supplied onpage JSON blob. Output that originates from untrusted sources (the LRS response body, usernames) is escaped with s() at the point of rendering, the client-side widgets escape injected text, and the email template relies on Mustache auto-escaping. Schema changes live exclusively in db/upgrade.php via the XMLDB manager, the Privacy API is fully and correctly implemented across both event tables, PHP object injection is mitigated (unserialize with allowed_classes restricted to stdClass), and the Moodle session key is one-way hashed before it ever leaves the site. The only issues are code-quality observations of low severity: statement delivery uses a raw curl_init() handle rather than a Moodle HTTP wrapper (bypassing proxy configuration and core egress controls, though TLS verification is explicitly enabled and the destination URL is admin-only), and two admin-only helper functions load unbounded result sets into memory. No finding is exploitable by a low-privileged or unauthenticated user, and there are no injection, XSS, auth-bypass, or data-exposure vulnerabilities.
Overview
logstore_xapi is a mature, actively-maintained logstore subplugin that converts Moodle log events into xAPI statements and pushes them to an external Learning Record Store. The reviewed tree is large (255 non-test PHP files, plus a 184-file test suite) but the security-relevant surface is small and well-contained: three web entry points, four scheduled tasks, a handful of admin settings, and a large body of pure data-mapping transformer functions.
Security posture
The plugin shows consistent, deliberate attention to security:
- Access control —
report.phpcallsrequire_login()thenrequire_capability('logstore/xapi:viewerrorlog' | ':managehistoric'); both AJAX endpoints addrequire_sesskey()and a capability check (manageerrors/managehistoricormoodle/site:config). Capabilities are declared withRISK_CONFIGand granted only tomanager. - SQL — every query is parameterised. Where a column name must be interpolated (it cannot be bound), it is validated against a fixed allow-list first.
- Output — the untrusted LRS response is double-escaped (JSON flags plus
s()), usernames uses(), JS widgets escape text before DOM insertion, and the notification email uses Mustache auto-escaping. - Transport — TLS peer/host verification is explicitly enabled for LRS requests and exposed as an admin setting backed by a site security check; the Moodle
sesskeyis hashed to a UUID before transmission. - Data lifecycle — DDL is confined to
db/upgrade.php; the Privacy API is fully implemented (export, delete, userlist) using recordsets.
Findings
Two low-severity, code-quality issues were identified:
- LRS statements are sent through a raw
curl_init()handle instead of Moodle's\curl/\core\http_client, bypassing site proxy settings and core outbound egress controls. TLS is handled correctly and the endpoint is admin-only, so this is a framework-compliance and hardening gap rather than an exploitable flaw. - Two admin-only helpers (
logstore_xapi_get_cohort_members,logstore_xapi_get_logstore_standard_context_options) load full/unbounded result sets into memory instead of streaming with a recordset.
No critical, high, or medium issues were found. No exploitable vulnerability is reachable by students, guests, or unauthenticated visitors.
Findings
The loader that sends xAPI statements to the Learning Record Store builds and executes the request with a raw curl_init() / curl_setopt_array() / curl_exec() sequence rather than one of Moodle's sanctioned HTTP entry points (\curl or \core\http_client).
A raw cURL handle bypasses two things that the Moodle wrappers provide:
- The site proxy configuration (
$CFG->proxyhostetc.) — outbound statements will not honour a configured forward proxy, which can break delivery on locked-down networks. - Core's outbound egress controls — the
\curlwrapper routes requests throughcurl_security_helper, which enforces the site's blocked-hosts and allowed-ports lists and restricts the protocol to HTTP/HTTPS. The raw handle skips these entirely.
Transport security itself is not weakened here: the code explicitly sets CURLOPT_SSL_VERIFYPEER => true and CURLOPT_SSL_VERIFYHOST => 2, applies an optional private CA bundle, and only relaxes verification when an administrator deliberately disables it. This is in fact stricter than the \curl wrapper's own default, whose curl::resetopt() initialises CURLOPT_SSL_VERIFYPEER to 0.
A secondary hardening gap: the endpoint setting is PARAM_URL, which permits an http:// URL. If an administrator configures a plaintext endpoint, the Basic-auth credentials and the personal data inside the statements would traverse the network in the clear. The default is https:// and there is no forced downgrade, so this is a configuration caveat rather than plugin-introduced cleartext.
Low risk. No user below site administrator can influence the destination URL, so this is not a server-side request forgery vector — the calibration that would push it higher (attacker-influenced URL, or plugin-forced cleartext) does not apply. TLS verification is on by default and configurable, so responses cannot be trivially spoofed by a network attacker. The concrete defects are operational and compliance-oriented: outbound statements ignore the site proxy and skip core's blocked-host/allowed-port enforcement, and an administrator who sets an http:// endpoint would expose credentials and learner PII on the wire. Blast radius is limited to the site's own outbound traffic to an administrator-chosen host.
This code runs inside the emit_task, failed_task and historical_task scheduled tasks (and synchronously when background mode is disabled). The destination URL comes from the logstore_xapi/endpoint admin setting, which is only writable by a user with moodle/site:config. build_curl_options() sets the TLS options and the request carries a Base64 Basic-auth header derived from the admin-configured LRS username/password. Responses (including error bodies) are captured and later shown, escaped, on the admin error report.
$request = curl_init();
curl_setopt_array($request, build_curl_options($config, $url, $auth, $postdata));
$responsetext = curl_exec($request);
$responsecode = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
Use a Moodle HTTP wrapper so the request inherits proxy settings and egress controls, while keeping the explicit TLS options the plugin already sets:
$curl = new \curl();
$curl->setopt([
'CURLOPT_SSL_VERIFYPEER' => !empty($config['lrs_ssl_verification']),
'CURLOPT_SSL_VERIFYHOST' => !empty($config['lrs_ssl_verification']) ? 2 : 0,
'CURLOPT_RETURNTRANSFER' => true,
]);
if (!empty($config['lrs_ssl_cabundle'])) {
$curl->setopt(['CURLOPT_CAINFO' => $config['lrs_ssl_cabundle']]);
}
$curl->setHeader([
'Authorization: Basic ' . $auth,
'X-Experience-API-Version: 1.0.3',
'Content-Type: application/json',
]);
$responsetext = $curl->post($url, $postdata);
$responsecode = $curl->get_info()['http_code'];
Do not change client purely for its own sake — the point is to regain proxy support and the curl_security_helper checks; keep verification on. Additionally consider validating that the configured endpoint uses https:// (or warning when it does not) so credentials and learner data are never sent in cleartext.
Two helper functions read potentially large result sets fully into memory with get_records_sql() instead of streaming them with a recordset:
logstore_xapi_get_cohort_members()selectsu.*(every user column) for all members of every selected notification cohort and merges them into a single array, even though only the name and email are subsequently used.logstore_xapi_get_logstore_standard_context_options()reads every distinctcontextidreferenced bylogstore_standard_loginto an array and then instantiates acontextobject for each one in a loop to build the historic-report filter dropdown.
On a large site — a big notification cohort, or a log table spanning many thousands of contexts — these patterns consume proportional memory and can slow the page/task. Moodle's coding guidelines recommend get_recordset*() for large or looped queries, and selecting only the needed columns.
Low risk. Both code paths are reachable only by administrators/managers (a scheduled task and a capability-gated report), and in typical deployments notification cohorts are small and the distinct-context count is bounded. There is no security impact — the concern is memory/performance scalability on very large sites, i.e. technical debt rather than a vulnerability.
logstore_xapi_get_cohort_members() is called from logstore_xapi_get_users_for_notifications(), used by the sendfailednotifications_task cron job to determine who receives failed-event alert emails. logstore_xapi_get_logstore_standard_context_options() is called from report.php only when rendering the historic-events report (which requires the logstore/xapi:managehistoric capability).
$sql = "SELECT u.*
FROM {user} u, {cohort_members} cm
WHERE u.id = cm.userid AND cm.cohortid = ?
ORDER BY lastname ASC, firstname ASC";
$cohortmembers = $DB->get_records_sql($sql, [$cohort->id]);
$members = array_merge($members, $cohortmembers);
Stream the rows and select only the columns that are used:
$sql = "SELECT u.id, u.firstname, u.lastname, u.email
FROM {user} u
JOIN {cohort_members} cm ON cm.userid = u.id
WHERE cm.cohortid = ?
ORDER BY u.lastname ASC, u.firstname ASC";
$rs = $DB->get_recordset_sql($sql, [$cohort->id]);
foreach ($rs as $member) {
$members[$member->id] = $member;
}
$rs->close();
$contextids = array_keys($DB->get_records_sql($sql));
foreach ($contextids as $contextid) {
$context = context::instance_by_id($contextid);
$options[$context->id] = $context->get_context_name();
}
Iterate a recordset rather than materialising all distinct context ids at once, and use IGNORE_MISSING when instantiating so a stale contextid does not throw:
$rs = $DB->get_recordset_sql($sql);
foreach ($rs as $row) {
if ($context = context::instance_by_id($row->contextid, IGNORE_MISSING)) {
$options[$context->id] = $context->get_context_name();
}
}
$rs->close();
No bundled third-party runtime libraries. thirdpartylibs.xml is empty and no vendor/ or node_modules/ directory ships in the plugin. The src/transformer and src/loader trees are the plugin's own first-party code (authored by the plugin maintainers), and the amd/build/*.min.js files are standard grunt-compiled versions of the plugin's own amd/src modules. composer.json references yetanalytics/statementfactory only as a require-dev dependency, so it is not bundled at runtime. The empty declaration is therefore appropriate.
Strong, deliberate security practices were observed and are worth acknowledging. Examples include: an allow-list (XAPI_REPORT_FILTER_COLUMNS) guarding the one place a column name is interpolated into SQL; double-escaping of the untrusted LRS response before it reaches an html_writer table cell; hardened deserialisation of the event other field (unserialize(..., ['allowed_classes' => [\stdClass::class]])); one-way hashing of the Moodle sesskey into a UUID before it is placed in a statement; and a dedicated \core\check\check implementation that surfaces disabled TLS verification on the site security report.
The Privacy (GDPR) API is fully implemented. classes/privacy/provider.php implements the metadata, logstore_provider and logstore_userlist_provider interfaces across both logstore_xapi_log and logstore_xapi_failed_log, uses a column allow-list to strip non-event bookkeeping fields before handing records to core, and streams exports with get_recordset_select(). Export, per-user delete, whole-context delete and userlist delete are all present and parameterised.
Data-mapping transformer layer is low-risk. The ~200 event/util transformer functions read records exclusively through a thin repository wrapper over $DB->get_record(s)() (parameterised array conditions, table names that are hardcoded literals or Moodle-controlled event metadata such as $event->objecttable), perform no filesystem/network/shell operations, and emit no HTML — their output is JSON destined for the LRS. The event dispatch map (get_event_function_map) resolves to hardcoded function names, so there is no user-controlled dynamic dispatch.