Chapitre 1 — Sécurité Moodle pour développeur
Bienvenue dans la partie « Niveau expert ». Jusqu’ici, vous avez appris à faire fonctionner du code Moodle. À partir de maintenant, vous devez apprendre à le faire fonctionner sans ouvrir de trou de sécurité. La différence est fondamentale, et elle est ce qui sépare un développeur de plugins amateur d’un développeur digne d’être déployé sur une instance qui héberge des milliers d’apprenants, leurs notes, leurs données personnelles (RGPD) et parfois des examens à enjeux.
Ce chapitre part d’un principe : en sécurité, l’attaquant n’a besoin de gagner qu’une seule fois, vous devez gagner à chaque ligne de code. Un LMS est une cible particulièrement riche parce qu’il mélange trois ingrédients explosifs : des utilisateurs authentifiés mais malveillants (les élèves), des contenus riches saisis par des humains (HTML, fichiers, liens), et des données à forte valeur (notes, corrigés, données personnelles). Moodle fournit un arsenal d’API défensives remarquablement complet — encore faut-il savoir lesquelles appeler, quand, et pourquoi.
💡 Pour un dev React : Vous connaissez déjà la moitié de ce chapitre sans le savoir.
dangerouslySetInnerHTML, la protection CSRF de Next.js Server Actions, la validation Zod côté serveur,next-authet ses sessions… Moodle a exactement les mêmes concepts, mais avec 20 ans d’histoire et une API en PHP procédural. Là où React vous protège par défaut (JSX échappe tout automatiquement), Moodle vous protège si vous appelez la bonne fonction. Le danger est donc inversé : en React on s’expose en écrivant quelque chose de spécial ; en Moodle on s’expose en oubliant quelque chose.
Mini-sommaire
- Le modèle de menace d’un LMS
- Les cinq règles d’or du code sécurisé Moodle
require_login,require_course_login,require_capability- Sécurité des external functions (services web)
sesskey: la protection CSRF de Moodlerequired_param/optional_paramet le tableau complet desPARAM_*- DML et injection SQL : placeholders vs concaténation
- Échappement en sortie :
s(),format_string(),format_text(), trusttext - Chemins de fichiers : jamais
fopen()sur du user input - Six exemples « code vulnérable → code corrigé »
- Sessions et cookies
- En-têtes HTTP et CSP dans Moodle (l’état réel)
- Le processus de sécurité Moodle (Bugcrowd, embargo, CVE)
- Durcissement d’une instance en production : la checklist complète
- Points clés à retenir
1. Le modèle de menace d’un LMS
Avant d’écrire une seule ligne défensive, il faut savoir contre qui on se défend. Modéliser la menace, c’est répondre à trois questions : qui attaque, quoi, et pourquoi. Sur un LMS, les réponses sont différentes de celles d’un site e-commerce ou d’un blog.
1.1 Les acteurs de la menace
L’élève motivé. C’est l’attaquant le plus fréquent et le plus sous-estimé. Contrairement à un attaquant externe anonyme, l’élève possède déjà un compte authentifié et légitime. Il n’a pas besoin de forcer la porte : il est à l’intérieur. Sa motivation est concrète et immédiate : voir le corrigé d’un quiz avant de le passer, modifier sa propre note, accéder aux copies des autres, se faire passer pour un enseignant, ou simplement « voir ce qui se passe si je change ce paramètre dans l’URL ». C’est un adolescent avec du temps, une curiosité technique, et l’accès aux DevTools de son navigateur. Ne le sous-estimez jamais : la majorité des incidents réels sur les instances Moodle scolaires viennent d’élèves, pas de pirates russes.
Le compte compromis. Un enseignant réutilise son mot de passe sur un forum piraté ; un administrateur clique sur un lien de phishing. Soudain, un attaquant dispose d’un compte à privilèges élevés. Votre code ne peut pas empêcher le vol de mot de passe, mais il doit limiter les dégâts : chaque action sensible doit revérifier les capabilities dans le contexte précis, jamais se contenter de « l’utilisateur est connecté ».
L’attaquant externe non authentifié. Il scanne les pages accessibles sans login (page d’accueil, inscription, mot de passe oublié, endpoints AJAX mal protégés) à la recherche d’injections SQL, de XSS réfléchie, ou de fuites d’information. Les formulaires publics (auto-inscription, contact) sont ses points d’entrée favoris.
L’auteur de contenu malveillant. Un enseignant, un créateur de cours, ou même un élève autorisé à poster dans un forum, insère du HTML piégé : une balise <script>, un attribut onerror sur une image, une iframe vers un site de phishing. Ce contenu est ensuite stocké et servi à toutes les victimes qui consultent la page. C’est la XSS stockée, le fléau numéro un des plateformes à contenu riche.
1.2 Les biens à protéger
- Les notes et les résultats. Le cœur de la valeur d’un LMS. Une modification de note non détectée peut ruiner la crédibilité d’un établissement.
- Le contenu d’évaluation avant l’heure. Les questions et corrigés d’un quiz, les sujets d’examen. Leur fuite invalide une évaluation entière.
- Les données personnelles. Noms, e-mails, adresses, parfois données de santé ou de handicap. Le RGPD transforme une fuite en risque juridique et financier majeur.
- L’intégrité de l’identité. Se faire passer pour quelqu’un d’autre (usurpation, loginas détourné) est catastrophique dans un contexte pédagogique.
- La disponibilité. Un examen national qui tombe le jour J à cause d’un déni de service, c’est un incident de sécurité.
1.3 Les vecteurs d’attaque classiques sur Moodle
| Vecteur | Description | Défense Moodle |
|---|---|---|
| XSS stockée | HTML piégé dans un post de forum, une description de cours, un champ profil | format_text(), clean_text(), purificateur HTML |
| XSS réfléchie | Paramètre d’URL renvoyé tel quel dans la page | s(), PARAM_* |
| Injection SQL | Paramètre concaténé dans une requête | Placeholders DML (?, :name) |
| CSRF | Action mutante déclenchée depuis un site tiers | sesskey |
| IDOR | Accès à l’objet d’autrui en changeant un id dans l’URL | require_capability + validate_context |
| Traversal de chemin | ../../etc/passwd dans un nom de fichier | PARAM_FILE, get_file_storage() |
| Open redirect | Redirection vers un domaine externe malveillant | PARAM_LOCALURL |
| Upload malveillant | Fichier PHP déguisé, SVG avec script | Validation MIME, filepicker, dataroot hors webroot |
⚠️ Piège : Le modèle de menace d’un LMS n’est PAS « garder les méchants dehors ». Vos utilisateurs les plus dangereux sont déjà dedans et légitimement connectés. Un
require_login()seul ne protège de rien contre un élève : il faut TOUJOURS un contrôle de capability dans le contexte de la ressource visée. « Est-ce que tu es connecté ? » n’est jamais la bonne question ; « as-tu le droit de faire ceci sur cet objet précis ? » l’est.
📚 Aller plus loin : La bible du sujet est Security — Moodle Developer Resources . Lisez-la en entier au moins une fois. Elle est courte, dense, et chaque phrase correspond à une CVE réelle du passé.
2. Les cinq règles d’or du code sécurisé Moodle
Avant de plonger dans le détail, mémorisez ces cinq règles. Elles couvrent 95 % des vulnérabilités que l’on trouve en revue de code Moodle. Le reste du chapitre les développe une par une.
- Contrôle d’accès systématique. Toute page ou endpoint commence par
require_login()(ourequire_course_login()), puis vérifie une capability avecrequire_capability()dans le bon contexte. Pas d’exception. - Validation de toute entrée. Aucune donnée externe (URL, POST, cookie, service web) n’entre dans le code sans passer par
required_param()/optional_param()avec le typePARAM_*le plus strict possible. - Requêtes paramétrées uniquement. Jamais de concaténation de variable dans du SQL. Toujours des placeholders. Le DML de Moodle rend cela facile ; il n’y a aucune excuse.
- Échappement en sortie. Toute donnée affichée est échappée selon son contexte :
s()pour du texte simple,format_string()pour les titres,format_text()pour du contenu riche. - Protection anti-CSRF sur toute mutation. Toute action qui change l’état (créer, modifier, supprimer) vérifie le
sesskey.
Retenez le mnémonique « ACR-EC » : Accès, Contexte, Requête, Échappement, CSRF. Si ces cinq boîtes sont cochées, votre code est déjà plus sûr que 90 % des plugins de la plugins directory.
3. require_login, require_course_login, require_capability
3.1 require_login()
C’est la première ligne de presque tout script Moodle après require('config.php'). Elle vérifie que l’utilisateur est authentifié, que la session est valide, que le compte n’est pas suspendu, et qu’il a bien confirmé son adresse e-mail et accepté les politiques du site. Si l’une de ces conditions échoue, elle redirige (vers la page de login) ou lève une exception.
<?php
require(__DIR__ . '/../../config.php');
// Signature simplifiée :
// require_login($courseorid = null, $autologinguest = true, $cm = null,
// $setwantsurltome = true, $preventredirect = false);
$id = required_param('id', PARAM_INT); // id du cours
$course = get_course($id);
// Vérifie login ET inscription au cours ET disponibilité du module.
require_login($course);Le premier paramètre est crucial. Si vous passez un cours (ou son id), require_login() vérifie en plus que l’utilisateur est inscrit à ce cours (ou y a accès via un rôle). Si vous passez aussi le $cm (course module), elle vérifie la visibilité et les restrictions d’accès (dates, conditions d’achèvement) de l’activité. Utilisez toujours la forme la plus spécifique disponible.
⚠️ Piège :
require_login()sans argument vérifie seulement que l’utilisateur est connecté au site. Il ne vérifie pas l’inscription à un cours. Si votre page affiche du contenu d’un cours, vous devez passer$courseen argument, sinon n’importe quel utilisateur connecté (y compris un invité sur un autre cours) accède à vos données.
3.2 require_course_login()
Variante conçue pour les contextes où l’accès invité est légitime (cours ouverts aux visiteurs, ressources front page). Elle gère finement les cours autorisant les invités et le mode « auto-login guest ». Utilisez-la dans les modules d’activité via le pattern standard :
$cm = get_coursemodule_from_id('yourmodule', $id, 0, false, MUST_EXIST);
$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST);
$moduleinstance = $DB->get_record('yourmodule', ['id' => $cm->instance], '*', MUST_EXIST);
require_course_login($course, true, $cm);
$context = context_module::instance($cm->id);
require_capability('mod/yourmodule:view', $context);3.3 require_capability() et les contextes
Voici le cœur du modèle de sécurité Moodle. Une capability est une permission nommée (mod/quiz:attempt, moodle/course:update, mod/forum:deletepost…). Un contexte est le périmètre d’application : système, catégorie, cours, module, bloc, utilisateur. Les rôles attribuent des capabilities dans des contextes, et l’héritage descend l’arbre des contextes.
$context = context_module::instance($cm->id);
// Lève une exception (required_capability_exception) si refus. À utiliser
// quand la page entière est réservée.
require_capability('mod/yourmodule:manage', $context);
// Retourne un booléen. À utiliser pour de l'affichage conditionnel.
if (has_capability('mod/yourmodule:manage', $context)) {
echo $OUTPUT->single_button($editurl, get_string('edit'));
}
// Variante « toutes » / « au moins une ».
if (has_all_capabilities(['a', 'b'], $context)) { /* ... */ }
if (has_any_capability(['a', 'b'], $context)) { /* ... */ }Le choix du contexte est la subtilité. Vérifier mod/quiz:attempt au niveau système ne veut rien dire ; il faut le vérifier dans context_module::instance($cm->id) — le contexte de ce quiz précis. Un contrôle sur le mauvais contexte est une faille silencieuse : le code « a l’air » sécurisé mais ne protège pas la bonne ressource.
💡 Pour un dev React : Pensez au contexte Moodle comme à une hiérarchie de React Context Providers imbriqués (
<SystemProvider><CourseProvider><ModuleProvider>). Une capability « héritée » descend l’arbre comme une valeur de contexte descend les composants.has_capability($cap, $context)revient à faire unuseContext()qui remonte l’arbre jusqu’à trouver une valeur. La différence : ici l’erreur ne fait pas planter un composant, elle ouvre une faille.
📚 Aller plus loin : Access API détaille les contextes, l’héritage, les capabilities et leur déclaration dans
db/access.php.
4. Sécurité des external functions (services web)
Les external functions (l’API des services web, aussi utilisée en interne par AJAX) sont un point d’entrée massif et souvent mal protégé. Une external function reçoit des paramètres depuis l’extérieur, potentiellement via un token, potentiellement par un utilisateur avec des droits limités. Elle ne bénéficie pas automatiquement du contexte de la page appelante.
La structure obligatoire d’une external function sécurisée comporte quatre étapes dans execute() :
- Valider les paramètres avec
validate_parameters(). - Récupérer et valider le contexte avec
validate_context(). - Vérifier les capabilities dans ce contexte.
- Seulement ensuite, exécuter la logique métier.
<?php
namespace mod_yourmodule\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_value;
use core_external\external_single_structure;
class get_item extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'cmid' => new external_value(PARAM_INT, 'Course module id'),
'itemid' => new external_value(PARAM_INT, 'Item id'),
]);
}
public static function execute(int $cmid, int $itemid): array {
global $DB;
// 1. Validation des paramètres (types PARAM_*).
[
'cmid' => $cmid,
'itemid' => $itemid,
] = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'itemid' => $itemid,
]);
// 2. Récupération et validation du contexte. INDISPENSABLE :
// validate_context() appelle require_login() en interne et
// positionne le $PAGE->context. Sans cet appel, aucune
// vérification d'accès n'est faite pour l'appel service web.
$cm = get_coursemodule_from_id('yourmodule', $cmid, 0, false, MUST_EXIST);
$context = \context_module::instance($cm->id);
self::validate_context($context);
// 3. Vérification des capabilities DANS ce contexte.
require_capability('mod/yourmodule:view', $context);
// 4. Logique métier : le itemid appartient-il bien à CE module ?
$item = $DB->get_record('yourmodule_items',
['id' => $itemid, 'cmid' => $cm->id], '*', MUST_EXIST);
return ['id' => $item->id, 'name' => $item->name];
}
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'id' => new external_value(PARAM_INT, 'Item id'),
'name' => new external_value(PARAM_TEXT, 'Item name'),
]);
}
}⚠️ Piège : Ne remplacez jamais
self::validate_context($context)par un simplerequire_login().validate_context()fait plus : elle vérifie que le token du service web est autorisé pour ce contexte, positionne$PAGEcorrectement, et gère les restrictions spécifiques aux services web. Oublier cet appel est l’une des erreurs les plus fréquentes et les plus graves en revue de code de plugins — elle transforme votre endpoint en porte ouverte.
⚠️ Piège : L’étape 4 est tout aussi vitale que l’étape 3. Vérifier que l’utilisateur peut « voir des items » ne suffit pas : il faut vérifier que cet item précis appartient bien au module dont on a validé le contexte. Sinon vous avez un IDOR : l’utilisateur A demande l’
itemidde l’utilisateur B et l’obtient parce qu’il a la capability « view » sur son propre module. La clause['id' => $itemid, 'cmid' => $cm->id]referme cette faille.
💡 Pour un dev React : Une external function, c’est un Route Handler / Server Action Next.js. Et le piège est identique : dans une Server Action,
auth()vous dit qui appelle, mais c’est à vous de vérifier que l’objet demandé lui appartient. Le nombre de failles IDOR dans des apps Next.js qui fontdb.post.findUnique({ where: { id } })sansAND authorId = session.user.idest colossal. Même combat côté Moodle.
📚 Aller plus loin : External functions API et External functions API — security .
5. sesskey : la protection CSRF de Moodle
Le CSRF (Cross-Site Request Forgery) consiste à faire exécuter à la victime, à son insu, une action mutante sur un site où elle est authentifiée. Un e-mail piégé contenant <img src="https://votremoodle/action.php?delete=42"> et voilà l’utilisateur qui supprime l’objet 42 sans le savoir, avec ses propres cookies de session.
La parade de Moodle est le sesskey : un jeton aléatoire lié à la session, que tout formulaire ou lien mutant doit transmettre, et que le serveur vérifie. Un site tiers ne connaît pas ce jeton, donc ne peut pas forger la requête.
5.1 Les trois fonctions
// Génère le jeton de session courant (à injecter dans un lien/formulaire).
$key = sesskey();
// Vérifie et lève une exception si absent ou invalide. À utiliser AVANT
// toute écriture. C'est la forme recommandée.
require_sesskey();
// Variante booléenne (pour logique conditionnelle).
if (confirm_sesskey()) {
// ...
}5.2 Dans un lien GET mutant (à éviter, mais fréquent)
$deleteurl = new moodle_url('/mod/yourmodule/action.php', [
'id' => $cm->id,
'delete' => $itemid,
'sesskey' => sesskey(),
]);
echo $OUTPUT->action_link($deleteurl, get_string('delete'));Côté cible :
$delete = optional_param('delete', 0, PARAM_INT);
if ($delete) {
require_sesskey(); // Anti-CSRF.
require_capability('mod/yourmodule:delete', $context); // Anti-IDOR.
$DB->delete_records('yourmodule_items', ['id' => $delete, 'cmid' => $cm->id]);
redirect($returnurl, get_string('deleted'), null, \core\output\notification::NOTIFY_SUCCESS);
}5.3 Avec les Moodle forms (moodleform)
Bonne nouvelle : les moodleform incluent et vérifient le sesskey automatiquement. Si vous utilisez $mform->get_data(), la vérification est déjà faite. C’est une raison de plus de toujours passer par le framework de formulaires plutôt que de bricoler du <form> HTML à la main.
⚠️ Piège : Une action mutante déclenchée par un lien GET sans sesskey est une faille CSRF, même si
require_login()etrequire_capability()sont présents. Le contrôle d’accès dit « cet utilisateur a le droit » ; le sesskey dit « c’est bien cet utilisateur qui a volontairement cliqué, pas un site tiers qui a forgé la requête ». Ce sont deux protections orthogonales, jamais interchangeables.
💡 Pour un dev React : C’est le jeton anti-CSRF que
next-auth(ou les Server Actions de Next.js 14+) gèrent pour vous de façon transparente. En Moodle, ce n’est transparent que si vous utilisezmoodleform. Dès que vous écrivez un lien ou unfetchmutant à la main, vous redevenez responsable, comme si vous écriviez une route API bare-metal.
6. required_param / optional_param et les types PARAM_*
Toute donnée qui vient de l’extérieur (URL query string, corps POST, parfois cookies) doit être lue exclusivement via required_param() ou optional_param(). Ces fonctions font deux choses : elles récupèrent la valeur et la nettoient/valident selon un type PARAM_*. Accéder directement à $_GET, $_POST ou $_REQUEST dans du code Moodle est une faute grave — c’est d’ailleurs signalé par les vérificateurs de code (codechecker) et refusé en revue.
// Obligatoire : lève une exception si absent.
$id = required_param('id', PARAM_INT);
// Optionnel : valeur par défaut si absent. Le défaut DOIT être du bon type.
$page = optional_param('page', 0, PARAM_INT);
// Pour les tableaux (ex: id[]=1&id[]=2).
$ids = required_param_array('ids', PARAM_INT);
$opts = optional_param_array('opts', [], PARAM_ALPHANUMEXT);6.1 Le tableau complet des types PARAM_*
La règle d’or : choisissez toujours le type le plus restrictif qui accepte vos données légitimes. Un PARAM_INT pour un id est infiniment plus sûr qu’un PARAM_RAW « au cas où ». Chaque cran de permissivité est une surface d’attaque supplémentaire.
| Type | Ce qu’il autorise | Cas d’usage typique | Danger si mal utilisé |
|---|---|---|---|
PARAM_INT | Entier signé | ids, pages, compteurs | Aucun s’il s’agit d’un entier ; ne pas l’utiliser pour du texte |
PARAM_FLOAT | Nombre décimal (locale-aware) | notes, coefficients | — |
PARAM_BOOL | Convertit en 0/1 | cases à cocher | — |
PARAM_ALPHA | a-z, A-Z uniquement | noms de composant simples | Rejette chiffres et underscore |
PARAM_ALPHANUM | a-z, A-Z, 0-9 | codes simples | — |
PARAM_ALPHANUMEXT | alphanum + _ et - | identifiants de plugin, clés | — |
PARAM_ALPHAEXT | alpha + _ et - | noms de méthode | — |
PARAM_TEXT | Texte, retire les balises mais garde entités | titres saisis, texte simple | À ne PAS renvoyer sans s() — garde & etc. |
PARAM_NOTAGS | Retire toutes les balises HTML | texte devant rester du texte | Ne « désinfecte » pas contre XSS en sortie à lui seul |
PARAM_RAW | Aucun nettoyage | contenu HTML à traiter par format_text | DANGER : doit impérativement être nettoyé en sortie |
PARAM_RAW_TRIMMED | Raw sans espaces aux extrémités | — | idem PARAM_RAW |
PARAM_CLEANHTML | HTML nettoyé par le purificateur | champ HTML sans éditeur riche | Coûteux ; préférer trusttext + format_text |
PARAM_URL | URL valide (http, https, ftp…) | liens saisis | Accepte des URLs externes — voir open redirect |
PARAM_LOCALURL | URL locale au site uniquement | cibles de redirection | Le bon choix pour toute redirection utilisateur |
PARAM_FILE | Nom de fichier sûr (retire /, \, ..) | noms de fichiers | Empêche le traversal |
PARAM_PATH | Chemin relatif sûr (autorise / mais pas ..) | chemins internes | Toujours vérifier en plus la racine |
PARAM_SAFEDIR | Nom de répertoire sûr (alphanum + _-) | noms de dossiers | — |
PARAM_SAFEPATH | Chemin de PARAM_SAFEDIR séparés par / | arborescences sûres | — |
PARAM_EMAIL | Adresse e-mail valide | e-mails | — |
PARAM_HOST | Nom d’hôte ou IP valide | hôtes | — |
PARAM_PEM | Certificat PEM | crypto | — |
PARAM_BASE64 | Données base64 | contenu encodé | — |
PARAM_TAG / PARAM_TAGLIST | Tag(s) valides | mots-clés | — |
PARAM_COMPONENT | Nom de composant Frankenstyle valide | mod_quiz, local_x | Valide la syntaxe frankenstyle |
PARAM_PLUGIN | Nom de plugin valide | — | — |
PARAM_AREA | Identifiant d’aire (files, cache) | — | — |
PARAM_CAPABILITY | Nom de capability valide | — | — |
PARAM_PERMISSION | Valeur de permission (allow/prevent…) | — | — |
PARAM_STRINGID | Identifiant de chaîne de langue | — | — |
PARAM_TIMEZONE | Fuseau horaire valide | — | — |
PARAM_LANG | Code de langue installé | — | — |
PARAM_THEME | Nom de thème installé | — | — |
PARAM_USERNAME | Nom d’utilisateur valide (selon config) | login | — |
PARAM_SEQUENCE | Suite d’entiers séparés par virgules | listes d’ids | — |
PARAM_INTEGER | (déprécié, alias de PARAM_INT) | — | Ne plus utiliser |
PARAM_CLEAN | (déprécié, nettoyage « magique ») | — | Ne plus utiliser, imprévisible |
⚠️ Piège :
PARAM_TEXTne fait pas de vous un site à l’abri de la XSS. Il retire les balises à l’entrée, mais si vous renvoyez ensuite la valeur sanss(), un contenu comme<script>re-décodé peut poser problème selon le chemin. La règle est double : nettoyer à l’entrée (PARAM) et échapper à la sortie (s()/format_*). L’un ne dispense jamais de l’autre.
⚠️ Piège :
PARAM_RAWest parfaitement légitime — c’est le type des contenus HTML riches destinés à passer parformat_text(). Le danger n’est pasPARAM_RAWen soi, maisPARAM_RAWsuivi d’unechodirect. Si vous voyezecho required_param('x', PARAM_RAW)en revue, c’est une XSS garantie.
💡 Pour un dev React :
PARAM_*est l’équivalent d’un schéma Zod appliqué à chaque champ de requête :z.number().int()=PARAM_INT,z.string().url()=PARAM_URL,z.enum([...])≈PARAM_ALPHA. La différence philosophique : Zod rejette ce qui ne colle pas ; certainsPARAM_*(commePARAM_TEXT,PARAM_FILE) transforment/nettoient silencieusement. Sachez lequel des deux comportements s’applique — un nettoyage silencieux peut masquer un bug.
7. DML et injection SQL : placeholders vs concaténation
Le DML (Database Manipulation Layer) de Moodle abstrait la base et, correctement utilisé, rend l’injection SQL impossible grâce aux requêtes paramétrées. Deux styles de placeholders coexistent :
- Positionnels :
?, avec un tableau de valeurs indexé numériquement. - Nommés :
:name, avec un tableau associatif.
Il ne faut jamais mélanger les deux styles dans une même requête.
7.1 Les helpers sûrs (à privilégier)
La plupart des besoins ne nécessitent aucun SQL brut. Les helpers construisent la clause WHERE à partir d’un tableau associatif, échappé automatiquement :
// SELECT ... WHERE courseid = ? AND visible = ?
$records = $DB->get_records('yourmodule', ['courseid' => $courseid, 'visible' => 1]);
$one = $DB->get_record('yourmodule', ['id' => $id], '*', MUST_EXIST);
$exists = $DB->record_exists('yourmodule', ['id' => $id]);
$count = $DB->count_records('yourmodule', ['courseid' => $courseid]);
// INSERT / UPDATE / DELETE : idem, jamais de concaténation.
$id = $DB->insert_record('yourmodule', (object)['courseid' => $courseid, 'name' => $name]);
$DB->update_record('yourmodule', (object)['id' => $id, 'name' => $name]);
$DB->delete_records('yourmodule', ['id' => $id]);7.2 SQL brut avec placeholders (quand un JOIN est nécessaire)
// CORRECT : placeholders nommés, valeurs séparées.
$sql = "SELECT ym.*, u.firstname, u.lastname
FROM {yourmodule} ym
JOIN {user} u ON u.id = ym.userid
WHERE ym.courseid = :courseid
AND ym.timecreated > :since
ORDER BY ym.timecreated DESC";
$params = ['courseid' => $courseid, 'since' => $since];
$records = $DB->get_records_sql($sql, $params);Notez les accolades {yourmodule} : le DML remplace {table} par le vrai nom préfixé (mdl_yourmodule). C’est indépendant de l’échappement des valeurs.
7.3 La version VULNÉRABLE (à ne jamais écrire)
// ⛔ INJECTION SQL — NE JAMAIS FAIRE CECI
$name = required_param('name', PARAM_RAW);
$sql = "SELECT * FROM {yourmodule} WHERE name = '$name'";
$records = $DB->get_records_sql($sql); // 'x' OR '1'='1 dump toute la tableUn attaquant passe name=x' OR '1'='1 et récupère toute la table ; pire, avec '; DROP TABLE ... ou des sous-requêtes UNION, il exfiltre les hachages de mots de passe.
7.4 Le cas des listes : get_in_or_equal()
Pour un IN (...) avec un nombre variable de valeurs, ne construisez jamais la liste à la main. Utilisez le helper qui génère les placeholders :
$userids = [12, 45, 78];
[$insql, $inparams] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'uid');
$sql = "SELECT * FROM {yourmodule} WHERE userid $insql";
$records = $DB->get_records_sql($sql, $inparams);7.5 LIKE et l’échappement des jokers
Un LIKE avec du texte utilisateur doit échapper les caractères % et _, sinon l’utilisateur contrôle le motif de recherche (et peut provoquer des recherches très coûteuses). Le DML fournit sql_like() et sql_like_escape() :
$search = required_param('q', PARAM_TEXT);
$like = $DB->sql_like('name', ':search', false); // insensible à la casse
$params = ['search' => '%' . $DB->sql_like_escape($search) . '%'];
$sql = "SELECT * FROM {yourmodule} WHERE $like";
$records = $DB->get_records_sql($sql, $params);⚠️ Piège : L’échappement DML protège les valeurs, jamais les identifiants. On ne peut pas passer un nom de colonne ou de table en placeholder. Si un nom de colonne provient de l’utilisateur (ex. tri dynamique
ORDER BY $column), vous devez le valider contre une liste blanche en dur, pas le concaténer.ORDER BY+ user input est un vecteur d’injection classique que les placeholders ne couvrent PAS.
💡 Pour un dev React : C’est exactement la différence entre
prisma.$queryRaw(paramétré, sûr) etprisma.$queryRawUnsafe(concaténation, dangereux), ou entre les requêtes préparées et le template string cru enpg/mysql2. Le DML de Moodle est votre ORM : restez dansget_records()/get_records_sql($sql, $params)et vous êtes aussi protégé qu’avec un query builder typé.
📚 Aller plus loin : Data manipulation API documente tous les helpers, y compris
sql_concat,sql_compare_text, et les subtilités cross-SGBD.
8. Échappement en sortie : s(), format_string(), format_text()
La validation à l’entrée ne suffit pas : la même donnée peut être affichée dans des contextes différents (HTML, attribut, JS, URL) avec des besoins d’échappement différents. Moodle fournit trois niveaux principaux.
8.1 s() et p() — texte simple dans du HTML
s($var) retourne la chaîne avec les caractères HTML spéciaux échappés (<, >, &, "), à insérer dans du contenu HTML. p($var) fait la même chose mais affiche directement (echo). C’est l’équivalent du comportement par défaut de JSX.
$name = required_param('name', PARAM_TEXT);
echo html_writer::tag('span', s($name)); // sûr
echo '<input value="' . s($name) . '">'; // sûr dans un attribut aussi8.2 format_string() — titres et noms « plats »
Pour les titres, noms de cours, noms d’activité : du texte qui peut contenir un peu de mise en forme (multilang, filtres) mais pas de HTML de bloc. format_string() applique les filtres actifs (glossaire, multilang, LaTeX…) et échappe le reste.
echo $OUTPUT->heading(format_string($course->fullname));
echo format_string($item->name, true, ['context' => $context]);Passez toujours le context en option : certains filtres en dépendent, et l’omettre peut fausser le rendu ou l’échappement.
8.3 format_text() — contenu riche (le cœur du sujet XSS)
Pour du contenu HTML riche saisi par un utilisateur (post de forum, description, page de contenu) : format_text() applique les filtres et fait passer le HTML par le purificateur (HTML Purifier) pour retirer scripts, gestionnaires d’événements et balises dangereuses.
echo format_text($post->message, $post->messageformat, [
'context' => $context,
'filter' => true,
'noclean' => false, // NE JAMAIS mettre true sur du contenu non fiable
]);Le second paramètre, $format, indique le format du contenu stocké : FORMAT_HTML, FORMAT_MOODLE, FORMAT_PLAIN, FORMAT_MARKDOWN. Il détermine comment le texte est interprété avant purification.
8.4 trusttext et les niveaux de confiance
Certains contenus proviennent d’utilisateurs de confiance (un enseignant avec la capability moodle/site:trustcontent) qui ont légitimement besoin d’insérer du HTML avancé (iframes, scripts pour des widgets pédagogiques). Le mécanisme trusttext stocke, avec le contenu, un marqueur indiquant s’il a été saisi par un utilisateur de confiance. Le purificateur est alors assoupli pour ces contenus.
// À la sauvegarde : on capture le niveau de confiance selon la capability.
$data->messagetrust = trusttext_trusted($context);
// À l'affichage : on prépare le contenu en respectant son marqueur trust.
$text = trusttext_pre_edit($data, 'message', $context); // avant édition
// ou pour l'affichage :
echo format_text($data->message, $data->messageformat, [
'context' => $context,
'trusted' => !empty($data->messagetrust),
]);⚠️ Piège :
'noclean' => truedésactive le purificateur. C’est la porte grande ouverte à la XSS stockée. Ne l’utilisez jamais sur du contenu qui a pu être saisi par un utilisateur non pleinement fiable. Le mécanisme correct pour autoriser du HTML avancé n’est pasnoclean, c’est trusttext — qui, lui, respecte la capability de l’auteur.
8.5 clean_text() et PARAM_CLEANHTML
clean_text($text, $format) nettoie du HTML sans appliquer les filtres — utile pour assainir un contenu avant stockage quand on ne veut pas passer par format_text à l’affichage. PARAM_CLEANHTML fait la même chose au niveau du required_param. Les deux sont plus coûteux et moins souples que le duo trusttext + format_text ; réservez-les aux cas où vous ne contrôlez pas le point d’affichage.
8.6 Le tableau de décision de l’échappement
| Type de donnée à afficher | Fonction | Exemple |
|---|---|---|
| Texte brut dans du HTML | s() / p() | nom d’utilisateur, valeur d’input |
| Titre / nom « plat » (filtres OK, pas de blocs) | format_string() | nom de cours, titre d’activité |
| Contenu riche HTML | format_text() | post de forum, description |
| Contenu riche d’un auteur de confiance | format_text() + trusttext | HTML avancé enseignant |
| Attribut HTML | s() (dans html_writer de préférence) | value="...", title="..." |
| Segment d’URL | urlencode() / moodle_url | construction de liens |
| Contexte JavaScript | passer par $PAGE->requires->js_call_amd() avec args | données pour un module AMD |
💡 Pour un dev React :
s()est le comportement par défaut de{variable}en JSX (échappement automatique).format_text()avec purification est l’équivalent responsable dedangerouslySetInnerHTML— sauf que Moodle passe le HTML au purificateur avant de l’injecter, ce que React ne fait pas. Et'noclean' => trueest undangerouslySetInnerHTMLsur du contenu non filtré : le péché mortel dans les deux mondes.
📚 Aller plus loin : Output functions et la page Output API — security .
9. Chemins de fichiers : jamais fopen() sur du user input
Les LMS manipulent beaucoup de fichiers (devoirs rendus, ressources, images). La tentation d’écrire fopen($CFG->dataroot . '/' . $filename) est grande — et catastrophique. Un $filename contenant ../../config.php (ou pire) permet le path traversal : lire ou écrire n’importe où sur le disque du serveur.
Moodle interdit en pratique cette approche via son File API : les fichiers ne sont pas rangés dans une arborescence lisible par chemin, mais dans un stockage adressé par content hash, avec une couche d’abstraction (get_file_storage()) qui gère contextes, aires de fichiers, itemids et permissions.
// La BONNE manière : passer par la File API.
$fs = get_file_storage();
// Récupérer un fichier stocké dans un contexte/aire donné.
$file = $fs->get_file($context->id, 'mod_yourmodule', 'attachment', $itemid, '/', $filename);
if ($file && !$file->is_directory()) {
send_stored_file($file, 0, 0, true); // envoie avec les bons en-têtes
}Si vous devez absolument manipuler un chemin (import CLI, traitement batch), validez le nom avec PARAM_FILE et vérifiez que le chemin résolu (realpath()) reste bien sous la racine autorisée :
$name = required_param('file', PARAM_FILE); // retire /, \, ..
$base = realpath($CFG->tempdir . '/yourmodule');
$full = realpath($base . '/' . $name);
if ($full === false || strpos($full, $base . DIRECTORY_SEPARATOR) !== 0) {
throw new \moodle_exception('invalidfile');
}⚠️ Piège :
PARAM_FILEprotège contre le traversal en retirant..et les séparateurs, mais il ne vérifie pas que le fichier est légitime pour cet utilisateur. Un contrôle de capability sur le contexte du fichier reste indispensable : le nom peut être « propre » et pointer néanmoins vers le devoir d’un autre élève.
⚠️ Piège : Un upload accepté n’est pas un upload sûr. Vérifiez le type MIME réel (pas seulement l’extension), refusez les
.php/.phtml, et surtout servez les fichiers utilisateurs depuis un domaine sans exécution PHP ou viasend_stored_file()qui poseContent-Dispositionet les bons en-têtes. C’est aussi pourquoidatarootdoit être hors du webroot (voir section 14). Un SVG peut contenir du JavaScript : traitez-le comme du contenu actif.
💡 Pour un dev React : L’équivalent Node est le classique
fs.readFile(path.join(uploadDir, req.query.name))sans normalisation — la faille de traversal la plus vue en audit d’API Express. La File API de Moodle est comme un stockage objet (S3) avec des clés opaques : vous ne manipulez jamais un chemin filesystem, vous manipulez(context, component, filearea, itemid, filepath, filename).
10. Six exemples « code vulnérable → code corrigé »
Voici les six patterns que l’on retrouve le plus souvent en revue de sécurité de plugins Moodle. Étudiez-les jusqu’à les reconnaître instantanément.
10.1 Injection SQL par concaténation
// ⛔ VULNÉRABLE
$q = required_param('q', PARAM_RAW);
$sql = "SELECT * FROM {book} WHERE title LIKE '%$q%'";
$rows = $DB->get_records_sql($sql);// ✅ CORRIGÉ
$q = required_param('q', PARAM_TEXT);
$like = $DB->sql_like('title', ':q', false);
$params = ['q' => '%' . $DB->sql_like_escape($q) . '%'];
$rows = $DB->get_records_sql("SELECT * FROM {book} WHERE $like", $params);Placeholder pour la valeur, échappement des jokers LIKE, et type d’entrée resserré.
10.2 XSS réfléchie par echo direct
// ⛔ VULNÉRABLE
$name = required_param('name', PARAM_RAW);
echo "<h2>Bonjour $name</h2>"; // ?name=<script>steal()</script>// ✅ CORRIGÉ
$name = required_param('name', PARAM_TEXT);
echo $OUTPUT->heading(s($name));Type resserré à l’entrée et s() à la sortie.
10.3 CSRF sans sesskey
// ⛔ VULNÉRABLE — un <img src> suffit à déclencher la suppression
$del = optional_param('del', 0, PARAM_INT);
if ($del) {
require_capability('mod/book:edit', $context);
$DB->delete_records('book_chapters', ['id' => $del]);
}// ✅ CORRIGÉ
$del = optional_param('del', 0, PARAM_INT);
if ($del) {
require_sesskey(); // anti-CSRF
require_capability('mod/book:edit', $context); // anti-abus de droit
$DB->delete_records('book_chapters', ['id' => $del, 'bookid' => $book->id]); // anti-IDOR
}Trois protections empilées : sesskey, capability, et périmètre de l’objet.
10.4 IDOR : pas de contrôle sur l’id passé
// ⛔ VULNÉRABLE — l'utilisateur A lit la soumission de B
$submissionid = required_param('sid', PARAM_INT);
$submission = $DB->get_record('assign_submission', ['id' => $submissionid], '*', MUST_EXIST);
echo format_text($submission->onlinetext, FORMAT_HTML);// ✅ CORRIGÉ
$submissionid = required_param('sid', PARAM_INT);
$submission = $DB->get_record('assign_submission', ['id' => $submissionid], '*', MUST_EXIST);
$cm = get_coursemodule_from_instance('assign', $submission->assignment, 0, false, MUST_EXIST);
$context = context_module::instance($cm->id);
require_login($cm->course, false, $cm);
// L'utilisateur voit-il SA soumission, ou a-t-il le droit de noter ?
if ($submission->userid != $USER->id) {
require_capability('mod/assign:grade', $context);
}
echo format_text($submission->onlinetext, FORMAT_HTML, ['context' => $context]);On relie l’objet à son contexte, on charge le login/contexte, et on distingue « ma ressource » de « la ressource d’autrui, réservée à qui a le droit de noter ».
10.5 Upload non validé
// ⛔ VULNÉRABLE — on fait confiance au nom et au contenu
$file = $_FILES['userfile'];
move_uploaded_file($file['tmp_name'], $CFG->dataroot . '/uploads/' . $file['name']);// ✅ CORRIGÉ — passer par le filepicker / la File API et valider
// Dans le moodleform :
$mform->addElement('filepicker', 'userfile', get_string('file'), null, [
'maxbytes' => $maxbytes,
'accepted_types' => ['.pdf', '.docx'], // liste blanche d'extensions
]);
// Au traitement :
$data = $mform->get_data(); // vérifie le sesskey automatiquement
if ($data) {
file_save_draft_area_files(
$data->userfile, // draft itemid
$context->id,
'mod_yourmodule',
'submission',
$itemid,
['maxbytes' => $maxbytes, 'maxfiles' => 1]
);
}Le filepicker valide taille et types, la File API range le fichier hors webroot avec un nom sûr, et le sesskey est vérifié par le formulaire.
10.6 Open redirect
// ⛔ VULNÉRABLE — redirige vers n'importe quel domaine (phishing)
$url = required_param('returnurl', PARAM_URL);
redirect($url);// ✅ CORRIGÉ — n'accepte qu'une URL LOCALE au site
$url = required_param('returnurl', PARAM_LOCALURL);
redirect(new moodle_url($url));PARAM_LOCALURL rejette toute URL pointant hors de $CFG->wwwroot. Pour une robustesse maximale, on peut aussi vérifier explicitement le préfixe :
$return = optional_param('returnurl', '', PARAM_LOCALURL);
if (empty($return)) {
$return = new moodle_url('/');
}
redirect($return);⚠️ Piège :
PARAM_URL(sans « LOCAL ») acceptehttps://phishing.example. C’est le bon type pour un lien que vous affichez, jamais pour une cible de redirection. Confondre les deux est l’erreur exacte qui crée un open redirect — souvent exploité pour donner de la crédibilité à des e-mails de phishing (« le lien commence bien par votremoodle.fr… »).
11. Sécurité des sessions et des cookies
Une session compromise = un compte compromis. Moodle offre plusieurs réglages, tous dans config.php ou l’admin.
11.1 Réglages de cookies dans config.php
// Cookie de session transmis uniquement en HTTPS.
$CFG->cookiesecure = true;
// Cookie inaccessible au JavaScript (protège contre le vol par XSS).
$CFG->cookiehttponly = true;
// Attribut SameSite (limite l'envoi cross-site, renfort anti-CSRF).
// 'Lax' par défaut ; 'None' nécessite Secure (pour intégrations LTI/iframe).
$CFG->cookiesamesite = 'Lax';
// Préfixe de cookie personnalisé (utile en multi-instance sur un domaine).
$CFG->sessioncookie = 'moodleprod';
$CFG->sessioncookiepath = '/';
$CFG->sessioncookiedomain = '';cookiesecure est en pratique activé automatiquement quand le site tourne en HTTPS, mais l’expliciter en config est une bonne hygiène. cookiehttponly doit toujours être true : sans lui, une XSS peut voler le cookie de session via document.cookie.
11.2 Durée de vie et verrouillage
// Durée d'inactivité avant expiration (secondes). 2h par défaut.
$CFG->sessiontimeout = 7200;
// Avertissement affiché avant expiration (secondes).
$CFG->sessiontimeoutwarning = 1200;11.3 Régénération de session au login
Moodle régénère l’identifiant de session à chaque authentification réussie, via \core\session\manager. C’est la protection contre la session fixation (un attaquant qui impose un id de session connu avant le login). Vous n’avez rien à faire pour en bénéficier — mais si vous écrivez un plugin d’authentification, ne court-circuitez jamais ce mécanisme.
// Dans le code core, au login :
\core\session\manager::login_user($user); // régénère l'id + charge la session
// À la déconnexion :
\core\session\manager::terminate_current();Le gestionnaire de session (\core\session\manager) est aussi le point d’entrée pour le stockage (base de données par défaut, ou Redis/Memcached en production — voir le chapitre Performance) et pour le verrouillage de session, qui évite les corruptions lors de requêtes AJAX concurrentes.
💡 Pour un dev React :
cookiehttponly+cookiesecure+SameSitesont exactement les flags quenext-authpose sur son cookie de session (__Secure-next-auth.session-token,httpOnly,sameSite: 'lax'). La régénération d’id au login correspond à la rotation de session token que fait toute lib d’auth sérieuse. Moodle fait la même chose ; il faut juste ne pas la désactiver.
📚 Aller plus loin : Session handling et la classe
\core\session\manager.
12. En-têtes HTTP et CSP dans Moodle : l’état réel
C’est un point où beaucoup de tutoriels racontent des choses fausses. Voici l’état réel en Moodle 5.2.
12.1 Ce que Moodle pose nativement
Moodle envoie par défaut plusieurs en-têtes de sécurité :
X-Frame-Options(ouContent-Security-Policy: frame-ancestors) pour contrôler l’inclusion en iframe, piloté par$CFG->allowframembedding.- Des en-têtes de cache adaptés selon les pages (no-cache sur les pages dynamiques authentifiées).
- La gestion HTTPS via
$CFG->sslproxyquand Moodle est derrière un terminateur TLS.
// Autoriser l'inclusion de ce Moodle dans une iframe d'un autre site
// (nécessaire pour certaines intégrations ; désactivé par défaut = plus sûr).
$CFG->allowframembedding = false; // défaut sûr : X-Frame-Options: sameorigin
// Moodle est derrière un reverse proxy / load balancer qui termine le TLS.
// Indispensable sinon Moodle croit être en HTTP et casse les liens/cookies.
$CFG->sslproxy = true;
// Forcer wwwroot en https (les liens générés utilisent https://).
$CFG->wwwroot = 'https://moodle.example.org';12.2 Le point important : la CSP n’est PAS complète en core
Contrairement à une idée reçue, Moodle ne fournit pas de Content-Security-Policy stricte et générale en core. Le code de Moodle (et de nombreux plugins) utilise abondamment du JavaScript inline et des styles inline, ce qui rend une CSP stricte (script-src 'self') impraticable sans casser l’interface. Moodle gère frame-ancestors (via allowframembedding) mais pas une politique script-src/style-src verrouillée par défaut.
Pour déployer une vraie CSP, la solution reconnue par la communauté est le plugin tiers local_csp de Catalyst IT, qui permet de :
- démarrer en mode report-only (
Content-Security-Policy-Report-Only) pour collecter les violations sans rien casser, - analyser les rapports dans une page dédiée,
- puis basculer en mode enforce une fois la politique affinée.
C’est la démarche pragmatique : on n’active jamais une CSP stricte d’un coup sur Moodle, on l’apprend en report-only d’abord.
À défaut de plugin, on peut poser une CSP au niveau du serveur web, mais il faut alors autoriser 'unsafe-inline' (ce qui affaiblit fortement l’intérêt anti-XSS de la CSP) ou faire un gros travail de mise en conformité.
⚠️ Piège : Ne prétendez pas « Moodle a une CSP ». En core, la protection XSS repose sur la purification du HTML (
format_text), pas sur une CSP navigateur. Une CSP est une défense en profondeur complémentaire à ajouter (vialocal_cspou le serveur web), pas un acquis. Si vous voulez une CSP stricte, prévoyez un chantier report-only de plusieurs semaines.
💡 Pour un dev React : En Next.js, vous posez votre CSP dans
next.config.js(headers) ou un middleware, avec des nonces pour les scripts inline (Next.jsgénère des nonces automatiquement en mode strict). Moodle n’a pas cette intégration nonce native généralisée : c’est pour ça qu’une CSP stricte y est bien plus douloureuse à déployer que sur une app React moderne. Ne transposez pas naïvement votre config Next.js.
📚 Aller plus loin : Plugin
local_csp(Catalyst) et la doc HTTP security .
13. Le processus de sécurité Moodle
Comprendre le processus officiel vous aide à réagir correctement (côté admin) et à divulguer de façon responsable (si vous trouvez une faille).
13.1 Signaler une vulnérabilité : Bugcrowd
Depuis plusieurs années, Moodle gère la réception des vulnérabilités via un programme Bugcrowd. On ne poste jamais une faille de sécurité dans le tracker public, ni sur les forums. On passe par le formulaire de soumission officiel (relié à Bugcrowd) sur moodle.org/security/report/. Cela garantit une triage rapide et confidentielle.
13.2 Politique d’embargo et divulgation responsable
Moodle pratique la divulgation responsable : le détail d’une faille reste confidentiel tant qu’un correctif n’est pas publié. Le cycle est le suivant :
- Le chercheur signale via Bugcrowd.
- L’équipe sécurité Moodle triage, confirme, et corrige — sans rien rendre public.
- Moodle demande un identifiant CVE.
- Un correctif est intégré dans les prochaines versions de maintenance.
- Les administrateurs des sites enregistrés reçoivent une notification par e-mail (via le forum « Security announcements ») avant la divulgation publique : c’est la fenêtre d’embargo qui leur laisse le temps de mettre à jour.
- Après ce délai, l’issue devient publique (CVE + forum Security announcements), et le premier rapporteur est crédité.
13.3 Cycle des versions de sécurité
Moodle publie des versions de maintenance mensuelles qui incluent les correctifs de sécurité, en plus des versions majeures. Les versions encore en support (general support et security support) reçoivent ces correctifs ; une version en fin de vie n’en reçoit plus. D’où l’importance capitale de suivre les versions supportées : tourner sur une version obsolète, c’est accumuler des CVE publiques non corrigées.
⚠️ Piège : Si vous découvrez une faille dans Moodle ou dans un plugin populaire, ne la publiez pas sur GitHub, un blog ou Twitter avant correctif. Passez par Bugcrowd (core) ou contactez le mainteneur (plugin) en privé. Une divulgation publique prématurée met en danger des milliers d’établissements et vous exclut du crédit CVE.
⚠️ Piège : Inscrivez votre instance de production sur moodle.org et abonnez-vous au forum Security announcements. C’est le seul moyen de recevoir les alertes pendant la fenêtre d’embargo. Une instance non enregistrée découvre les failles en même temps que les attaquants, à la divulgation publique.
📚 Aller plus loin : Security procedures et Reporting a security vulnerability .
14. Durcissement d’une instance en production : la checklist complète
Le code sûr ne sert à rien sur un serveur mal configuré. Voici la checklist de durcissement d’une instance Moodle 5.2 en production, à parcourir intégralement avant toute mise en ligne.
14.1 Système de fichiers et emplacements
-
dataroothors du webroot.$CFG->dataroot(le moodledata) ne doit jamais être accessible via HTTP. S’il est sous le docroot, tous les fichiers uploadés, les sauvegardes, les sessions sont téléchargeables. Placez-le ailleurs (ex./var/moodledata), en dehors de toutDocumentRoot. -
config.phpprotégé. Il contient les identifiants de base de données. Idéalement en lecture seule pour l’utilisateur du serveur web (chmod 440, propriétaire root ou utilisateur dédié), jamais éditable par le processus PHP en production. Certaines équipes le placent hors webroot avec un stub qui l’inclut. - Droits fichiers restrictifs. Le code Moodle appartient à un utilisateur différent de celui qui exécute PHP-FPM, en lecture seule pour ce dernier. Seul
datarootest inscriptible par PHP. Cela empêche un attaquant ayant obtenu l’exécution PHP de réécrire le code (webshell persistant).
# Exemple de permissions (à adapter à votre distribution)
# Code : propriétaire deploy, lecture pour www-data, pas d'écriture web
sudo chown -R deploy:www-data /var/www/moodle
sudo find /var/www/moodle -type d -exec chmod 750 {} \;
sudo find /var/www/moodle -type f -exec chmod 640 {} \;
# config.php : encore plus strict
sudo chmod 440 /var/www/moodle/config.php
# moodledata : inscriptible par PHP, HORS webroot
sudo chown -R www-data:www-data /var/moodledata
sudo chmod -R 700 /var/moodledata14.2 Empêcher l’exécution PHP dans moodledata (nginx)
# Bloquer tout accès HTTP à moodledata même s'il est mal placé.
# (La vraie protection reste de le sortir du webroot.)
location ^~ /moodledata/ {
deny all;
return 403;
}
# Ne jamais exécuter de PHP dans les répertoires d'upload.
location ~* /(?:uploads|files)/.*\.php$ {
deny all;
}14.3 Cron : jamais par le web
- Cron web désactivé. Le cron doit tourner en CLI (
php admin/cli/cron.php) via une vraie tâche planifiée, jamais viaadmin/cron.phpexposé sur le web. Bloquez l’accès HTTP àcron.phpet forcez le mot de passe cron si un déclenchement web reste possible.
// Interdit l'exécution du cron via le navigateur sans clé.
$CFG->cronclionly = true;# crontab de l'utilisateur système, toutes les minutes
* * * * * /usr/bin/php /var/www/moodle/admin/cli/cron.php >/dev/null 2>&114.4 Restreindre l’administration
-
/adminrestreint par IP (si possible) au réseau d’administration, via le serveur web. -
loginhttps/ HTTPS forcé partout (voirsslproxyetwwwrooten https). -
preventexecpathactivé pour empêcher la configuration de chemins d’exécutables arbitraires depuis l’interface admin.
$CFG->preventexecpath = true; // pas de chemins d'exécutables via l'UI admin14.5 Politique de comptes et d’authentification
- Désactiver l’auto-inscription (
Email-based self-registration) si le site n’en a pas besoin — c’est un vecteur de spam et de comptes indésirables. Réglage : Administration du site → Plugins → Authentification. - Politique de mot de passe stricte (longueur, chiffres, majuscules, caractères spéciaux, rotation). Activée par défaut, à durcir : Administration du site → Sécurité → Politiques du site.
- MFA en core. Depuis Moodle 4.3, l’authentification multi-facteurs est intégrée au core (issue de l’ancien plugin
tool_mfade Catalyst). Activez-la : Administration du site → Plugins → Authentification → Manage multi-factor authentication. Elle fonctionne par facteurs pondérés qui doivent totaliser 100 points pour autoriser la connexion (TOTP, e-mail, plage IP, WebAuthn/FIDO2…). Rendez au minimum un facteur fort (TOTP ou WebAuthn) obligatoire pour les rôles à privilèges. - Rate limiting sur le login. Moodle intègre la protection contre le brute-force : verrouillage de compte après N tentatives (
Account lockout threshold) et le paramètre anti-attaque. Réglages : Sécurité → Politiques du site (lockoutthreshold,lockoutwindow,lockoutduration). Complétez par un rate limiting au niveau du reverse proxy / WAF.
// Durcissements courants (peuvent aussi se régler via l'UI) :
$CFG->passwordpolicy = true;
$CFG->minpasswordlength = 12;
$CFG->lockoutthreshold = 10; // tentatives avant verrouillage
$CFG->lockoutwindow = 1800; // fenêtre d'observation (s)
$CFG->lockoutduration = 1800; // durée du verrouillage (s)14.6 Réseau et surface d’exposition
- Ports fermés. Seuls 80/443 exposés publiquement ; base de données, Redis, PHP-FPM (9000), etc. sur réseau privé uniquement. Aucun accès direct à Redis depuis Internet (Redis n’a pas d’auth robuste par défaut).
- Cookies sécurisés :
cookiesecure,cookiehttponly,cookiesamesite(section 11). -
sslproxy = truesi derrière un LB terminant le TLS.
14.7 Le rapport de sécurité intégré
Moodle fournit une page d’audit de sécurité intégrée : Administration du site → Rapports → Vérification de la sécurité (Security overview / admin/tool/securityoverview et le report report/security). Elle vérifie automatiquement une longue liste de points : cron web ouvert, inscription invités, politique de mot de passe faible, permissions par défaut dangereuses, présence du fichier de test testing, HTTPS, etc. Parcourez-la après chaque changement de configuration et visez zéro « problème critique ».
- Rapport de sécurité au vert. Aucun item en rouge (critique). Les warnings doivent être justifiés et documentés.
- Supprimer les fichiers de dev/test exposés (installeur,
phpinfo, scripts de debug). -
$CFG->debugen production réglé surNONE(jamaisDEVELOPER), et$CFG->debugdisplay = 0pour ne pas fuiter de stack traces aux visiteurs.
// Production : pas de fuite d'information via les erreurs.
$CFG->debug = 0; // (E_NONE ; DEVELOPER = 32767 réservé au dev)
$CFG->debugdisplay = 0; // les erreurs vont dans les logs, pas à l'écran⚠️ Piège :
$CFG->debugdisplay = 1avecdebug = DEVELOPERen production est une fuite d’information massive : chemins serveur, requêtes SQL, structure de la base apparaissent dans les messages d’erreur affichés à n’importe quel visiteur. C’est l’un des premiers items que le rapport de sécurité signale — et l’un des plus fréquemment ignorés.
💡 Pour un dev React :
debugdisplay = 0en prod est l’équivalent exact deNODE_ENV=productionqui masque les stack traces React côté client et n’expose pas les erreurs serveur détaillées. Vous ne déployez jamais un build Next.js en mode dev ; ne déployez jamais un Moodle en mode DEVELOPER.
📚 Aller plus loin : Security recommendations , Security overview report , et Multi-factor authentication .
15. Points clés à retenir
- Le modèle de menace d’un LMS est particulier : vos attaquants les plus fréquents (les élèves) sont déjà authentifiés. « Es-tu connecté ? » n’est jamais la bonne question ; « as-tu le droit de faire ceci sur cet objet ? » l’est.
- Cinq règles d’or (ACR-EC) : contrôle d’Accès (
require_login+require_capability), validation d’entrée (PARAM_*), requêtes paramétrées (R), Échappement en sortie (s/format_string/format_text), et anti-CSRF (sesskey). Chaque faille classique correspond à l’oubli d’une de ces boîtes. - Les external functions exigent le quatuor
validate_parameters→validate_context→require_capability→ vérification d’appartenance de l’objet. Oubliervalidate_context()ou le contrôle d’appartenance ouvre respectivement un accès non authentifié et un IDOR. - Le DML paramétré rend l’injection SQL impossible — à condition de ne jamais concaténer, et de valider en liste blanche les identifiants (colonnes,
ORDER BY) que les placeholders ne couvrent pas. - La défense XSS de Moodle repose sur la purification HTML (
format_text), pas sur une CSP navigateur : la CSP stricte n’est pas native et se déploie vialocal_cspen mode report-only d’abord.noclean => trueest le péché mortel. - Le processus de sécurité passe par Bugcrowd, un embargo, un CVE et le forum Security announcements : divulgation responsable obligatoire, et inscription de son instance pour recevoir les alertes.
- Le durcissement prod est indissociable du code :
dataroot/config.phphors ou protégés du webroot, cron en CLI (cronclionly), MFA (core depuis 4.3), politique de mots de passe, verrouillage anti-brute-force,debugdisplay = 0, et le rapport de sécurité intégré au vert.
Vous savez désormais écrire du code Moodle qui résiste à un élève motivé et durcir l’instance qui l’héberge. Mais un site sûr qui s’effondre sous la charge le jour d’un examen national est tout aussi inutilisable qu’un site piraté. Le prochain chapitre attaque l’autre grand pilier de l’expertise : la performance et la montée en charge — comment mesurer avant d’optimiser, architecturer une instance qui encaisse des dizaines de milliers d’utilisateurs concurrents, et survivre au redoutable « pic d’examen ».
Rendez-vous au Chapitre 2 — Performance et montée en charge.