MDL Shield

Provider API OpenWebUI

aiprovider_openwebui

Print Report
Plugin Information

An AI subsystem provider that lets Moodle use a self-hosted Open WebUI instance as its AI backend. It implements the generate_text, summarise_text, explain_text and generate_image actions, sending prompts to an admin-configured Open WebUI URL over its OpenAI-compatible API and returning generated text or watermarked draft image files. The plugin is closely derived from Moodle core's aiprovider_openai provider.

GitHubrabser/moodle-aiprovider_openwebuiorigin/MOODLE_501_STABLE
Version:2025103000
Release:1.5.1.01
Reviewed for:5.1
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-09-21
18 files·1,432 lines
Grade Justification

This provider is a small, focused plugin that closely tracks Moodle's core aiprovider_openai reference implementation and uses the sanctioned framework APIs correctly throughout.

No security vulnerabilities were found. Key points that were verified against core:

  • All outbound requests use \core\http_client obtained via \core\di::get(). That client enforces the site's blocked-host/allowed-port list (via the check_request middleware calling curl_security_helper) on both the initial request and redirect hops, and leaves Guzzle's default TLS peer/host verification enabled. The plugin never disables verify or passes ignoresecurity.
  • Every URL used for outbound requests (apiurl, per-action endpoint) is site-administrator configuration, not user input. The user-supplied prompttext travels in the request body, never the URL, so there is no SSRF path reachable by non-admins.
  • Draft image files are created through the File API; temporary files use make_request_directory(). There is no direct database, filesystem, or SQL access, and no shell/eval usage.
  • Access control is correctly delegated: providers are invoked internally by \core_ai\manager, with capability and login checks performed upstream by placements.
  • A Privacy API provider is present and correctly models the plugin as storing no local personal data while declaring the external data transfer.

The remaining findings are low-severity robustness and documentation issues: external API responses are parsed without validating their structure, the image-download step lacks failure handling, the README's stated Moodle requirement contradicts version.php, and there are no automated tests. None of these are exploitable and none affect other users' data.

AI Summary

The OpenWebUI AI provider integrates a self-hosted Open WebUI instance into Moodle's AI subsystem. It is a thin, well-structured plugin derived from core's aiprovider_openai, implementing four actions (text generation, summarisation, explanation, image generation).

Security posture is strong. Every outbound HTTP call is made through the sanctioned \core\http_client, which applies core's outbound-request block list on initial and redirect requests and keeps TLS verification on by default. All request URLs are derived from site-admin configuration (apiurl + per-action endpoint); the only user-controlled input, the prompt text, is confined to the request body. There is no direct DB/filesystem/SQL access, no code execution, correct File API and temp-directory usage, and a proper Privacy API implementation.

The findings are limited to low-severity code-quality and documentation matters:

  • External API responses are decoded and dereferenced without checking their shape, which can emit PHP warnings and produce unhelpful errors when the upstream service misbehaves.
  • The image-download helper (url_to_file) issues a second authenticated HTTP GET without error handling and builds the URL by concatenating an unvalidated response field onto the base URL.
  • The README.md claims Moodle 4.5 support while version.php requires a Moodle 5.1 build and CI only tests 5.1.
  • No automated tests are shipped despite CI being configured to run them.

Overall this is a clean, standards-following plugin with only minor hardening and hygiene opportunities.

Findings

code qualityLow
External API responses parsed without validating their structure

The response handlers decode JSON from the external Open WebUI service and immediately dereference nested properties/array offsets without checking that the decode succeeded or that the expected fields are present.

If the upstream service returns a non-JSON body, an error page, or a response whose shape differs from what is assumed, json_decode() yields null (or an unexpected type) and the subsequent property/offset access emits PHP warnings and yields null values. In handle_api_error() this means the error message passed to \core_ai\error\factory::create() can itself be null, degrading the error reported to the user.

This is a robustness / code-quality issue rather than a security flaw: the data originates from the admin-configured backend, and PHP 8 treats these as warnings rather than fatal errors. It does, however, produce noisy debugging output and unclear failures when the backend is misconfigured or unreachable at the application layer.

Risk Assessment

Low risk. The response comes from the site-admin-configured backend, not from an untrusted party, and PHP 8 raises warnings (not fatals) for the null dereferences, so the action degrades to an error rather than crashing. The practical impact is debugging noise and unhelpful error messages when the backend returns something unexpected. No user data is exposed and there is no exploit path for a non-admin user.

Context

These handlers run inside query_ai_api() after a request to the configured Open WebUI endpoint. handle_api_success() runs on HTTP 200 and handle_api_error() on other statuses. The results flow back through \core_ai\process_base::process() to the placement that requested the action. The core aiprovider_openai reference uses the same defensive-light pattern (e.g. $bodyobj->error->message), so this reflects an inherited style rather than a new mistake, but the assumption is still unchecked.

Identified Code
            $bodyobj = json_decode($response->getBody()->getContents());
            $errormessage = $bodyobj->detail;
Suggested Fix

Guard the decode and the property access, falling back to the HTTP reason phrase when the body is not in the expected shape:

$bodyobj = json_decode($response->getBody()->getContents());
$errormessage = $bodyobj->detail ?? $response->getReasonPhrase();
Identified Code
        $responsebody = $response->getBody();
        $bodyobj = json_decode($responsebody->getContents());

        // Cleanup thinking before returning text if any !
        $thinkclean = preg_replace('/<think>.*?<\/think>/s', '', $bodyobj->choices[0]->message->content);
Suggested Fix

Validate that $bodyobj decoded successfully and that choices[0]->message->content exists before using it (for example with null coalescing or an explicit is_object/isset check), and handle the missing-data case as an error response rather than dereferencing blindly.

Identified Code
        $responsebody = $response->getBody();
        $bodyobj = json_decode($responsebody->getContents());

        return [
            'success' => true,
            'sourceurl' => $this->provider->config['apiurl'] . $bodyobj[0]->url,
            'model' => $this->get_model(), // There is no model in the response, use config.
        ];
Suggested Fix

Confirm $bodyobj is a non-empty array whose first element carries a url property before building sourceurl; otherwise return a structured error via \core_ai\error\factory so callers receive a clean failure instead of warnings.

code qualityLow
Image download lacks failure handling and derives the URL from an unvalidated response field

url_to_file() performs a second outbound HTTP request to fetch the generated image. Two aspects are worth hardening:

  • No error handling. Unlike the main query_ai_api() call (which passes HTTP_ERRORS => false and wraps send() in a try/catch for RequestException), this get() does neither. If the download fails (network error, timeout, or any non-2xx status) Guzzle throws a RequestException. Core's \core_ai\manager::call_action_provider() does not wrap the processor in a try/catch, so the exception propagates out of the AI subsystem to the calling placement instead of returning a clean error response.
  • URL built from an unvalidated response field. The download URL is apiurl (admin) concatenated with $bodyobj[0]->url taken from the API response, and the provider API key is attached as an Authorization: Bearer header. Because the value is concatenated onto the base URL without normalisation, a malformed url value from the backend could resolve to an unexpected host. \core\http_client still applies the block list, and the backend is already a trusted party that holds the API key, so this is a defence-in-depth concern rather than a live vulnerability.
Risk Assessment

Low risk. The missing error handling only affects the resilience of image generation: a failed download turns into an uncaught exception for the requesting user rather than a graceful error message. The URL-concatenation concern requires the admin-configured backend itself to return a hostile url value, but that backend is already fully trusted (it receives every prompt and holds the API key), and \core\http_client enforces the outbound block list regardless, so no meaningful new exposure exists. Reaching this code requires whatever role a placement grants for image generation; it cannot be triggered against other users.

Context

url_to_file() is called from process_generate_image::query_ai_api() after a successful generation response. It downloads the image to a request-scoped temp file, applies the AI watermark, and stores the result in the user's draft file area via the File API. The $url argument is $response['sourceurl'], which handle_api_success() builds as apiurl . $bodyobj[0]->url.

Identified Code
        $client = \core\di::get(http_client::class);

        // Download the image and add the watermark.
        $tempdst = make_request_directory() . DIRECTORY_SEPARATOR . $filename;
        $client->get($url, [
            'sink' => $tempdst,
            'timeout' => $CFG->repositorygetfiletimeout,
            'headers' => [
            'Authorization' => "Bearer {$apikey}",
            ],
        ]);
Suggested Fix

Wrap the download in a try/catch and return a structured error (as query_ai_api() does) so a failed fetch does not surface as an uncaught exception:

try {
    $client->get($url, [
        'sink' => $tempdst,
        'timeout' => $CFG->repositorygetfiletimeout,
        RequestOptions::HTTP_ERRORS => true,
        'headers' => ['Authorization' => "Bearer {$apikey}"],
    ]);
} catch (RequestException $e) {
    return \core_ai\error\factory::create($e->getCode(), $e->getMessage())->get_error_details();
}

Additionally, validate that the resolved download URL still targets the configured apiurl host before attaching the API key.

code qualityLow
README states a Moodle version requirement that contradicts version.php

The README.md tells administrators the plugin requires Moodle LMS 4.5, but version.php sets $plugin->requires = 2025092600, which corresponds to a Moodle 5.1 development build, and the CI workflow only builds and tests against MOODLE_501_STABLE.

An administrator following the README and attempting to install on Moodle 4.5 will hit the requirements check and be unable to install, or will assume compatibility that has never been tested. The documentation should state the actual supported baseline.

Risk Assessment

Low risk. This is a documentation accuracy problem with no security impact. Its effect is administrator confusion and potential wasted effort attempting installation on an unsupported branch.

Context

version.php declares the plugin metadata used by Moodle's installer to enforce the minimum core version. The README is the primary human-facing installation guidance.

Identified Code
This provider requires Moodle LMS 4.5, the first version to include the AI subsystem.
Suggested Fix

Update the README to state the version that version.php and CI actually target (Moodle 5.1), or lower $plugin->requires and extend CI coverage if 4.5 support is genuinely intended and verified.

Identified Code
$plugin->requires = 2025092600;
Suggested Fix

Keep this in sync with the documented minimum Moodle version. If 4.5 support is desired, set the corresponding lower requires value and confirm the plugin runs on that branch.

best practiceInfo
No automated tests shipped

The plugin contains no tests/ directory (no PHPUnit unit tests and no Behat features), yet the CI workflow is configured to run phpunit and behat. For a plugin that transforms request/response payloads and performs file handling, unit tests around the request builders and response handlers would catch regressions and document the expected API contract.

This is a best-practice observation, not a defect in the shipping code.

Risk Assessment

Informational. No runtime or security impact. Adding tests for create_request_object(), handle_api_success()/handle_api_error(), and the form get_data()/validation() logic would improve maintainability and guard against upstream API changes.

Context

The reference aiprovider_openai provider ships a test helper trait and test coverage. This plugin reuses much of that provider's structure but omits tests entirely, so the CI test steps are effectively no-ops.

code qualityLow
apiurl provider setting typed as PARAM_TEXT rather than PARAM_URL

The provider setup form registers the apiurl field — which is a URL used as the base for every outbound request — with PARAM_TEXT. The per-action endpoint fields, by contrast, are typed PARAM_URL. PARAM_URL is Moodle's idiomatic type for URL inputs and applies URL-aware cleaning.

This is a minor code-quality/consistency point. Its security impact is negligible: the field is site-administrator-only, and the actual outbound request URL is validated by \core\http_client's block-list middleware regardless of the stored value.

Risk Assessment

Low risk. Admin-only configuration input with no downstream injection sink — the value is only ever used to construct an HTTP request that passes through core's outbound security checks. The recommendation is about using the correct Moodle parameter type, not mitigating an exploit.

Context

set_form_definition_for_aiprovider_openwebui() builds the provider-instance configuration form via the after_ai_provider_form_hook. Only users who can manage AI providers (site administrators) can submit this form.

Identified Code
        $mform->setType('apiurl', PARAM_TEXT);
Suggested Fix

Type the field as a URL so it receives URL-appropriate cleaning:

$mform->setType('apiurl', PARAM_URL);

If PARAM_URL proves too restrictive for a required internal host, keep PARAM_TEXT but document the choice.

Additional AI Notes

Correct use of the sanctioned HTTP layer. Both the main AI call and the image download obtain \core\http_client via \core\di::get(). Verified in core (lib/classes/http_client.php, lib/classes/local/guzzle/check_request.php): this client enforces the site blocked-host/allowed-port list on the initial request and on redirects, and inherits Guzzle's default TLS peer/host verification (the plugin never overrides verify or sets ignoresecurity). No TLS or SSRF finding applies.

Access control is correctly delegated to the framework. Providers are instantiated and invoked by \core_ai\manager; require_login/capability checks live in the placements that create actions. The absence of such checks inside this plugin is the expected architecture, not a missing-check defect.

Model list intentionally offers only “custom”. The aimodel namespace ships only the openwebui_base interface and no concrete model classes. Core's \core_component::get_component_classes_in_namespace() filters results through class_exists(), which excludes interfaces, so helper::get_model_classes() returns an empty array (it does not throw) and the model chooser presents only the “custom” option. This is a valid design choice for a provider whose available models are site-specific.

Harmless inherited dead import. classes/hook_listener.php carries use aiprovider_openwebui\model\base;, referencing a non-existent model namespace. It is never used and is copied verbatim from the core aiprovider_openai reference (which has the equivalent unused import and passes CI). Worth removing for tidiness but it has no effect.

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