MDL Shield

AI Hub

local_aihub

Print Report
Plugin Information

Bring-your-own-key AI text generation hub. Other plugins call it through a PHP facade (\local_aihub\ai), which resolves a personal or site API key for Gemini, Groq, DeepSeek or an OpenAI-compatible endpoint and calls the provider with failover. It also logs each attempt, gives users a self-service page for personal keys and provider order, and gives managers a site usage report with CSV/Excel export.

Version:2026102000
Release:v1.4.0
Reviewed for:5.3
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-11-02
65 files·9,704 lines
Grade Justification

No exploitable security vulnerabilities were found. Every entry point enforces the right controls:

  • mykeys.php requires login, checks the personal-key permission and the sesskey, and only ever writes the current user's own preferences.
  • providerorder.php uses admin_externalpage_setup() with moodle/site:config plus sesskey.
  • report.php requires local/aihub:viewusage (flagged RISK_PERSONAL).
  • All templates use escaped Mustache output, and all SQL is parameterised.

The main risk area, a teacher-supplied OpenAI-compatible endpoint URL, is defended in depth. The URL must be HTTPS and every resolved address must be public. Each address is also checked against core's curl_security_helper, the connection is pinned to the validated addresses with CURLOPT_RESOLVE, redirects are disabled, and TLS peer/host verification is turned on explicitly. A user-supplied URL only ever receives that user's own personal key, never a site key.

The remaining findings are low or info: API keys are stored unencrypted although core has an encryption API, personal key input is not checked for control characters or length, and there is a minor performance note on the permission check. Privacy API coverage, uninstall cleanup, upgrade steps, a scheduled log purge and an extensive test suite are all in place.

AI Summary

local_aihub is a mid-sized local plugin (about 2,500 lines of production PHP plus a large PHPUnit/Behat suite). It gives Moodle a bring-your-own-key AI layer: site and personal API keys for Gemini, Groq, DeepSeek and any OpenAI-compatible endpoint, provider ordering and failover, a usage log, and a manager report with export.

Security is strong. The one feature that would normally be a serious concern, letting teachers set their own OpenAI-compatible endpoint URL, has unusually careful SSRF hardening:

  • HTTPS only.
  • DNS resolution with public-IP checks that also handle IPv4-mapped/NAT64 IPv6 and special ranges.
  • Core blocked-host checks on every resolved address.
  • CURLOPT_RESOLVE pinning against DNS rebinding.
  • Redirects disabled and TLS verification forced on.

I checked this against core's \curl implementation (resetopt(), request(), apply_opt()), and the plugin's options do override core's insecure CURLOPT_SSL_VERIFYPEER = 0 default and its own resolve list. Pages use correct login, capability and sesskey checks, and output is escaped throughout.

The only finding worth acting on is that API keys are stored in plaintext: site keys in config_plugins, personal keys in user_preferences (and so in the session). Core's \core\encryption and the encrypted-password admin setting could protect them at rest. The other findings are minor input-validation and performance notes.

Findings

best practiceLow
Site and personal API keys are stored unencrypted

Site-wide provider keys are saved with admin_setting_configpasswordunmask, which writes the plaintext value to config_plugins. Personal keys are saved with set_user_preference() into the core user_preferences table. Moodle loads all of a user's preferences into $USER->preference, so the current user's keys also end up in session storage. Anyone who gets a database dump, backup or session-store read can recover live, billable provider keys. Core ships \core\encryption and an encrypted-password admin setting (admin/classes/setting/setting/encryptedpassword.php, aliased as admin_setting_encryptedpassword) for exactly this purpose.

Risk Assessment

Low risk. Exploiting this needs database, backup or session-store access, which is a compromise in its own right. Encryption is still a cheap layer of defence for paid, third-party credentials, especially as backups and DB copies are often shared with less-trusted staff or vendors.

Context

keys::get_site_key() and keys::get_personal_key() read these values directly and put them into outgoing Authorization / x-goog-api-key headers. The UI is correctly write-only and the privacy export redacts key values, so the exposure is limited to data at rest.

Identified Code
$setting = new admin_setting_configpasswordunmask(
    'local_aihub/gemini_key',
    get_string('settings_gemini_key', 'local_aihub'),
    get_string('settings_gemini_key_desc', 'local_aihub'),
    ''
);
Suggested Fix

Use the encrypted setting for each provider key (gemini_key, groq_key, deepseek_key, openai_key):

$setting = new admin_setting_encryptedpassword(
    'local_aihub/gemini_key',
    get_string('settings_gemini_key', 'local_aihub'),
    get_string('settings_gemini_key_desc', 'local_aihub')
);

Then decrypt in keys::get_site_key() with \core\encryption::decrypt(), and add an upgrade step that encrypts existing values.

Identified Code
set_user_preference($prefname, $key, $userid);
Suggested Fix
set_user_preference($prefname, \core\encryption::encrypt($key), $userid);

Then decrypt in get_personal_key(). Consider a dedicated plugin table instead of user_preferences, so the secret is not loaded into the session with every other preference.

code qualityLow
Personal API key input not checked for control characters or length

mykeys.php reads each personal key with PARAM_RAW_TRIMMED, which only trims whitespace at the ends, and stores it as-is. The key is then concatenated straight into an HTTP header line ('Authorization: Bearer ' . $key or 'x-goog-api-key: ' . $key). A value with embedded CR/LF or other control characters can corrupt or add headers on the outgoing request. A value longer than the user_preferences.value column (1333 characters) makes the DB write fail with an exception page instead of a validation message. Provider API keys have a narrow, predictable character set, so strict validation costs nothing.

Risk Assessment

Low risk. The effect is limited to the attacker's own outbound requests and their own key, so it causes no harm to other users. It is a robustness and input-validation gap, not a privilege boundary issue.

Context

Only users who pass keys::personal_keys_allowed() can reach the form, and they can only write their own preferences. The tainted header only goes out on requests made with that same user's personal key.

Identified Code
$key = optional_param('key_' . $provider, '', PARAM_RAW_TRIMMED);
if ($key !== '') {
    keys::save_user_key($provider, $key, $userid);
    $changed = true;
}
Suggested Fix

Reject anything that is not a plausible token before saving:

$key = optional_param('key_' . $provider, '', PARAM_RAW_TRIMMED);
if ($key !== '') {
    if (!preg_match('/^[\x21-\x7E]{1,512}$/', $key)) {
        redirect($url, get_string('mykeys_keyinvalid', 'local_aihub'), null,
            \core\output\notification::NOTIFY_ERROR);
    }
    keys::save_user_key($provider, $key, $userid);
    $changed = true;
}
Identified Code
['Authorization: Bearer ' . $key, 'Content-Type: application/json'],
Suggested Fix

As a second check, strip or refuse control characters when building headers (for example, if (preg_match('/[\x00-\x1F\x7F]/', $key)) { return failure; }) in case a key reaches the store by another route.

best practiceInfo
Personal-key permission check runs a course-wide capability query per request

keys::personal_keys_allowed() falls back to get_user_capability_course() when the capability is not held at system level, which is the normal case for teachers. That core function builds and runs a query across every course and category context where the capability could apply. The check runs on every ai::generate_text() call, every ai::is_available() call, every load of mykeys.php, and every render of the user's preferences navigation. On large sites, or for consumer plugins that call is_available() often while rendering pages, this adds avoidable load.

Risk Assessment

Informational. This is a performance note and has no security impact.

Context

Treating the capability as held when it is granted in any single course is documented and deliberate: teachers get it through course enrolments. The $limit = 1 argument already caps the result set, but the access-data and context resolution still run each time.

Identified Code
return !empty(get_user_capability_course($capability, $userid, false, '', '', 1));
Suggested Fix

Memoise the result per user for the request, for example in a static array keyed by user id, or in a short-TTL request/session cache invalidated on role changes:

static $allowed = [];
return $allowed[$userid] ??= !empty(get_user_capability_course($capability, $userid, false, '', '', 1));
Additional AI Notes

Outbound HTTP is well handled. client::http_post() uses core's \curl and explicitly sets CURLOPT_SSL_VERIFYPEER => 1, CURLOPT_SSL_VERIFYHOST => 2 and CURLOPT_FOLLOWLOCATION => 0, with a 30-second timeout. In this Moodle tree, curl::resetopt() defaults peer verification to 0, so the override matters, and it takes effect because apply_opt() applies per-request options after the defaults.

SSRF defences for the user-configurable endpoint are layered. safe_addresses() enforces HTTPS, resolves A/AAAA records, and rejects private, reserved, CGNAT and benchmark ranges plus IPv4-embedded IPv6. It also checks each address against core's curl_security_helper. pin_to() then supplies a CURLOPT_RESOLVE entry that replaces core's own resolve list, so the connection goes to the validated addresses. A personal endpoint is only ever used with that user's personal key, so a site key can never reach a user-chosen host.

Privacy API coverage is thorough. It declares the log table, every user preference and all four external destinations, exports preferences with key values redacted, and includes tests that confirm every declared log field is exported. db/uninstall.php cleans up the plugin's rows in the core user_preferences table.

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