MDL Shield

Logstore xAPI

logstore_xapi

Print Report
Plugin Information

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.

Version:2026072001
Release:5.1.0
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-07-21
449 files·34,858 lines
Grade Justification

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.

AI Summary

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 controlreport.php calls require_login() then require_capability('logstore/xapi:viewerrorlog' | ':managehistoric'); both AJAX endpoints add require_sesskey() and a capability check (manageerrors/managehistoric or moodle/site:config). Capabilities are declared with RISK_CONFIG and granted only to manager.
  • 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 use s(), 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 sesskey is 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:

  1. 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.
  2. 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

code qualityLow
LRS statements delivered via a raw cURL handle instead of a Moodle HTTP client

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->proxyhost etc.) — outbound statements will not honour a configured forward proxy, which can break delivery on locked-down networks.
  • Core's outbound egress controls — the \curl wrapper routes requests through curl_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.

Risk Assessment

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.

Context

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.

Identified Code
$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);
Suggested Fix

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.

code qualityLow
Admin report/notification helpers load unbounded result sets into memory

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() selects u.* (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 distinct contextid referenced by logstore_standard_log into an array and then instantiates a context object 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.

Risk Assessment

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.

Context

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).

Identified Code
$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);
Suggested Fix

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();
Identified Code
$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();
}
Suggested Fix

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();
Additional AI Notes

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.

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