Interactive book
mod_mubook
An interactive book activity module for Moodle that supports multiple content types (HTML, Markdown, unsafe raw HTML, and disclosure/collapsible sections). It provides chapters with sub-chapters, tagging, backup/restore, and an extensible hook system for registering additional content types. Depends on `tool_mulib` for UI components (ajax forms, dropdowns). Bundles league/commonmark for Markdown rendering.
The plugin demonstrates strong security practices throughout. All pages enforce require_login() and require_capability() before any privileged operation. Database access uses Moodle's $DB API with parameterized queries exclusively. Output is properly sanitized via format_text(), format_string(), and clean_text(). Sesskey validation is handled automatically by moodleform and ajax_form. File handling uses the Moodle File API correctly. The unsafehtml content type is properly gated behind mod/mubook:usexss (managers only by default), and restore security correctly resets trust for cross-site backups.
The few findings are all low severity:
- A bug in the tagged chapters SQL query that references the wrong module name (
bookinstead ofmubook), causing tag search to fail silently. - Use of triple-mustache (unescaped output) in the disclosure template for text labels that should use double-mustache.
- Unescaped content type name in the unknown content rendering path, though the audience is restricted to editing teachers.
None of these represent exploitable security vulnerabilities in normal usage. The codebase is well-structured with comprehensive test coverage (PHPUnit and Behat), proper Privacy API implementation, full backup/restore support, and thoughtful extensibility through hooks.
Plugin Overview
mod_mubook is a well-engineered interactive book module that extends Moodle's content capabilities. It supports:
- Multiple content types: HTML (via Atto/TinyMCE editor), Markdown (via bundled league/commonmark 2.8), unsafe raw HTML (for trusted admins), and disclosure/collapsible sections
- Hierarchical chapters: Top-level chapters with one level of sub-chapter nesting
- Extensibility: Hook-based system (
content_classes,content_post_render,book_actions,chapter_actions,content_actions) allowing other plugins to register new content types and actions - Security model: Proper capability-based access control with
mod/mubook:usexssgating unsafe content behindRISK_XSS
Security Assessment
The plugin follows Moodle security best practices consistently:
- Authentication/Authorization: Every page calls
require_login()orrequire_course_login()followed by appropriaterequire_capability()checks. Additional per-objectcan_create/can_update/can_delete/can_viewmethods provide fine-grained control. - Input validation: Uses
required_param()/optional_param()with properPARAM_*types. Form data processed throughmoodleform. - Output sanitization: HTML content passes through
format_text()withnoclean => false. Markdown output is sanitized viaclean_text()after conversion. Chapter titles useformat_string(). - CSRF protection: All state-changing operations use
moodleformorajax_formwhich handle sesskey automatically. - Restore security: Unsafe content trust is properly reset for cross-site restores unless explicitly enabled by admin.
Code Quality
The codebase is clean and well-organized with proper separation of concerns. The plugin includes comprehensive PHPUnit and Behat tests, proper event logging, full backup/restore support, and a null Privacy API provider (appropriate since the module stores no personal data).
Findings
The SQL query in mod_mubook_get_tagged_chapters() joins with the {modules} table using m.name='book' instead of m.name='mubook'. This means the tag search feature for mubook chapters will never return results, because it looks for course modules belonging to the core book module rather than mubook.
This is a functional bug inherited from copying the core book module's tag implementation without updating the module name reference.
Low risk. This is a functional bug, not a security issue. The tag search for mubook chapters will silently return no results because it filters on the wrong module name. No data is exposed or modified incorrectly — the feature simply doesn't work. The fix is a one-character change from 'book' to 'mubook'.
The mod_mubook_get_tagged_chapters() function in locallib.php is a callback used by Moodle's tag system to find book chapters tagged with a specific tag. The SQL query joins several tables to find matching chapters and their associated course modules. The {modules} table join is used to ensure the course module belongs to the correct activity type.
JOIN {modules} m ON m.name='book'
Change the module name to mubook:
JOIN {modules} m ON m.name='mubook'
The disclosure.mustache template renders user-provided text labels using triple-mustache syntax ({{{labelshow}}}, {{{labelhide}}}, {{{labelprinted}}}), which outputs content without HTML escaping. These labels are plain text, not HTML, and should use double-mustache ({{labelshow}}) for automatic HTML escaping.
In normal operation, the labels are cleaned by PARAM_TEXT during form submission, which strips HTML tags. However, during backup/restore, the data1 field (containing JSON with these labels) is restored verbatim from the backup XML, bypassing form validation. A maliciously crafted backup file could inject HTML/JavaScript into these labels.
Low risk. The attack vector is very constrained — it requires an administrator to restore a maliciously crafted backup file. Normal form-based input is properly sanitized via PARAM_TEXT. The fix is trivial (switch to double-mustache) and would eliminate the theoretical risk entirely. Since this is a defense-in-depth issue rather than an actively exploitable vulnerability, low severity is appropriate.
The disclosure content type stores custom button labels as JSON in the data1 database field. The disclosure.php render method decodes this JSON and passes the labels directly to the Mustache template. During normal form submission, PARAM_TEXT cleaning strips HTML tags from the labels. During backup/restore, the data1 field is transferred without re-validation.
<span class="d-print-none label-collapsed">{{{labelshow}}}</span>
<span class="d-print-none label-expanded">{{{labelhide}}}</span>
<span class="d-print-inline hidden">{{{labelprinted}}}</span>
Use double-mustache for automatic HTML escaping since these are text labels, not pre-rendered HTML:
<span class="d-print-none label-collapsed">{{labelshow}}</span>
<span class="d-print-none label-expanded">{{labelhide}}</span>
<span class="d-print-inline hidden">{{labelprinted}}</span>
The unknown content type renders the content type name using get_string() with an {$a} placeholder, which does not HTML-escape the replacement value. The $this->record->type value is passed directly into the language string and output as HTML.
In normal operation, content types are validated as PARAM_ALPHANUM during creation, making this safe. However, during backup/restore, the type field is restored from backup XML without re-validation. Additionally, only users with mod/mubook:editcontent capability can see unknown content (restricted in unknown::can_view()).
Low risk. The combination of constraints makes this very difficult to exploit:
- Content types are validated as
PARAM_ALPHANUMduring normal creation - The only injection vector is through a malicious backup restore
- Only users with editing capabilities can see the unknown content rendering
- The DB column is
CHAR(100), limiting payload size
This is a defense-in-depth issue. Adding s() escaping is a simple best-practice fix.
The unknown content class is a fallback used when a content record's type doesn't match any registered content class (e.g., after uninstalling an extension that added a custom content type). The can_view() method restricts visibility to users with the mod/mubook:editcontent capability, which limits the audience to teachers, editing teachers, and managers.
return '<div class="text-danger">' . get_string('content_unknowntype', 'mod_mubook', $this->record->type) . '</div>';
HTML-escape the type value before passing to get_string():
return '<div class="text-danger">' . get_string('content_unknowntype', 'mod_mubook', s($this->record->type)) . '</div>';
| Library | Version | License | Declared |
|---|---|---|---|
league/commonmark Markdown to HTML conversion engine, used for the Markdown content type rendering | 2.8.0 | BSD-3-Clause | ✓ |
dflydev/dot-access-data Dependency of league/commonmark for dot-notation data access in configuration | 3.0.3 | MIT | ✓ |
league/config Dependency of league/commonmark for configuration management | 1.2.0 | BSD-3-Clause | ✓ |
nette/schema Dependency of league/config for schema validation | 1.3.3 | BSD-3-Clause | ✓ |
nette/utils Dependency of nette/schema for utility functions | 4.0.10 | BSD-3-Clause | ✓ |
psr/event-dispatcher PSR-14 event dispatcher interface, dependency of league/commonmark | 1.0.0 | MIT | ✓ |
symfony/deprecation-contracts Symfony deprecation handling, dependency of league/commonmark | 3.6.0 | MIT | ✓ |
symfony/polyfill-php80 PHP 8.0 polyfills for older PHP versions, dependency of league/commonmark | 1.33.0 | MIT | ✓ |
pomodocs/commonmark-alert CommonMark extension for GitHub-style alert blocks (note, tip, important, warning, caution) | dev-mutms-php81 | MIT | ✓ |
mutms/commonmark-extra Custom CommonMark extension with additional Markdown features (task lists, etc.) | dev-main | MIT | ✓ |
The plugin has a well-designed extensibility model using Moodle's hook system. Other plugins can register new content types via the content_classes hook, add actions via book_actions/chapter_actions/content_actions hooks, and modify rendered content via content_post_render. This is a clean architectural pattern that avoids tight coupling.
The unsafe HTML content type (unsafehtml) is properly security-gated. It requires mod/mubook:usexss capability (managers only by default, marked with RISK_XSS). During restore from other sites, the unsafetrusted flag is automatically reset to 0 unless the admin explicitly enables cross-site trust via the restoreothertrustunsafe setting. This is a thoughtful security design.
The plugin includes comprehensive test coverage with both PHPUnit tests (events, hooks, content types, generators, formatters, privacy) and Behat tests (chapters, content types, navigation). This level of testing is above average for Moodle plugins.
The Privacy API uses null_provider, which is appropriate since the plugin's database tables (mubook, mubook_chapter, mubook_content) store course content rather than personal user data. User activity tracking is handled by Moodle core (events, completion).
Two vendor libraries use dev branch versions: pomodocs/commonmark-alert at dev-mutms-php81 and mutms/commonmark-extra at dev-main. These are custom forks maintained by the plugin author. While this works, pinning to tagged releases would improve reproducibility and make it easier to audit for security updates.