Flexible sections format
format_flexsections
A more recent review of this plugin is available. View the latest review →
Flexible sections course format for Moodle. Sections can be nested inside other sections; each section can be shown on the course page or as a link to a separate page, and hiding a section recursively hides its subsections and activities. Provides a reactive (AJAX) course editor, course index, and backup/restore integration.
This is a mature, carefully written course format plugin with strong security hygiene and an extensive automated test suite that explicitly covers abuse cases.
No security vulnerabilities were found. Every state-changing operation is gated by both a capability check and session-key validation:
- The AJAX state actions (
section_add,section_delete,section_mergeup,section_move_after,section_switch_collapsed, visibility changes) run through thecore_courseformat_update_courseweb service (which validatessesskey) and each method callsrequire_capability/require_all_capabilitiesplusvalidate_sections. Sections are resolved against the course's ownmodinfowithMUST_EXIST, so cross-course manipulation throws — a behaviour backed by thetest_section_hide_missing_sectiontest. - The non-JavaScript GET fallbacks in
format_flexsections::page_set_course()(addchildsection,mergeup,deletesection,movesection,switchcollapsed,marker,hide,show) each requireconfirm_sesskey()and the appropriate course capability, closing the CSRF gap noted in the plugin's own changelog.
No XSS: templates follow core's escaping conventions (section titles come from format_string/inplace-editable rendering; triple-mustache is used only for server-generated HTML and moodle_url output). No SQL injection: all queries use parameter placeholders and cross-DB helpers (get_in_or_equal, sql_like, sql_like_escape). Section summary files are handled through the File API, course-structure changes to core tables mirror core's own section-management approach, and section deletion is wrapped in a lock to avoid corruption. The referer handling uses get_local_referer(), which sanitises via PARAM_LOCALURL.
The only issues are a low-severity privacy completeness gap (the provider declares no stored data while the format persists split section-state user preferences that core's export only partially covers) and an informational note about using Reflection to mutate a core object's protected property. Neither is a security risk, and data retention/cleanup for the preferences is correctly implemented.
format_flexsections is a well-engineered course format that adds nested sections on top of Moodle's reactive course editor. The review read all PHP, JavaScript, and Mustache files and cross-checked the security-relevant behaviour against core.
Security posture is strong:
- All privileged operations enforce capabilities and sesskey — both the AJAX
stateactionspath (via thecore_courseformat_update_courseweb service) and the legacy GET handlers inpage_set_course(). - Section operations are validated against the course's own
modinfo(MUST_EXIST), preventing cross-course tampering; this is covered by dedicated tests. - No XSS (core escaping patterns,
format_stringfor names), no SQL injection (parameterised queries, cross-DB helpers), File API used for section files, and a lock guards bulk section deletion.
Findings:
- Low (compliance) — The Privacy provider is a
null_providerand the metadata string claims no personal data is stored, but the format persists per-user section collapse state touser_preferences, including continuation keys (coursesectionspreferences_<courseid>#N) that core's export does not surface. - Info (best practice) — Reflection is used to modify a core renderable's protected
actionlinksproperty to hide themod_subsectionchooser entry; this is fragile against core changes.
The plugin ships no third-party libraries and requires no thirdpartylibs.xml.
Findings
The plugin's Privacy API implementation is a null_provider, and the privacy:metadata language string asserts that the plugin does not store any personal data. This is inaccurate.
Through the preferences trait, the format persists each user's per-course section collapse/expand state into the user_preferences table. The value is written under coursesectionspreferences_<courseid> and, when the JSON exceeds ~1300 characters, is split across additional continuation keys coursesectionspreferences_<courseid>#1, coursesectionspreferences_<courseid>#2, and so on.
Core's core_courseformat privacy provider declares and exports only the base key (coursesectionspreferences_<courseid>) for each of a user's courses. The continuation keys introduced by this plugin are not known to any privacy provider, so they are never included in a data-subject export — and in the split case even the base key holds only the first fragment of the JSON value.
Because core's own format_topics/format_weeks legitimately use null_provider (core handles the single base key), the defect here is specifically the extra split-key storage combined with the affirmative "no data" declaration.
Low risk. This is a GDPR/Privacy API completeness gap, not a security vulnerability — no attacker and no privilege boundary is involved. The stored data is low-sensitivity interface state (which sections a user has collapsed). The base preference key is already covered by core's core_courseformat export, and the undeclared continuation keys only appear for courses with many sections. Because deletion is implemented correctly, the practical consequence is an incomplete response to a data-subject access request, and only for users of large courses.
persist_to_user_preference() calls set_long_preference(), which chunks the JSON-encoded preference into 1300-character pieces and writes each via set_user_preference(). The unit tests confirm these split keys are created in normal use (a 500-section course in test_add_section_preference_ids_long).
Data retention is handled correctly: format_flexsections::delete_format_data() deletes the base key and all #N continuation keys on course deletion (verified by test_delete_format_data), and core removes all user_preferences rows when a user account is deleted. The gap is confined to declaration/export, not retention.
class provider implements null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } }
Implement \core_privacy\local\request\user_preference_provider, describe the preference in get_metadata(), and export the reassembled value in export_user_preferences() using the trait's get_long_preference():
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\user_preference_provider {
public static function get_metadata(collection $collection): collection {
$collection->add_user_preference(
'coursesectionspreferences',
'privacy:metadata:preference:coursesectionspreferences'
);
return $collection;
}
public static function export_user_preferences(int $userid) {
// Export the reassembled coursesectionspreferences_<courseid> value(s).
}
}
Alternatively, if core is intended to own these preferences, drop the custom split-key storage so only the single coursesectionspreferences_<courseid> key (already exported by core_courseformat) is written.
public static function set_long_preference(string $name, ?string $value): void { $allpreferences = array_filter(get_user_preferences(), function ($prefname) use ($name) { return $prefname === $name || (strpos($prefname, "{$name}#") === 0); }, ARRAY_FILTER_USE_KEY); $len = ceil(core_text::strlen((string)$value) / 1300); for ($cnt = 0; $cnt < $len; $cnt++) { $pref = self::get_preference_name($name, $cnt); set_user_preference($pref, core_text::substr($value, $cnt * 1300, 1300)); unset($allpreferences[$pref]); } foreach (array_keys($allpreferences) as $pref) { unset_user_preference($pref); } }
No change required to the storage mechanism itself — the fix belongs in the Privacy provider (describe and export these preferences). Only the undeclared continuation keys written here need to be reflected in the Privacy API.
$string['privacy:metadata'] = 'The Flexible sections format plugin does not store any personal data.';
Replace the "stores no data" wording with a description of the stored section-state user preference, matching the metadata declared in the provider, e.g.:
$string['privacy:metadata:preference:coursesectionspreferences'] =
'The user preference storing which course sections the user has collapsed or expanded.';
before_activitychooserbutton_exported::callback() removes the core mod_subsection entry from the activity chooser button by using ReflectionObject to read and overwrite the protected actionlinks property of the core renderable.
This couples the plugin to a private implementation detail of core. If a future core version renames or restructures that property, getProperty('actionlinks') throws a ReflectionException (there is no property_exists() guard or try/catch), or the filtering silently stops having any effect. It is a maintainability/robustness concern rather than a defect in current behaviour.
Informational. No security impact and no user data is involved. The Reflection operates on an object constructed within the same request and does not expose or alter protected data beyond the current render. The only realistic downside is a future core change breaking the activity-chooser tweak (a visible functional regression), not a vulnerability.
The callback only acts on flexsections courses and filters out the action link whose data-modname is subsection, so the flexible-sections subsections are not confused with the core mod_subsection activity in the activity chooser. It mutates only in-memory render data scoped to the current request.
// Remove action link added by submodule. Use Reflections to set protected property $activitychooserbutton->actionlinks.
$refobject = new \ReflectionObject($activitychooserbutton);
$refproperty = $refobject->getProperty('actionlinks');
$actionlinks = $refproperty->getValue($activitychooserbutton);
$actionlinks = array_filter($actionlinks, fn($a) => ($a->attributes['data-modname'] ?? null) !== 'subsection');
$refproperty->setValue($activitychooserbutton, array_values($actionlinks));
Prefer a supported core API for removing chooser entries if one exists in the targeted Moodle versions. If Reflection must be retained, degrade gracefully instead of throwing on a future core change:
$refobject = new \ReflectionObject($activitychooserbutton);
if (!$refobject->hasProperty('actionlinks')) {
return;
}
$refproperty = $refobject->getProperty('actionlinks');
// ... existing filtering ...
Authorization is consistently enforced on every state-changing path. The AJAX stateactions (in classes/courseformat/stateactions.php) each call require_capability/require_all_capabilities and validate_sections, and resolve sections against the course's own modinfo with MUST_EXIST. The legacy GET handlers in format_flexsections::page_set_course() each require confirm_sesskey() plus the matching course capability. The permission model (including per-activity moodle/course:manageactivities checks for deletion and merge, and moodle/course:movesections for move/add-in-middle) mirrors core and is validated by dedicated PHPUnit tests.
Cross-course isolation is tested, not just assumed. test_section_hide_missing_section confirms that passing a section id belonging to a different course (or a deleted section) throws rather than modifying unrelated data, and test_section_delete_protected_activity_in_subsection confirms the recursive activity-permission checks.
Course-structure writes to core tables are appropriate for a course format. move_section() and delete_section_with_children() issue direct course_sections / course_format_options updates and deletes, but all use parameterised $DB calls and cross-DB helpers (get_in_or_equal), wrap deletion in a lock_config lock, and follow up with event dispatch, cache purging, and File-API cleanup of section summary files. This matches how core itself manages sections, for which no higher-level nested-reordering API exists; it was therefore not treated as a finding.
No bundled third-party code. There is no thirdpartylibs.xml, and none is required: the amd/build/* files are the compiled output of the plugin's own amd/src sources, and no vendored libraries are present. The plugin does not re-ship any library that Moodle core already provides.
Referer handling was hardened correctly. get_caller_page_url() uses core's get_local_referer(false), which runs the referer through clean_param(..., PARAM_LOCALURL), so the AJAX section-detection logic cannot be steered to an off-site URL.