MDL Shield

CAMPLA exam configuration

quizaccess_campla

Print Report
Plugin Information

A Moodle quiz access rule (quizaccess_campla) that bridges Moodle quizzes to the external CAMPLA (Cloud E-Assessment Management Platform) service. It adds a CAMPLA section to the quiz settings form with a "Generate CAMPLA configuration" button that, for privileged users, fetches a JWT token from and pushes exam metadata (quiz name, dates, owner, enrolled participants, Safe Exam Browser keys) to an admin-configured CAMPLA REST API. Communication uses admin-configured credentials (application id, secret) and Moodle's download_file_content() HTTP helper.

Version:2026072500
Release:v5.2-r2
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-08-23
17 files·2,102 lines
Grade Justification

The plugin has a solid security posture. Every sensitive operation is gated behind the module-level quizaccess/campla:canusecampla capability, which by default is granted only to managers and admins (course creators, editing teachers and teachers are explicitly prevented). The state-changing paths run through a core_form\dynamic_form and an ajax external function, so require_login, require_sesskey and require_capability are all enforced by the framework, and the capability is independently re-checked on the effective cmid inside campla_client::init(). All SQL is parameterised, the outbound HTTP calls use the sanctioned download_file_content() wrapper (which keeps TLS peer/host verification on by default), the CAMPLA endpoint URL is admin-configured (no below-admin SSRF surface), and the Privacy API is implemented correctly for a plugin that transfers personal data to an external location without storing it locally.

No critical, high or medium issues were found, and there is no path by which a student, teacher or unauthenticated user can harm another user. The findings are a set of low-severity issues: a defense-in-depth XSS sink where the external endpoint's raw HTTP response body is rendered as HTML in a toast (exploitable only by a malicious/compromised CAMPLA backend, against a manager), the CAMPLA secret being stored/displayed in a cleartext admin text field, no enforcement of HTTPS on the endpoint URL, plus several code-quality defects (include-time side effects in an autoloaded class file, a : array method that returns false, a getter that silently writes Safe Exam Browser settings, fragile host validation, and a broken JS default-parameter expression). The number and nature of these low findings — including a few in the security/hardening space, all well mitigated by access controls and the trusted-backend model — keep the plugin just below the top tier.

AI Summary

Overview

quizaccess_campla is a quiz access rule that integrates Moodle quizzes with the external CAMPLA e-assessment platform. When a privileged user edits a quiz, the plugin injects a CAMPLA section and a Generate CAMPLA configuration button. Opening the modal calls an AJAX web service that authenticates to CAMPLA (using an admin-configured application id + secret) and stores the returned JWT token; submitting the modal pushes exam metadata and the list of enrolled participants to the CAMPLA REST API.

Security posture

The plugin is well built from a security standpoint:

  • Access is gated behind the quizaccess/campla:canusecampla capability, which defaults to managers/admins only (teachers are explicitly CAP_PREVENT).
  • The write paths are a core_form\dynamic_form and an ajax external function, so login, sesskey and capability checks are enforced by the framework; the capability is also re-validated on the effective cmid in campla_client::init().
  • All database access uses $DB with parameterised queries.
  • Outbound HTTP uses the sanctioned download_file_content() helper, which keeps CURLOPT_SSL_VERIFYPEER/CURLOPT_SSL_VERIFYHOST enabled — TLS verification is intact.
  • The endpoint URL is admin-configured, so there is no SSRF surface reachable by lower-privilege users.
  • The Privacy API correctly declares an external-location transfer and stores no per-user data locally.

Findings

All findings are low severity. The most notable are:

  1. The external endpoint's raw HTTP response body is rendered as HTML in a toast ({{{message}}}), creating an XSS sink that a malicious/compromised CAMPLA backend could use against a manager.
  2. The CAMPLA secret is stored and shown in cleartext (admin_setting_configtext + PARAM_RAW) instead of a masked password field.
  3. The endpoint URL is not constrained to HTTPS, so credentials and participant PII could transit in cleartext if an admin configures http://.
  4. Code-quality defects: include-time side effects (require config.php / require_login()) in an autoloaded class file, a : array method returning false, a "getter" that silently writes Safe Exam Browser settings, fragile host validation, and a broken JS default-parameter expression.

None of these are exploitable by students, teachers, or unauthenticated visitors.

Findings

securityLow
External CAMPLA response body rendered as raw HTML in a toast (XSS sink)

When a submission to CAMPLA fails, campla_client::sendtocampla() builds an error message that embeds the raw HTTP response body returned by the CAMPLA endpoint ($response->results, via $response->error ?? $response->results). That string is concatenated into the message returned by process_dynamic_submission() and handed to the JavaScript, which passes it to core/toast's add().

The core toast template (core/local/toast/message.mustache) renders the message with a triple-mustache {{{message}}}, i.e. without HTML escaping. The plugin deliberately injects an HTML <br /> into the message, confirming it relies on raw-HTML rendering. As a result, any HTML/JavaScript present in the CAMPLA endpoint's response body is executed in the browser of the manager who triggered the action.

This is a defense-in-depth issue rather than a directly reachable vulnerability: the payload must come from the configured CAMPLA server's HTTP response, and TLS verification is on by default, so an attacker needs to control (or compromise) the configured endpoint, or occupy a network MITM position, to inject the payload. The blast radius is limited to users holding quizaccess/campla:canusecampla (managers/admins).

Risk Assessment

Low risk. The injection vector is the configured external endpoint's HTTP response, not a Moodle role: exploitation requires the CAMPLA server to be malicious/compromised, or an attacker on the network path (mitigated by the default TLS verification of download_file_content()). The victim must hold quizaccess/campla:canusecampla, i.e. a manager/admin. There is no path for a student, teacher, or unauthenticated user to inject the payload. It is nonetheless a genuine XSS sink — untrusted external content should never be rendered with {{{ }}} — and is cheap to fix by escaping the endpoint-controlled text.

Context

The data flow is: download_file_content() returns the CAMPLA server's response object → campla_client::sendtocampla() extracts $response->results/$response->error into the returned message → sendtocamplaform::process_dynamic_submission() concatenates it with <br /> → the dynamic form returns it to amd/src/modalforms.js, which calls addToast(response.message, {type})core/toast renders {{{message}}} (unescaped).

Proof of Concept

Point the plugin at a CAMPLA endpoint (or MITM the connection) that answers the POST /rest/lms/examination/ request with a non-2xx status and a response body such as:

<img src=x onerror="alert(document.cookie)">

A manager who clicks Send to CAMPLA then receives a toast whose body is that HTML, and the onerror handler runs in their session.

Identified Code
        if (!$response || ((int)$response->status !== 200 && (int)$response->status !== 201)) {
            $errormsg = $response->error ?? $response->results ?? get_string('unknownerror', 'quizaccess_campla');
            return [false, $response->status . ': ' . $errormsg];
        }
Suggested Fix

Escape the endpoint-controlled portion before it can reach the HTML toast. For example, wrap the server text in s() (or clean_text()), and keep only the trusted <br /> as markup:

$errormsg = $response->error ?? $response->results ?? get_string('unknownerror', 'quizaccess_campla');
return [false, s($response->status . ': ' . $errormsg)];
Identified Code
        return [
            'status' => 500,
            'message' => get_string('sendtocamplafail', 'quizaccess_campla') . '<br />' . $camplamessage,
        ];
Suggested Fix

Ensure $camplamessage is already escaped (see the campla_client fix). Alternatively, render the dynamic part as text and build the line break with escaped content so untrusted markup can never be interpreted by the {{{message}}} toast template.

securityLow
CAMPLA application secret stored and displayed in cleartext admin setting

The CAMPLA application secret is registered as a plain admin_setting_configtext with PARAM_RAW. This means the credential is shown in cleartext in the site administration form (as the manageplugin.feature Behat scenario asserts — the saved secret value is read back verbatim), and there is no masking on screen.

Moodle provides admin_setting_configpasswordunmask (and admin_setting_encryptedpassword) precisely for secrets: they mask the value in the UI and, in the encrypted variant, store it encrypted at rest. Using a plain text field for a shared secret is an avoidable information-exposure/hardening gap.

Risk Assessment

Low risk. Visibility of the cleartext secret is limited to site administrators (and anyone who can read a config export or shoulder-surf the settings page). It is not reachable by lower-privilege users. The concern is confidentiality hardening of a shared credential rather than a directly exploitable flaw; switching to a masked/encrypted setting removes the on-screen exposure with a one-line change.

Context

The secret, together with the application id and base URL, is used by sendtocamplaform::handle_jwttoken_request() to authenticate to CAMPLA and obtain a JWT token. It is read back via settings_provider::read_secret(). Only site administrators can view the admin settings page, so exposure is limited to that audience.

Identified Code
    $settings->add(
        new admin_setting_configtext(
            'quizaccess_campla/secret',
            get_string('camplasecret', 'quizaccess_campla'),
            get_string('camplasecret_desc', 'quizaccess_campla'),
            '',
            PARAM_RAW,
        )
    );
Suggested Fix

Use a masked password setting for the secret:

    $settings->add(
        new admin_setting_configpasswordunmask(
            'quizaccess_campla/secret',
            get_string('camplasecret', 'quizaccess_campla'),
            get_string('camplasecret_desc', 'quizaccess_campla'),
            ''
        )
    );

Consider the same treatment for the stored JWT token; the application id is less sensitive but could also be masked.

securityLow
CAMPLA endpoint URL is not constrained to HTTPS; credentials and PII may transit in cleartext

The CAMPLA base URL is stored as an admin_setting_configtext with PARAM_URL, which accepts both http:// and https://. The plugin never enforces or warns that the scheme must be HTTPS. All requests to CAMPLA carry sensitive material:

  • the application secret (in the token request body),
  • the Bearer JWT token (in the Authorization header of the examination request), and
  • participant personal data — email, first name, last name, full name of every enrolled user — plus the SEB quit password.

If an administrator configures an http:// endpoint, download_file_content() will happily use it and all of the above is transmitted in cleartext.

Risk Assessment

Low risk. This is a transport-hardening gap, not a live exploit. It requires two conditions that are outside the reach of ordinary users: an administrator configuring a plaintext http:// endpoint, and an attacker positioned on the network path between Moodle and CAMPLA. The default/documented endpoint is https://campla.ch, and with HTTPS the traffic is protected (TLS verification is on by default in download_file_content()). Enforcing HTTPS removes the footgun entirely.

Context

The URL flows into settings_provider::read_camplabasisurl() and is used by both campla_client::sendtocampla() (examination push, with the Bearer token and participant PII) and sendtocamplaform::handle_jwttoken_request() (secret exchange). download_file_content() only requires an http(s):// scheme; it does not force TLS.

Identified Code
    $settings->add(
        new admin_setting_configtext(
            'quizaccess_campla/basisurl',
            get_string('camplabasisurl', 'quizaccess_campla'),
            get_string('camplabasisurl_desc', 'quizaccess_campla'),
            '',
            PARAM_URL,
        )
    );
Suggested Fix

Reject non-HTTPS endpoints (except perhaps an explicit localhost testing case). Options:

  • Validate the scheme in a custom setting validate() method or in settings_provider::is_campla_config_valid() and refuse http://.
  • At minimum, document the HTTPS requirement and check parse_url($url, PHP_URL_SCHEME) === 'https' before sending the secret/token/PII, returning a clear configuration error otherwise.
code qualityLow
Include-time side effects in autoloaded form class (require config.php and require_login at file scope)

classes/form/sendtocamplaform.php is an autoloaded class file, yet at file scope it requires config.php, requires filelib.php, and calls require_login(). Moodle's coding standards require class files under classes/ to be free of side effects at include time — they must not bootstrap Moodle (config.php) and must not perform request-level actions such as require_login().

Because the class is autoloaded lazily, require_login() executes whenever the class is first referenced. In the current call paths this happens to be after authentication has already been established (the ajax external function and the dynamic-form handler both call require_login() themselves), so it is redundant. However, running require_login() at autoload time is fragile: if the class is ever autoloaded in a context that has not set up a page (web-service/class discovery, CLI, unit-test bootstrap, etc.), it can trigger unexpected redirects or exceptions.

A related coupling exists in campla_client.php: it calls download_file_content() but does not require filelib.php itself — it silently depends on the form file having loaded filelib.php at file scope. If campla_client::sendtocampla() were ever reached without the form file being loaded first, that call would fatal.

Risk Assessment

Low risk. No security impact and, given the current call graph, no functional break today. The issue is maintainability and robustness: side effects in an autoloaded class file violate Moodle standards and make behavior depend on load order (notably campla_client's implicit reliance on the form file to have loaded filelib.php). It should be corrected to avoid latent fatals if call paths change.

Context

The file also declares defined('MOODLE_INTERNAL') || die; at line 28, so direct web access dies before reaching the requires; the side effects therefore run only during in-process autoloading. The require_once(config.php) is effectively a no-op in that situation (config is already loaded), which underlines that it should not be there at all.

Identified Code
require_once(__DIR__ . '/../../../../../../config.php');
require_once($CFG->libdir . '/filelib.php');

require_login();
Suggested Fix

Remove the file-scope bootstrap and require_login() from the class file. The dynamic form and the external function already establish login and context. Load filelib.php where it is actually used — inside the methods that call download_file_content() (both in the form's handle_jwttoken_request() and in campla_client::sendtocampla()):

global $CFG;
require_once($CFG->libdir . '/filelib.php');
$response = download_file_content($url, $headers, $postdata, true);
code qualityLow
Getter get_campla_quizallowedbrowserexamkey() silently creates/overwrites Safe Exam Browser settings

settings_provider::get_campla_quizallowedbrowserexamkey() is named and documented as a read accessor ("Returns the quiz browser exam key"), but when no key exists it writes to another plugin's data: it fabricates a new key, then creates or mutates a quizaccess_seb\seb_quiz_settings record — setting requiresafeexambrowser to USE_SEB_CLIENT_CONFIG, showsebdownloadlink to 0, and the new allowedbrowserexamkeys — and calls save().

This is invoked during campla_client::sendtocampla(). So clicking Send to CAMPLA can silently reconfigure the quiz's Safe Exam Browser access rule (potentially enabling SEB enforcement on a quiz that previously had none), with no explicit user confirmation and no indication that a get performed a persistent write. The persistence uses the SEB persistent API (not raw DDL), and it is behind the manager capability and the dynamic form (sesskey enforced), so it is authorized — but the surprising side effect in a getter is a correctness/maintainability hazard.

Risk Assessment

Low risk. No privilege escalation — the caller already holds a manager-level capability over the module and could configure SEB directly. The concern is a hidden, potentially unexpected mutation of a sibling plugin's configuration performed by a method that presents itself as a read, which can lead to surprising behaviour and hard-to-trace bugs.

Context

The method first checks that the seb quizaccess plugin is installed before referencing its classes, so it will not fatal when SEB is absent. It is reached only from campla_client::sendtocampla(), which is gated behind the quizaccess/campla:canusecampla capability and runs inside the dynamic form submission.

Identified Code
        } else {
            // If no, generate settings and exam keys, and retrieve the key.
            $randombytes = random_bytes(32);
            $newallowedbrowserexamkey = hash('sha256', $randombytes);
            [$quiz, ] = get_module_from_cmid($cmid);
            $sebquizsettings = \quizaccess_seb\seb_quiz_settings::get_by_quiz_id($quiz->id);
            if (!$sebquizsettings) {
                $sebquizsettings = new \quizaccess_seb\seb_quiz_settings();
                $sebquizsettings->set('cmid', $cmid);
                $sebquizsettings->set('quizid', $quiz->id);
            }
            $sebquizsettings->set('requiresafeexambrowser', \quizaccess_seb\settings_provider::USE_SEB_CLIENT_CONFIG);
            $sebquizsettings->set('showsebdownloadlink', 0);
            $sebquizsettings->set('allowedbrowserexamkeys', $newallowedbrowserexamkey);
            $sebquizsettings->save();

            $allowedbrowserexamkey = $newallowedbrowserexamkey;
        }
Suggested Fix

Separate the read from the write. Give the write path an explicit, clearly named method (e.g. ensure_seb_browser_exam_key($cmid)) and call it deliberately from the submission flow, rather than as a side effect of a get_* accessor. Also confirm that overriding an existing quiz's SEB configuration on send is the intended behaviour and, if so, surface it to the user.

code qualityLow
sendtocampla() declares array return type but returns false

campla_client::sendtocampla() is declared with a : array return type, but its guard clause return false; returns a boolean. If that branch were reached, PHP would raise a TypeError. The caller, process_dynamic_submission(), immediately list-destructures the result ([$camplaresponse, $camplamessage] = ...), which also assumes an array.

In practice the guard is effectively dead code: process_dynamic_submission() is only reachable after check_access_for_dynamic_submission() passes the capability check, and campla_client::init() sets self::$caps['canusecampla'] from the same capability, so it will be truthy; $formdata from get_data() will also be non-empty. Nonetheless the type contract is violated and the guard cannot actually signal failure the way the caller expects.

Risk Assessment

Low risk. No security impact and, given the upstream capability checks, the offending branch is not reachable in normal operation. It is a latent type-contract bug that would surface as a fatal TypeError if the preconditions ever changed.

Context

The method's normal returns are [true, ''] on success and [false, '<status>: <error>'] on failure, both arrays — consistent with the caller. Only the early guard returns a bare false.

Identified Code
        if (!self::$caps['canusecampla'] || !$formdata) {
            return false;
        }
Suggested Fix

Return the same [bool, string] shape the caller destructures, e.g.:

        if (!self::$caps['canusecampla'] || !$formdata) {
            return [false, get_string('nopermissions', 'error', get_string('campla:canusecampla', 'quizaccess_campla'))];
        }
code qualityLow
Fragile host validation in handle_jwttoken_request() (undefined array key, redundant DNS lookup)

sendtocamplaform::handle_jwttoken_request() performs a hand-rolled host check before contacting CAMPLA. Two problems:

  • After computing $parts = parse_url($url) and setting $hostvalid = false when $parts is falsy or $parts['host'] is empty, the code unconditionally reads $host = $parts['host'];. If $parts is false or lacks a host, this raises an undefined array key / array offset on bool warning and yields null, which is then passed to dns_get_record(null, ...) (a deprecation/TypeError risk on modern PHP).
  • The dns_get_record() gate adds no security value here — the URL is admin-configured, so there is no SSRF to prevent — while making the flow brittle (transient DNS failures cause a spurious "No valid CAMPLA URL" error).

This method is reachable via the ajax external function by a manager even when the base URL is empty/invalid (the external function checks the capability but not is_campla_config_valid()), so the degenerate $url = '/rest/auth/application/' path is actually reachable.

Risk Assessment

Low risk. No security consequence — the URL is not attacker-controlled below admin. The impact is a PHP warning/notice and a confusing error message under misconfiguration or transient DNS issues. Reachable only by managers/admins.

Context

The base URL is admin-configured via PARAM_URL, so a stored value is either a valid URL or empty. When empty, is_campla_config_valid() hides the button in the quiz form, but the AJAX endpoint itself does not re-check configuration validity, so a manager can still invoke it and reach the fragile code with an empty URL.

Identified Code
        $parts = parse_url($url);

        $hostvalid = true;

        if (!$parts || empty($parts['host'])) {
            $hostvalid = false;
        }

        $host = $parts['host'];
        if ($host !== 'localhost') {
            $records = dns_get_record($host, DNS_A + DNS_AAAA);

            if ($records === false || empty($records)) {
                $hostvalid = false;
            } else {
                $hostvalid = true;
            }
        }
Suggested Fix

Guard the host access and drop the DNS lookup. For example:

$parts = parse_url($url);
if (empty($parts['host'])) {
    return ['status' => 500, 'message' => get_string('novalidcamplaurl', 'quizaccess_campla')];
}

Let download_file_content() (and core's curl_security_helper) handle reachability and blocked-host enforcement rather than pre-validating with dns_get_record().

code qualityLow
Broken default-parameter expression in modalForm() JavaScript

The modalForm export defines its args parameter with a default value that references args inside its own initializer: args = {...args, hidebuttons: args.hidebuttons ?? 1}. A parameter's default expression cannot read the parameter being initialized (it is in the temporal dead zone), so if modalForm were ever called with fewer than four arguments, this would throw a ReferenceError at call time. The compiled amd/build/modalforms.min.js reproduces the same broken pattern.

In practice this is currently unreachable: the only caller is rule.php, which always passes the fourth argument (['hidebuttons' => 1, 'cmid' => $cmid]) via js_call_amd, so the default never executes. It remains latent, dead-but-wrong code.

Risk Assessment

Low risk. No security impact and no current functional impact, since the sole caller always supplies args. It is a correctness defect that should be cleaned up to avoid confusion and future breakage.

Context

rule.php calls $PAGE->requires->js_call_amd('quizaccess_campla/modalforms', 'modalForm', [...4 elements...]), so args is always supplied and the redundant line-72 normalization runs harmlessly. The bug only manifests if the function is called with three or fewer arguments.

Identified Code
export const modalForm = (linkSelector, formClass, title, args = {...args, hidebuttons: args.hidebuttons ?? 1}) => {
    // Ensure default: hidebuttons = 1 unless explicitly disabled.
    args.hidebuttons = (args.hidebuttons ?? 1);
Suggested Fix

Default to an empty object and normalize inside the body:

export const modalForm = (linkSelector, formClass, title, args = {}) => {
    args.hidebuttons = args.hidebuttons ?? 1;

Remember to rebuild the AMD bundle (grunt) so amd/build/modalforms.min.js picks up the fix.

Additional AI Notes

Access control is a strength. The quizaccess/campla:canusecampla capability is CAP_ALLOW only for manager and is explicitly CAP_PREVENT for coursecreator, editingteacher and teacher. Combined with require_capability in the external function, check_access_for_dynamic_submission() in the form, and an independent re-check on the effective cmid in campla_client::init(), there is no path for students, teachers or unauthenticated users to reach the CAMPLA integration.

Outbound HTTP is correctly wrapped. Both requests use core's download_file_content(), which forces CURLOPT_SSL_VERIFYPEER=true and inherits CURLOPT_SSL_VERIFYHOST=2 from curl::resetopt(), and routes through curl_security_helper. TLS verification and blocked-host/port enforcement are therefore intact; the only residual transport concern is the unforced HTTPS scheme noted in the findings.

Participant selection sends suspended enrolments. campla_client::sendtocampla() calls get_enrolled_users(..., 0, 0, false) with $onlyactive = false, so users with suspended enrolments are included in the participant list transmitted to CAMPLA. The sibling helper get_campla_coursestudents() and the corresponding test use true. Consider passing true for consistency so that suspended users' personal data is not exported to the exam platform.

Minor performance pattern. In sendtocampla(), get_enrolled_users() is asked only for u.id,u.email, then the loop calls \core_user::get_user($userid->id) for every enrolled user to obtain first/last name — an N+1 query pattern. Requesting the needed fields directly (e.g. u.id,u.email,u.firstname,u.lastname) would avoid the per-user re-fetch.

Privacy API is implemented appropriately. The provider declares an external-location transfer of email/first name/last name and stores no per-user data locally, so the empty get_contexts_for_userid/export/delete implementations are correct. One wording nit: the privacy:metadata:quizaccess_campla:externalpurpose string says "No user data is explicitly sent to the CAMPLA server", which contradicts the transfer the plugin actually performs; consider rephrasing.

No third-party code is bundled, so the absence of thirdpartylibs.xml is correct. amd/build/modalforms.min.js is the plugin's own compiled AMD module (it matches amd/src/modalforms.js), not an external library.

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