Multi-Language Content (v2)
tiny_multilang2
TinyMCE 6 subplugin (tiny_multilang2) that adds a globe toolbar button, a Format menu entry and a context toolbar for inserting Moodle multi-language content markers into editor text. Depending on whether the filter_multilang2 filter is active in the context, it emits either {mlang xx} filter tags or standard <span class="multilang" lang="xx"> markup, and it visually highlights the markers while editing. Almost all logic is client-side (AMD/TinyMCE); the PHP layer only supplies admin settings, a capability gate and the language list.
The plugin is a well-structured, predominantly client-side TinyMCE subplugin with a small and correct server-side surface. Access is gated by an appropriate has_capability('tiny/multilang2:use', $context) check surfaced through the editor's is_enabled() hook (enforced by core's editor_tiny manager), the Privacy API is implemented correctly as a null_provider (no personal data is stored), and there is no direct database, filesystem, HTTP, SQL or superglobal access anywhere in the code.
All administrative settings that use PARAM_RAW (highlight_css, languageoptions) are admin-only and, where relevant, validated against the installed language list before use. All editor content is saved and later rendered through Moodle's normal format_text() sanitisation and the multilang2 filter, so the plugin's heavy client-side HTML manipulation does not create a stored-XSS path to other users.
Only two low-severity, non-security issues were found: production code unconditionally loads Behat test infrastructure on every editor render (a performance/fragility concern), and a couple of context-toolbar handlers depend on the deprecated window.event global. Neither is exploitable; both are code-quality matters. With no security findings and only minor technical-debt items, the plugin sits at the top of the quality range.
Overview
tiny_multilang2 is a TinyMCE 6 editor subplugin that helps authors mark up multilingual content. It adds a language button, a Format menu entry and a context toolbar; selecting a language wraps the selection (or inserts a placeholder) in multi-language markers. On save it converts the visual markers to either {mlang xx} filter tags (when the filter_multilang2 filter is active) or standard <span class="multilang" lang="xx"> markup, and it reverses the conversion on load.
Architecture
- PHP layer (
classes/plugininfo.php,settings.php,db/access.php,classes/privacy/provider.php): admin settings, atiny/multilang2:usecapability gate, and construction of the language list passed to the editor. No custom pages, endpoints, DB tables or file I/O. - JavaScript layer (
amd/src/*.js): all editor behaviour — a hand-rolled HTML parser (htmlparser.js), marker insertion/removal and highlighting (ui.js), and TinyMCE registration (commands.js,configuration.js,options.js).
Security assessment
The security posture is strong:
- Correct capability enforcement via
is_enabled()→has_capability(). - Privacy API implemented as a
null_provider(nothing stored). - No direct database, filesystem, HTTP, raw SQL, or PHP superglobal usage.
PARAM_RAWadmin settings are admin-only and validated where relevant.- Editor output is sanitised by core
format_text()on display, so client-side HTML construction is not a stored-XSS vector against other users.
Findings
Two low-severity code-quality issues only:
classes/plugininfo.phploads Moodle's Behat test infrastructure with an unconditional top-levelrequire_once, executed on every page that renders a Tiny editor.- Two context-toolbar
onActionhandlers inamd/src/commands.jsrely on the deprecated globalwindow.event, and the remove button is registered redundantly inside a loop.
No high or critical issues were identified.
Findings
classes/plugininfo.php includes Moodle's Behat testing utility at file top level with an unconditional require_once. This class is autoloaded and its static methods (is_enabled(), get_plugin_configuration_for_context()) are invoked by the editor_tiny manager for every page that renders a TinyMCE editor, so the require_once executes on essentially every editing form across the whole site.
The include exists only so that is_multilang2_simulated_for_test() can call \behat_util::is_test_site(). behat_util is a test-only class; pulling it in drags a chain of testing/CLI libraries into normal request handling: lib/behat/lib.php, lib/testing/lib.php, behat_command.php, behat_config_manager.php, clilib.php, csslib.php and filelib.php.
Moodle already exposes a lightweight, supported way to detect a Behat run — the BEHAT_SITE_RUNNING constant defined in lib/setup.php — which requires no includes.
Low risk. No security impact, and the feature works correctly on standard installations. The concerns are:
- Performance — a chain of test/CLI libraries is parsed and loaded on every editor render site-wide, which is pure overhead in production.
- Fragility — the hard-coded five-level relative path reaches deep into core's Behat internals; any future re-organisation of those files silently breaks the plugin.
- Availability in hardened deployments — sites that remove
tests//behat/directories to reduce attack surface will hit a fatal error on every page containing a Tiny editor.
Because it is not exploitable and functions on default installs, this is a code-quality issue rather than a security one.
get_plugin_configuration_for_context() calls self::is_multilang2_simulated_for_test(), which calls \behat_util::is_test_site(). On a normal production site is_test_site() returns false after a single file_exists() check for the dataroot behattestdir.txt marker, so the feature behaves correctly — the cost is purely the unconditional loading of the test infrastructure needed to reach that method. The editor_tiny manager (lib/editor/tiny/classes/manager.php) iterates all enabled sub-plugins and calls is_enabled() on each, which is what triggers autoloading of this class file and therefore the top-level require_once.
require_once(__DIR__ . '/../../../../../behat/classes/util.php');
Detect a Behat run with the core BEHAT_SITE_RUNNING constant instead of loading behat_util. Remove the top-level require_once and gate the check on the constant, which is defined in lib/setup.php during Behat runs and needs no includes:
public static function is_multilang2_simulated_for_test(): bool {
if (!defined('BEHAT_SITE_RUNNING') || !BEHAT_SITE_RUNNING) {
return false;
}
return (bool)get_config('tiny_multilang2', 'simulatemultilang2');
}
This keeps all test/CLI libraries out of production request handling and removes the fragile deep relative path into core internals.
In amd/src/commands.js, two context-toolbar button onAction callbacks pass a bare event identifier to onDelete() and applyLanguage(). These callbacks are invoked by TinyMCE with an editor-API argument, not a DOM event, so event is an undeclared identifier that resolves to the non-standard, deprecated window.event global.
applyLanguage() and onDelete() then use event.target (e.g. to call hideContentToolbar(event.target)), so correct behaviour depends entirely on the browser populating window.event at call time. This works in current browsers but is unreliable and may break under stricter execution environments or future browser changes.
Separately, editor.ui.registry.addButton(component + '_remove', ...) is placed inside the for (const lang of languageList) loop, so the same remove button is re-registered once per language. The final registration wins, so it is harmless, but the registration belongs outside the loop.
Low risk. This is a robustness/maintainability defect, not a security issue. It relies on the deprecated window.event global, which still works in current browsers but is not guaranteed; if window.event is absent or stale, event.target becomes undefined and the toolbar-hide/tag-removal logic misbehaves. The redundant in-loop button registration is purely cosmetic. No user data or privilege boundary is involved.
These buttons make up the per-tag context toolbar shown when the cursor is on a highlighted language marker (registered only when showalllangs is off or addlanguage is on). When a button is clicked, TinyMCE calls the arrow-function onAction with its button API object; the code ignores that and reads the ambient window.event, which browsers set to the in-flight click event during synchronous dispatch. hideContentToolbar(event.target) then walks up from the clicked node to hide the tox-pop container.
editor.ui.registry.addButton(component + '_remove', {
icon: 'remove',
tooltip: removeTag,
onAction: () => {
onDelete(editor, event);
}
});
Move this registration out of the for (const lang of languageList) loop so the button is registered once. TinyMCE's context-toolbar onAction does not receive a DOM event; refactor onDelete() so it does not depend on window.event/event.target (for the trash icon it can locate the currently selected marker via editor.selection rather than the click target).
editor.ui.registry.addButton(component + '_' + lang.iso, {
text: lang.iso,
tooltip: lang.label,
onAction: () => {
applyLanguage(editor, lang.iso, event);
}
});
Do not reference the implicit global event here. Pass an explicit value the handler controls, or change applyLanguage() to resolve the toolbar node from the editor state instead of event.target, so behaviour no longer depends on the deprecated window.event.
No stored-XSS exposure despite heavy client-side HTML manipulation. The plugin builds markup by string concatenation and a hand-rolled regex HTML parser (amd/src/htmlparser.js) and injects it with insertAdjacentHTML, setOuterHTML, insertContent and setContent. User-typed {mlang xx} values do flow into these sinks, but only within the author's own editor session; the saved content is later rendered through Moodle's format_text()/HTML Purifier and the multilang2 filter, which neutralise any injected markup before it reaches other users. The regex-based parser is inherently more fragile than a DOM-based approach, but it is covered by a Jest suite (tests/js/htmlparser.test.js) exercising comment, <script>, SVG and MathML edge cases.
Admin-only PARAM_RAW settings are acceptable in context. highlight_css is injected into the editor iframe via ed.dom.addStyle(), and languageoptions feeds the language menu. Both are writable only by users with site configuration rights, and languageoptions entries are additionally validated against get_string_manager()->get_list_of_languages() in get_language_options() before use, so neither constitutes a privilege-escalation or injection path beyond what an administrator can already do.
Minor version-metadata inconsistency. version.php declares $plugin->release = '1.9' while the README.md badge still advertises Release 1.8.1. Cosmetic only, but worth aligning for release hygiene.