AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL unreviewed MIT Self-run

Moodle Security Audit

skill-saadrahman01-claude-moodle-dev-moodle-security-audit · by SaadRahman01

Use when reviewing or hardening Moodle plugin code for security — capability checks, sesskey CSRF, input validation, SQL injection, XSS, file serving, SSRF, IDOR, secrets handling, and Moodle's specific anti-patterns.

No reviews yet
0 installs
50 views
0.0% view→install

Install

$ agentstack add skill-saadrahman01-claude-moodle-dev-moodle-security-audit

Open-source listing, not yet scanned by AgentStack. Follow the source repository for install instructions.

Security review

⚠ Flagged

1 finding(s); flagged for manual review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures
  • high Dangerous shell/eval execution.

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution Used

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Reliability & compatibility

Not yet reviewed
0 installs to date
no reviews yet
3mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Moodle Security Audit? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Moodle Security Audit

Overview

Moodle has framework-level protections (capability system, sesskey, $DB placeholders, output escaping) — but only if used. Most Moodle plugin CVEs come from skipping these. This skill enforces the checklist.

When to Use

  • Reviewing a PR for security
  • Auditing existing plugin code
  • Responding to a vulnerability report
  • Pre-submission review for moodle.org plugin directory

Skip when: writing new feature code (use moodle-plugin-development — it covers the basics).

Audit checklist (every entry script)

require('../../config.php');                            // bootstrap
require_login();                                        // 1. session + cookies
$context = context_course::instance($courseid);         // 2. context
$PAGE->set_context($context);
require_capability('local/example:view', $context);     // 3. capability
require_sesskey();                                      // 4. CSRF (POST only)

$id   = required_param('id', PARAM_INT);                // 5. typed input
$name = optional_param('name', '', PARAM_TEXT);

Order matters. require_login before context_*::instance because login resolves $USER.

1. Authentication — require_login()

| Variant | Use | |---------|-----| | require_login() | Logged-in user, any context | | require_login($course) | Logged-in + enrolled in course | | require_login($course, true, $cm) | Logged-in + activity-level access | | require_admin() | Site admin only | | require_login(null, false) | Skips guest auto-login (rare) |

Bug: Forgetting require_login() on AJAX endpoints. AJAX still needs it — session might be valid but unauthorized.

2. Authorization — capabilities

require_capability('local/example:edit', $context);
// or for soft check:
if (!has_capability('local/example:edit', $context)) {
    redirect(...);
}

Bug — IDOR (Insecure Direct Object Reference):

// BAD — checks site-level cap, but $item belongs to a course user can't access
$item = $DB->get_record('local_example_items', ['id' => $id], '*', MUST_EXIST);
require_capability('local/example:view', context_system::instance());

// GOOD — derive context from the object
$item = $DB->get_record('local_example_items', ['id' => $id], '*', MUST_EXIST);
$context = context_course::instance($item->courseid);
require_capability('local/example:view', $context);

Always derive $context from the object being acted on, not from URL params.

3. CSRF — sesskey

Every state-changing request (POST, GET that mutates) needs:

require_sesskey();

In forms via formslib: MoodleQuickForm adds it automatically. In raw HTML forms:

">

In AJAX core/ajax: token attached automatically. In raw fetch:

import {sesskey} from 'core/config';
// include in body

Bug: GET request that mutates DB without sesskey check.

4. Input — never raw $_GET/$_POST/$_REQUEST

$id    = required_param('id', PARAM_INT);                // throws if missing
$name  = optional_param('name', '', PARAM_TEXT);
$ids   = required_param_array('ids', PARAM_INT);
$tags  = optional_param_array('tags', [], PARAM_RAW);

PARAM_RAW accepts any input — use only with explicit downstream sanitization. PARAM_TEXT strips tags; PARAM_NOTAGS is similar; PARAM_CLEANHTML allows safe HTML.

Bug: $_REQUEST['id'] direct access — bypasses type coercion, vulnerable to type juggling.

5. SQL — placeholders only

// GOOD
$DB->get_records_sql('SELECT * FROM {local_example_items} WHERE name = ?', [$name]);
$DB->get_records_sql('... WHERE name = :name', ['name' => $name]);
$DB->get_records('local_example_items', ['courseid' => $cid]);

// BAD — concatenation
$DB->get_records_sql("SELECT * FROM {local_example_items} WHERE name = '$name'");

// SUBTLE BUG — string interpolation in IN clause
$ids = implode(',', $idarray);
$DB->get_records_sql("... WHERE id IN ($ids)");

// FIX — get_in_or_equal
[$insql, $params] = $DB->get_in_or_equal($idarray);
$DB->get_records_sql("... WHERE id $insql", $params);

Bug: Building LIKE patterns by concatenation — use $DB->sql_like() + $DB->sql_like_escape().

$pattern = '%' . $DB->sql_like_escape($search) . '%';
$where = $DB->sql_like('name', '?', false);   // case-insensitive
$DB->get_records_sql("SELECT * FROM {t} WHERE $where", [$pattern]);

6. Output — escape everything

| Function | Use | |----------|-----| | s($str) | Escape for HTML attribute / text | | format_string($str, true, ['context' => $c]) | Plain string with filters (multi-lang, etc.) | | format_text($html, FORMAT_HTML, ['context' => $c]) | Rich text — runs filters + cleans | | clean_text($str, FORMAT_HTML) | Strip dangerous tags | | html_writer::tag('div', $content, $attrs) | Build HTML safely | | $OUTPUT->render_from_template(...) | Mustache auto-escapes {{var}}; raw via {{{var}}} |

Always pass context to format_string/format_text — controls which filters run.

Bug: echo $row->name directly — XSS if name contains tags.

{{name}}        ← escaped (safe)
{{{name}}}      ← raw HTML (dangerous unless format_text'd in PHP first)

7. File serving — pluginfile.php

Never expose $CFG->dataroot paths directly. File areas served via pluginfile.php callback in lib.php:

function local_example_pluginfile($course, $cm, $context, $filearea, $args, $forcedl, $options = []) {
    require_login($course, false, $cm);

    if ($filearea !== 'attachments') {
        return false;
    }

    $itemid = (int)array_shift($args);
    require_capability('local/example:viewfile', $context);

    $filename = array_pop($args);
    $filepath = '/' . implode('/', $args) . '/';
    $filepath = $filepath === '//' ? '/' : $filepath;

    $fs = get_file_storage();
    $file = $fs->get_file($context->id, 'local_example', $filearea, $itemid, $filepath, $filename);
    if (!$file || $file->is_directory()) {
        return false;
    }

    send_stored_file($file, 0, 0, $forcedl, $options);
}

Bug: Skipping capability check inside callback. Yes, pluginfile.php does session auth but not plugin-specific authorization.

8. SSRF / external HTTP

$curl = new \curl();                        // Moodle's curl wrapper
$response = $curl->get($url);

\curl respects $CFG->curlsecurityblockedhosts + $CFG->curlsecurityallowedport. Don't use raw curl_exec() or file_get_contents($url) for user-supplied URLs.

// Validate URL belongs to allowed scheme
$url = clean_param($url, PARAM_URL);
if (!$url) {
    throw new moodle_exception('invalidurl');
}

9. File uploads

  • Always go through Moodle's draft area + file_save_draft_area_files
  • Check MIME via \core_form\filetypes_util or accept filemanager option
  • Set maxfiles, maxbytes, accepted_types
  • Never trust client-provided $_FILES['file']['type']

10. Secrets

  • Never commit tokens / API keys to version.php / source
  • Store in mdl_config_plugins via set_config('apikey', $value, 'local_example')
  • Read with get_config('local_example', 'apikey')
  • For very sensitive data, encrypt with \core\encryption (Moodle 4.0+)

11. Logging

User actions trigger events; events flow to logs. Don't log sensitive data:

// BAD
debugging("Token: $token");

// GOOD
debugging('Token issued for user ' . $userid);

12. Redirect open-redirect

// BAD
$next = optional_param('returnto', '', PARAM_URL);
redirect($next);

// GOOD — whitelist or use moodle_url
$next = new moodle_url($next);
if ($next->get_host() !== (new moodle_url($CFG->wwwroot))->get_host()) {
    throw new moodle_exception('invalidurl');
}
redirect($next);

Anti-patterns checklist

| Anti-pattern | Fix | |--------------|-----| | $_GET['id'] | required_param('id', PARAM_INT) | | Raw SQL concat | Placeholders ? or :name | | echo $userdata | s() / format_string / format_text | | Missing require_login() | Add at top of every entry script | | Missing require_capability() | Check on object's context, not arbitrary system | | Missing require_sesskey() on POST | Always | | Direct $CFG->dataroot access | Go through \file_storage | | eval() / create_function() | Never | | unserialize($userdata) | Never on user input — use JSON | | file_get_contents($userurl) | Use \curl wrapper | | print_error() | Deprecated — throw new \moodle_exception(...) | | Hard-coded admin user check ($USER->id == 2) | is_siteadmin() or capability | | Trusting $_SERVER['HTTP_*'] | Check $_SERVER['SERVER_NAME'] instead; headers spoofable | | md5($password) for new code | password_hash() / Moodle's hash_internal_user_password() | | Skipping cap check in pluginfile callback | Add explicitly — session auth ≠ authorization |

Static analysis

# Moodle's local_codechecker (phpcs)
phpcs --standard=moodle local/example

# Psalm / PHPStan with Moodle stubs (community projects)
# https://github.com/MoodleHQ/moodle-local_codechecker

Reporting vulnerabilities upstream

Found a bug in Moodle core? Don't open a public issue. Email security@moodle.org per the Moodle security policy.

References

  • Security overview: https://moodledev.io/general/development/policies/security
  • Output escaping: https://moodledev.io/docs/apis/subsystems/output#escaping-content
  • Capabilities: https://moodledev.io/docs/apis/subsystems/access
  • File API: https://moodledev.io/docs/apis/subsystems/files
  • DML placeholders: https://moodledev.io/docs/apis/core/dml#placeholders
  • Reporting security: https://moodle.org/security/

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.