Whiteboard
mod_whiteboard
Activity module that embeds (or links to) boards hosted by external online whiteboard services such as Miro, Collaboard, Conceptboard and TaskCards. Site administrators manage an allow-list of services (link prefixes, extra URL parameters, embed vs. new-tab display, JSON import/export); teachers paste a board link or embed code per activity and can assign separate boards to individual groups and users.
No security vulnerabilities were found. Every entry point has the access controls it needs:
view.phpusesrequire_course_login()and checksmod/whiteboard:view.board.phpandboards.phpuserequire_login()and checkmod/whiteboard:manageboards. Separate-groups scoping is enforced inboards::get_managed(), and board owners are validated against the server-computed choices.services.php,service.phpandimport.phpgo throughadmin_externalpage_setup()for a page registered withmoodle/site:config.- State changes use
moodleformor explicitrequire_sesskey()/confirm_sesskey().
The key risk in a plugin that embeds teacher-supplied URLs in an iframe is handled carefully. Board links must be https://, must survive PARAM_URL cleaning unchanged, and must match an admin-approved service prefix on exact origin and a path prefix starting with /. They are re-validated at render time, so restored or legacy data is also checked. Output goes through Mustache auto-escaping or s()/format_string().
The only findings are two low-severity maintainability and performance issues: per-row enrolment and capability checks on the boards page, and an upgrade step that calls live plugin class code. The plugin also has a complete Privacy API provider, backup/restore with user-data handling, course reset support, event observers for cleanup, and a good test suite.
mod_whiteboard is a small, well-built activity module (about 25 PHP files plus two templates). It shows boards from external whiteboard services, either in an iframe or as a new-tab link. Administrators keep an allow-list of services. Teachers choose a board link per activity, and optionally separate boards per group or per user.
The security posture is strong. Teacher-supplied links can only reach the iframe if they are https://, unchanged by PARAM_URL cleaning, and match an admin-approved origin and path prefix. They are checked again every time the page is shown, so restored backups and upgraded legacy data cannot get around the allow-list. Every page has the right login and capability checks, separate-groups restrictions are applied when teachers manage boards, CSRF protection is in place, and all output is escaped correctly. No raw SQL concatenation, outbound HTTP requests, file handling or third-party code was found.
The two findings are both low severity. The boards management page runs per-user enrolment and capability checks for each listed board, which will slow down in large courses with many user boards. The upgrade step also calls live plugin class methods, so future changes to those methods would silently change what that historical upgrade step does. Neither needs urgent action.
Findings
boards::get_list() calls is_enrolled($cm->context, $board->userid, 'mod/whiteboard:view', true) once for every user board. For any user other than the current one, core's is_enrolled() runs enrol_get_enrolment_end() and then has_capability() for that user, which loads that user's access data. The boards page (boards.php) therefore runs a few queries per user board. boards::get_managed() also builds this whole list just to check one board, so opening the edit form or a delete confirmation costs the same. In a large course with hundreds of individually assigned boards, these pages get slow and put load on the database.
Low risk. This is only a performance issue. Only users with the manage-boards capability can reach these pages, and the cost only becomes noticeable when a course has many per-user boards.
boards.php lists group or user boards for teachers with mod/whiteboard:manageboards. board.php and the delete branch of boards.php call get_managed(), which uses get_list() to check that the teacher may manage the requested board.
foreach ($boards as $board) {
$board->name = fullname($board);
$board->active = is_enrolled($cm->context, $board->userid, 'mod/whiteboard:view', true);
}
Fetch the set of active, capable users once and look them up in memory:
$active = get_enrolled_users($cm->context, 'mod/whiteboard:view', 0, 'u.id', null, 0, 0, true);
foreach ($boards as $board) {
$board->name = fullname($board);
$board->active = isset($active[$board->userid]);
}
For very large courses, join get_enrolled_sql() into the main query instead.
$list = self::get_list($cm, self::get_type($board));
if (!isset($list[$id])) {
Check access for the single board directly: for a group board, check the group is in get_groups($cm) or the user can access all groups; for a user board, check membership of an allowed group. Do this instead of building the full list.
The 2026102800 savepoint in db/upgrade.php seeds the service table from \mod_whiteboard\service::defaults() and converts legacy data with \mod_whiteboard\service::legacy_board_url(). Moodle's upgrade API guidance says upgrade steps should not depend on component API code that may change later, because the step must do exactly what it did when it was written. If a later release adds, removes or renames a default service, or changes how legacy IDs become URLs, sites upgrading directly from 1.0 will silently get a different result from this historical step than sites that upgraded in order. The same defaults() call in db/install.php is fine, because install code is meant to reflect the current state.
Low risk. This only affects maintainability. Today the code behaves correctly, but future edits to the service class could quietly change what this historical upgrade step does.
This savepoint moves sites from the 1.0 schema (boardtype/boardid columns and Miro/Conceptboard enable settings) to the service-based model. It creates whiteboard_service, seeds it, and rewrites each activity's board into a boardurl.
foreach (\mod_whiteboard\service::defaults() as $service) {
if (!$DB->record_exists('whiteboard_service', ['name' => $service['name']])) {
Copy the four default service records into the upgrade step as a literal array, so the step stays fixed even if service::defaults() changes later.
\mod_whiteboard\service::legacy_board_url($whiteboard->boardtype, $whiteboard->boardid),
Move the conversion into a private helper function in db/upgrade.php, or into db/upgradelib.php, that is used only by the upgrade. This keeps the historical behaviour fixed. The restore step can keep using the class method.
URL allow-list design is sound. service::find() requires https://, a length limit, and clean_param(PARAM_URL) returning the input unchanged. split_link() compares the lower-cased origin up to the first / after the scheme, and the path prefix after it. This defeats user@host, look-alike subdomain and javascript: tricks, and the plugin's own tests cover these cases. The check runs again at render time in view.php, so restored backups and upgraded legacy data cannot bypass it.
Escaping in forms is correct. Option labels from boards::get_choices() are pre-escaped with s()/format_string(). This is correct because the core autocomplete/select templates output option text with triple braces ({{{text}}}). The activity title is built with format_string(..., ['escape' => false]) and then escaped by Mustache's {{title}}, so it is not double-encoded and not left unescaped.
Iframe permissions. Embedded boards get allow="fullscreen; clipboard-read; clipboard-write" and referrerpolicy="strict-origin-when-cross-origin". Only administrator-approved origins can be embedded, so this is reasonable. Administrators should still only add services they trust with clipboard access.
Visible groups mode. In visible groups mode, students can pick any group in the group selector and see that group's board link. This matches Moodle's normal visible-groups behaviour rather than being a leak. Teachers who need group boards kept private should use separate groups.