07 — Cache (MUC), sessions et sécurité côté code
Partie 05 — Architecture de Moodle · Chapitre 7 Version de référence : Moodle 5.2 (PHP 8.3+)
Ce chapitre clôt la partie architecture avec deux sujets que tout développeur Moodle manipule quotidiennement, souvent sans les avoir jamais appris formellement : le cache universel de Moodle (MUC) — l’équivalent d’un Redis/React Query intégré au framework — et la sécurité côté code : contextes, authentification, capabilities, CSRF, nettoyage des entrées et échappement des sorties. La seconde moitié du chapitre est volontairement très concrète : elle se termine par un pattern de page sécurisée complet, commenté ligne par ligne, qui synthétise tout ce que vous avez appris dans cette partie.
Mini-sommaire
Partie A — Cache et sessions
- Architecture du MUC : définitions, stores, loaders
- Déclarer une définition de cache :
db/caches.php - Utiliser le cache :
cache::make()et le pattern get-or-compute - Invalidation : purges, événements, clés versionnées
- Les sessions :
$SESSION, stockage, verrouillage
Partie B — Sécurité côté code
- Les contextes : la colonne vertébrale de la sécurité
- Authentification :
require_login()et les constantes de page - Capabilities côté code :
has_capability()et compagnie - CSRF : le sesskey
- Nettoyage des entrées :
required_param()et les typesPARAM_* - Sortie et XSS :
s(),format_string(),format_text() - Le pattern de page sécurisée complet
- Checklist sécurité finale et outillage
PARTIE A — MUC (Moodle Universal Cache) et sessions
1. Architecture du MUC : définitions, stores, loaders
Le MUC (Moodle Universal Cache) existe depuis Moodle 2.4 et constitue l’unique API de cache que votre code doit utiliser. Son idée fondatrice est une séparation stricte entre deux responsabilités :
- « Quoi cacher » : c’est la définition (cache definition), déclarée par le
développeur dans le code de son plugin (
db/caches.php). Elle décrit la nature des données : leur durée de vie logique, leur portée (globale, par utilisateur, par requête), leur forme (scalaires simples ou objets complexes), et comment elles s’invalident. - « Où le stocker » : c’est le store (cache store), configuré par l’administrateur du site dans Administration du site > Plugins > Mise en cache > Configuration. Fichiers sur disque, Redis, Memcached, APCu, mémoire statique PHP…
En tant que développeur, vous ne savez jamais où vos données sont physiquement
stockées, et c’est voulu. Vous déclarez un contrat (« ces données sont partagées entre
tous les utilisateurs, ce sont des tableaux simples, elles s’invalident quand un cours
change »), et l’admin décide de la meilleure implémentation pour son infrastructure. Un
petit Moodle mono-serveur utilisera le store file par défaut ; une grosse installation
en cluster mappera les définitions critiques sur Redis.
Entre les deux se trouve le loader : l’objet que vous obtenez via cache::make() et
qui expose l’API get/set/delete. Le loader peut empiler plusieurs couches (par
exemple une couche d’accélération statique en mémoire PHP au-dessus du store réel — nous
y reviendrons).
💡 Pour un dev React : le MUC, c’est React Query + Redis fusionnés dans le framework. La définition joue le rôle de la configuration d’une query (
staleTime,queryKeyscheme, scope), le store est le backend physique (comme choisir entre le cache mémoire de React Query et un persister IndexedDB/Redis), et le loader est lequeryClient. L’invalidation par événement (invalidationevents) est l’analogue direct dequeryClient.invalidateQueries({ queryKey: [...] }). La grande différence : l’utilisateur final (l’admin) peut re-router n’importe quelle « query » vers n’importe quel backend sans toucher au code.
1.1 Les trois modes de cache
Chaque définition déclare exactement un mode, qui détermine la portée des données :
| Mode | Constante | Portée | Analogie |
|---|---|---|---|
| Application | cache_store::MODE_APPLICATION | Partagé entre tous les utilisateurs et toutes les requêtes | Redis partagé, cache CDN |
| Session | cache_store::MODE_SESSION | Par utilisateur, survit d’une requête à l’autre | sessionStorage, state serveur par session |
| Request | cache_store::MODE_REQUEST | Durée d’une seule requête PHP | Memoization, useMemo, React.cache() |
- Application : le mode le plus courant. Chaînes de langue compilées, structure des cours, configuration des plugins… Tout ce qui est coûteux à calculer et identique pour tout le monde.
- Session : données propres à un utilisateur qui doivent persister entre les pages
sans encombrer
$SESSION(dont nous parlerons en §5). Exemple core : le cachecalendar_subscriptions. - Request : de la pure memoization. Les données disparaissent à la fin de la requête PHP. Utile pour éviter de recalculer dix fois la même chose dans un seul rendu de page, quand une variable statique locale serait trop rigide.
1.2 Les stores disponibles
Les stores sont eux-mêmes des plugins (type cachestore), dans cache/stores/ :
file: le store par défaut. Fichiers sérialisés dans$CFG->dataroot/cache. Zéro configuration, correct pour un site modeste, mais des I/O disque à chaque hit.redis: le store recommandé en production. Rapide, partageable entre les nœuds d’un cluster, supporte le mode session. Depuis Moodle 4.x, il gère la compression et la sérialisation igbinary.memcached: historique, toujours supporté, mais Redis lui est généralement préféré (Memcached ne convient pas au cache de session car il peut évincer des données arbitrairement).apcu: mémoire partagée locale au processus PHP. Extrêmement rapide mais local à chaque serveur web — dangereux en cluster pour des données qui doivent être cohérentes, parfait pour des données re-calculables localement (voircanuselocalstoreen §2).static: mémoire PHP de la requête courante. C’est le store qui sert de base au moderequest.
L’admin peut créer plusieurs instances de store (par exemple deux serveurs Redis distincts) puis mapper chaque définition sur l’instance de son choix, dans Plugins > Mise en cache > Configuration. Il peut aussi changer les mappings par défaut de chaque mode. Le MUC vérifie la compatibilité : une définition qui exige la garantie de persistance dans la requête ne pourra pas être mappée sur un store qui ne l’offre pas.
📚 Aller plus loin : la documentation développeur du MUC — moodledev.io/docs/5.2/apis/subsystems/muc — détaille le contrat des stores et le fonctionnement interne des loaders.
⚠️ Piège : depuis Moodle 4.4, les classes du MUC ont été déplacées dans le namespace
\core_cache(\core_cache\cache,\core_cache\store,\core_cache\helper…), comme les contextes l’ont été vers\core\contexten 4.2. Les anciens noms globaux (cache,cache_store,cache_helper) restent des alias parfaitement valides en 5.2 et sont encore massivement présents dans le code core et les exemples en ligne. Pour du code neuf, préférez les noms namespacés ; mais ne soyez pas surpris de croiser les deux formes.
2. Déclarer une définition de cache : db/caches.php
Un plugin déclare ses définitions dans le fichier db/caches.php, sous la forme d’un
tableau $definitions. Chaque clé du tableau est le nom de la définition (l’« area »),
chaque valeur un tableau d’options. Exemple complet et commenté pour un plugin
hypothétique local_catalogue :
<?php
// local/catalogue/db/caches.php
defined('MOODLE_INTERNAL') || die();
$definitions = [
// Définition n°1 : les fiches du catalogue, partagées entre tous les
// utilisateurs. Nom complet côté API : cache::make('local_catalogue', 'fiches').
'fiches' => [
// OBLIGATOIRE. Le mode : APPLICATION, SESSION ou REQUEST.
'mode' => cache_store::MODE_APPLICATION,
// Nos clés sont des entiers ou des chaînes simples ([a-zA-Z0-9_]).
// Le MUC peut alors les utiliser telles quelles sans les hasher :
// gain de perfs ET clés lisibles dans redis-cli / sur le disque.
'simplekeys' => true,
// Nos valeurs sont des scalaires ou des tableaux de scalaires
// (PAS d'objets). Le MUC peut alors éviter la sérialisation coûteuse
// sur certains stores, et surtout éviter le clonage défensif des
// données servies depuis l'accélération statique.
'simpledata' => true,
// Accélération statique : le loader garde en mémoire PHP (pour la
// durée de la requête) les entrées déjà lues ou écrites. Une donnée
// demandée 15 fois dans la même page ne touchera le store qu'une
// seule fois. C'est une couche "request" gratuite AU-DESSUS du store.
'staticacceleration' => true,
// Nombre max d'entrées conservées dans l'accélération statique
// (éviction FIFO au-delà). Toujours le définir quand on active
// staticacceleration, sinon la mémoire PHP peut gonfler.
'staticaccelerationsize' => 30,
// Événements d'invalidation : quand un code appelle
// cache_helper::purge_by_event('local_catalogue_fiches_changed'),
// toutes les entrées de cette définition sont invalidées.
'invalidationevents' => [
'local_catalogue_fiches_changed',
],
// En cluster : autorise l'admin à mapper cette définition sur un
// store LOCAL à chaque nœud (ex. APCu) plutôt que partagé. À ne
// mettre à true que si une incohérence temporaire entre nœuds est
// acceptable (données recalculables, purge par version...).
'canuselocalstore' => true,
],
// Définition n°2 : préférences d'affichage du catalogue, par utilisateur.
'userprefs' => [
'mode' => cache_store::MODE_SESSION,
'simplekeys' => true,
'simpledata' => true,
// TTL en secondes. DÉCONSEILLÉ par la documentation : le TTL force
// le MUC à stocker un horodatage avec chaque entrée et à le vérifier
// à chaque lecture, ce qui dégrade les perfs sur tous les stores.
// Préférez toujours une invalidation explicite ou des clés
// versionnées. Il existe néanmoins des cas légitimes (données
// externes qu'on ne peut pas invalider soi-même, ex. réponse d'API).
'ttl' => 1800,
],
// Définition n°3 : memoization pure pendant la requête.
'resolvedpaths' => [
'mode' => cache_store::MODE_REQUEST,
'simplekeys' => true,
// simpledata false (défaut) : on stocke des objets, le MUC les
// clonera si nécessaire pour éviter les mutations partagées.
],
];Points importants :
modeest la seule clé obligatoire. Tout le reste a des valeurs par défaut prudentes (simplekeys => false,simpledata => false, pas d’accélération statique).simplekeysetsimpledatasont des promesses, pas des options cosmétiques. Si vous déclarezsimplekeys => truepuis passez une clé contenant un/, le MUC lèvera une exception en mode debug. Si vous déclarezsimpledata => truepuis stockez un objet, vous récupérerez des comportements indéfinis (références partagées via l’accélération statique, notamment).- Après toute modification de
db/caches.php, il faut purger les caches (ou incrémenter la version du plugin) pour que Moodle relise le fichier : les définitions elles-mêmes sont… mises en cache. C’est le grand classique du « ma nouvelle définition n’existe pas » :php admin/cli/purge_caches.phpet c’est réglé.
⚠️ Piège :
staticaccelerationsanssimpledata => truea un coût caché — le loader clone les objets à chaqueget()pour empêcher qu’une mutation de l’objet retourné ne corrompe la copie en cache (le même bug que muter directement un state Redux). Si vos données sont des tableaux de scalaires, déclarezsimpledata => truepour court-circuiter ce clonage. Et inversement : si vous stockez des objets sans accélération statique, chaqueget()désérialise une copie fraîche, donc pas de problème de partage — mais unget()répété devient coûteux.
3. Utiliser le cache : cache::make() et le pattern get-or-compute
3.1 Obtenir un loader et l’API de base
// Obtenir le loader pour la définition 'fiches' du plugin local_catalogue.
// L'appel est peu coûteux et peut être répété : les loaders sont eux-mêmes
// réutilisés en interne. Forme moderne (5.2) :
$cache = \core_cache\cache::make('local_catalogue', 'fiches');
// Forme legacy, strictement équivalente, encore partout dans le core :
$cache = \cache::make('local_catalogue', 'fiches');
// Écrire. La valeur doit être sérialisable (pas de ressource, pas de
// closure, pas de connexion DB). Retourne bool.
$cache->set('fiche_42', ['titre' => 'PHP avancé', 'places' => 12]);
// Lire. Retourne LA VALEUR, ou false si absente/expirée.
$fiche = $cache->get('fiche_42');
// Tester l'existence sans lire (rarement utile : get() suffit presque toujours,
// et has() suivi de get() fait DEUX allers-retours vers le store).
if ($cache->has('fiche_42')) { /* ... */ }
// Supprimer une entrée précise.
$cache->delete('fiche_42');
// Vider TOUTES les entrées de cette définition (sur ce store).
$cache->purge();⚠️ Piège :
get()retournefalsepour signaler l’absence. Si vous avez besoin de cacher des valeurs qui peuvent légitimement valoirfalse(ou quefalseest un résultat calculé valable, ex. « cet utilisateur n’a PAS accès »), vous ne pourrez plus distinguer « pas en cache » de « en cache, valeur false ». Solutions classiques : stocker un tableau enveloppe (['v' => $value]), stocker0/1, ou stocker une chaîne sentinelle. C’est exactement le même problème queMap.get()retournantundefineden JS quand on stockeundefinedcomme valeur.
3.2 Opérations par lot
Quand vous manipulez plusieurs clés, utilisez les variantes _many : sur un store
réseau comme Redis, elles se traduisent par un seul aller-retour (MGET/MSET) au
lieu de N :
// get_many() retourne un tableau clé => valeur, avec false pour les absentes.
$result = $cache->get_many(['fiche_42', 'fiche_43', 'fiche_44']);
// => ['fiche_42' => [...], 'fiche_43' => false, 'fiche_44' => [...]]
$cache->set_many([
'fiche_43' => ['titre' => 'SQL avancé', 'places' => 8],
'fiche_45' => ['titre' => 'Git', 'places' => 20],
]);
$cache->delete_many(['fiche_42', 'fiche_45']);3.3 Le pattern canonique get-or-compute
99 % de vos usages du MUC ressembleront à ceci. Mémorisez-le :
/**
* Retourne la fiche catalogue d'un cours, en cache.
*
* @param int $courseid
* @return array
*/
function local_catalogue_get_fiche(int $courseid): array {
global $DB;
$cache = \core_cache\cache::make('local_catalogue', 'fiches');
$key = (string) $courseid; // simplekeys => clé simple.
// 1. Tentative de lecture.
$fiche = $cache->get($key);
if ($fiche !== false) {
return $fiche; // Hit : on sort immédiatement.
}
// 2. Miss : on calcule (la partie coûteuse qu'on veut éviter).
$course = $DB->get_record('course', ['id' => $courseid], '*', MUST_EXIST);
$enrolled = count_enrolled_users(\core\context\course::instance($courseid));
$fiche = [
'fullname' => $course->fullname,
'enrolled' => $enrolled,
'built' => time(),
];
// 3. On stocke pour les prochains appels...
$cache->set($key, $fiche);
// 4. ...et on retourne.
return $fiche;
}Ce pattern est tolérant à la purge : si le cache est vidé (par l’admin, par un upgrade, par un redémarrage de Redis), la fonction recalcule silencieusement. C’est la propriété essentielle d’un bon usage du cache : le cache doit toujours être une optimisation, jamais une source de vérité. Votre code doit produire un résultat correct même si le MUC était intégralement remplacé par un trou noir.
💡 Pour un dev React : ce pattern est littéralement
useQueryécrit à la main : la clé de cache est laqueryKey, le bloc « calcul » est laqueryFn, etstaticaccelerationjoue le rôle de la déduplication des requêtes dans le même render. La règle « le cache n’est jamais la source de vérité » est la même qui fait qu’on ne stocke jamais dans React Query une donnée qu’on ne sait pas re-fetcher.
3.4 Caches ad hoc : cache::make_from_params() — et pourquoi les éviter
Il est possible de créer un cache sans le déclarer dans db/caches.php :
// Cache "ad hoc" : défini à la volée, uniquement par ses paramètres.
$cache = \core_cache\cache::make_from_params(
cache_store::MODE_REQUEST, // mode
'local_catalogue', // component
'tempcomputation' // area
);
$cache->set('x', 42);C’est pratique pour un prototype, mais à éviter en production, pour deux raisons :
- L’admin ne peut pas le configurer : un cache ad hoc n’apparaît pas dans l’interface de mapping, il reste collé au store par défaut de son mode. Impossible de le déplacer sur Redis, impossible de régler l’accélération statique finement.
- Il est invisible : pas de déclaration, pas de documentation, pas
d’
invalidationevents. Le reviewer du plugin directory vous le fera remarquer.
La règle : si le cache mérite d’exister à la prochaine requête, il mérite trois lignes
dans db/caches.php.
3.5 Que peut-on stocker ?
Tout ce qui survit à serialize()/unserialize() : scalaires, tableaux, objets
stdClass, instances de classes sérialisables. Jamais : ressources, closures,
objets tenant une connexion (DB, curl), objets énormes « par confort » (un
course_modinfo complet n’a rien à faire dans votre cache — le core le fait déjà, voir
§4.4). Et si simpledata => true est déclaré : uniquement scalaires et tableaux
(récursifs) de scalaires.
4. Invalidation : purges, événements, clés versionnées
« There are only two hard things in Computer Science: cache invalidation and naming things. » Le MUC offre quatre mécanismes, du plus chirurgical au plus brutal.
4.1 Suppression ciblée et purge de définition
// Chirurgical : une clé.
$cache = \core_cache\cache::make('local_catalogue', 'fiches');
$cache->delete((string) $courseid);
// Toute une définition, depuis n'importe où (sans construire le loader) :
\core_cache\helper::purge_by_definition('local_catalogue', 'fiches');
// Forme legacy : cache_helper::purge_by_definition(...).purge_by_definition() est l’outil à appeler dans vos observers d’événements, vos
hooks d’upgrade, vos traitements d’admin : « quelque chose a changé dans ce domaine,
on jette tout ce domaine ».
4.2 Purge par événement : purge_by_event()
Si votre définition déclare invalidationevents (cf. §2), n’importe quel code peut
déclencher l’invalidation sans connaître les définitions concernées :
// Côté producteur (le code qui modifie les données) :
\core_cache\helper::purge_by_event('local_catalogue_fiches_changed');
// Toutes les définitions (de N'IMPORTE QUEL plugin) qui ont listé
// 'local_catalogue_fiches_changed' dans leurs invalidationevents sont purgées.C’est un découplage élégant : le module A modifie des données, les plugins B et C qui
en dérivent des caches s’invalident sans que A les connaisse. Le core utilise ce
mécanisme avec l’événement changesincourse, par exemple. Attention : ces « événements »
de cache sont un mécanisme propre au MUC, distinct des events de \core\event
vus au chapitre 06 (même si on combine souvent les deux : un observer d’event core qui
appelle purge_by_event).
4.3 Purge globale
Le marteau-pilon, à connaître car vous l’utiliserez dix fois par jour en développement :
# CLI — LE réflexe du développeur Moodle :
php admin/cli/purge_caches.php
# Purges sélectives possibles :
php admin/cli/purge_caches.php --muc # seulement le MUC
php admin/cli/purge_caches.php --lang # seulement les chaînes de langue
php admin/cli/purge_caches.php --theme # seulement les caches de thèmeÉquivalent web : Administration du site > Développement > Purger les caches (bouton
« Purger tous les caches » ou purges sélectives). En code :
purge_all_caches(); (lib/moodlelib.php) ou \core_cache\helper::purge_all(); pour le
seul MUC.
Enfin, le lien avec les upgrades : quand vous incrémentez $plugin->version dans
version.php et lancez l’upgrade, Moodle purge automatiquement les caches en fin de
processus. C’est pourquoi « bumper la version » résout si souvent les problèmes de
définitions, de chaînes de langue ou de templates fantômes — ce n’est pas magique, c’est
une purge déguisée.
4.4 Stratégies d’invalidation et granularité
Deux conseils d’architecture qui évitent 90 % des bugs de cache :
Clés versionnées plutôt que purges fines. Au lieu de traquer chaque entrée à supprimer, incluez un numéro de version (ou de révision) dans la clé :
// La révision est elle-même stockée (en config ou dans une entrée du cache).
$rev = get_config('local_catalogue', 'cacherev') ?: 1;
$key = "fiche_{$courseid}_v{$rev}";
// Pour tout invalider : on incrémente la révision. Les anciennes entrées
// deviennent orphelines et seront évincées naturellement par le store.
set_config('cacherev', $rev + 1, 'local_catalogue');C’est exactement la stratégie du core : la table course a une colonne cacherev
incrémentée à chaque modification du cours, et le cache coursemodinfo compare cette
révision. Pas de purge distribuée fragile en cluster, juste une comparaison d’entiers.
Éviter le cache trop fin. Mille entrées minuscules coûtent plus cher (allers-retours réseau, métadonnées) que dix entrées moyennes bien pensées. Si vos données se lisent toujours ensemble, cachez-les ensemble. À l’inverse, ne cachez pas un blob géant dont vous ne lisez qu’un champ. Pensez « unité de lecture ».
4.5 Les caches core à connaître
Quelques définitions du core que vous croiserez sans arrêt (visibles dans
lib/db/caches.php et dans l’écran d’admin du cache) :
core/coursemodinfo: LA star. La structure complète de chaque cours (sections, modules, disponibilité). C’est ce que sertget_fast_modinfo():
// Le bon réflexe pour lire la structure d'un cours — JAMAIS de requêtes
// directes sur course_modules/course_sections :
$modinfo = get_fast_modinfo($courseid); // objet course_modinfo, servi par MUC
foreach ($modinfo->get_cms() as $cm) { // cm_info : léger, lazy
if ($cm->uservisible) {
echo $cm->get_formatted_name();
}
}
// Et quand VOTRE code modifie la structure d'un cours (ajout de module,
// changement de section...), il DOIT demander la reconstruction :
rebuild_course_cache($courseid, true); // true = purge complète du coursget_fast_modinfo() est le cas d’école d’un MUC bien utilisé : donnée coûteuse
(dizaines de requêtes SQL évitées), invalidation par révision (cacherev),
accélération statique, et personnalisation par utilisateur calculée après le cache
(la partie « uservisible » n’est pas cachée, elle est dérivée à la volée).
core/string: les chaînes de langue compilées (tous lesget_string()). Purgé par--lang.core/langmenu: la liste des langues installées.core/config: la tableconfig_pluginsentière — c’est pour ça queget_config()est quasi gratuit.core/databasemeta: les métadonnées de schéma (colonnes des tables) — c’est lui qui rend$DB->get_record()capable de valider les noms de colonnes sans requête.
⚠️ Piège : oublier
rebuild_course_cache($courseid)après avoir modifiécourse_modulesoucourse_sectionsen SQL direct produit le bug le plus déroutant de Moodle : la base est correcte, mais le cours affiché est figé dans son état antérieur — parfois pour certains utilisateurs seulement (selon le store et l’accélération statique). Symétriquement : si vous passez par les vraies API (course_create_module… ou les fonctions decourse/lib.php), elles s’en chargent. Règle : SQL direct sur les tables de structure de cours = interdiction, ou au minimumrebuild_course_cache()immédiat.
📚 Aller plus loin : la page moodledev.io/docs/5.2/apis/subsystems/muc/usage et, pour la conception des stores et le benchmark des configurations, l’outil Administration du site > Plugins > Mise en cache > Test des performances du cache.
5. Les sessions : $SESSION, stockage, verrouillage
5.1 Le global $SESSION
Moodle enveloppe la session PHP dans un objet global $SESSION (déclaré par
config.php comme $USER, $DB, $CFG…). Vous pouvez y accrocher des données
arbitraires, propres à l’utilisateur courant, qui survivent de page en page :
global $SESSION;
// Écrire — convention : préfixez vos clés par votre frankenstyle pour
// éviter les collisions avec le core et les autres plugins.
$SESSION->local_catalogue_lastfilter = ['category' => 3, 'sort' => 'name'];
// Lire (toujours défensivement : la session peut avoir été détruite).
$filter = $SESSION->local_catalogue_lastfilter ?? null;
// Nettoyer.
unset($SESSION->local_catalogue_lastfilter);$SESSION est adapté aux petites données de navigation : filtres en cours,
préférences volatiles, jetons d’assistant multi-étapes. Pour des données plus lourdes ou
structurées, préférez un cache MUC en mode MODE_SESSION (même portée, mais
configurable par l’admin et hors du blob de session). Pour des préférences durables,
utilisez l’API set_user_preference() (stockée en base, survit à la déconnexion).
5.2 Stockage des sessions
Par défaut, Moodle stocke les sessions en base de données, dans la table
sessions (colonnes sid, userid, timemodified, sessdata…). C’est fiable et sans
configuration, mais chaque requête lit et réécrit ce blob. En production sérieuse, on
bascule sur Redis :
// config.php — sessions dans Redis (recommandé en production) :
$CFG->session_handler_class = '\core\session\redis';
$CFG->session_redis_host = '127.0.0.1';
$CFG->session_redis_port = 6379;
$CFG->session_redis_database = 0;
$CFG->session_redis_prefix = 'mdl_sess_';
$CFG->session_redis_acquire_lock_timeout = 120;
$CFG->session_redis_lock_expire = 7200;Le timeout de session est réglé par l’admin ($CFG->sessiontimeout, défaut 8 heures,
dans Sécurité > Politique de session). Le cron nettoie les sessions expirées.
5.3 \core\session\manager
La classe \core\session\manager centralise la gestion. Les méthodes utiles côté code :
// Tuer toutes les sessions d'un utilisateur (ex. après désactivation du
// compte, changement de mot de passe forcé, bannissement) :
\core\session\manager::kill_user_sessions($userid);
// Tuer une session précise (par son id de la table sessions) :
\core\session\manager::kill_session($sid);
// Tuer TOUTES les sessions du site (fin de maintenance lourde) :
\core\session\manager::kill_all_sessions();
// "Se connecter en tant que" (loginas) et détection :
if (\core\session\manager::is_loggedinas()) {
$realuser = \core\session\manager::get_realuser();
}5.4 Le verrouillage de session — le piège des AJAX parallèles
C’est LE point de performance méconnu. Comme PHP natif, Moodle verrouille la session
pendant toute la durée d’une requête : la deuxième requête du même utilisateur attend
que la première libère le verrou. Conséquence directe : si votre page lance six
requêtes AJAX en parallèle (ce qu’un dev React fait sans y penser avec un
Promise.all de fetches), elles s’exécuteront en série côté serveur. Six requêtes
de 500 ms = 3 secondes, pas 500 ms.
Deux parades :
1. Déclarer la page/le service en lecture seule. Les services web AJAX du core
(chapitre suivant sur les webservices) peuvent déclarer 'readonlysession' => true
dans db/services.php. Pour un script maison, on définit la constante avant
config.php :
<?php
// local/catalogue/ajax/status.php — endpoint AJAX en lecture seule.
define('AJAX_SCRIPT', true);
define('READ_ONLY_SESSION', true); // AVANT config.php, comme toutes les constantes.
require(__DIR__ . '/../../../config.php');
require_login();
require_sesskey();
// ... aucune écriture dans $SESSION ne sera persistée ici, mais aucune
// autre requête de l'utilisateur n'est bloquée pendant l'exécution.(Le support effectif dépend du handler : les handlers database et redis du core
gèrent les sessions en lecture seule.)
2. Libérer le verrou dès qu’on n’en a plus besoin. Si votre script commence par lire la session puis enchaîne un long traitement (export, appel d’API externe…), relâchez le verrou explicitement :
require_login();
require_capability('local/catalogue:export', $context);
// À partir d'ici, plus aucune écriture de session ne sera possible,
// mais l'utilisateur peut continuer à naviguer pendant l'export.
\core\session\manager::write_close();
local_catalogue_generate_huge_export(); // 30 secondes de traitement...💡 Pour un dev React : pensez au verrou de session comme à un
SELECT ... FOR UPDATEimplicite posé sur l’utilisateur à chaque requête. Votre front peut paralléliser tant qu’il veut avecPromise.all, le backend sérialise. Si vous constatez en DevTools des requêtes AJAX qui « attendent » (TTFB en escalier, chacune démarrant quand la précédente finit), c’est presque toujours ça — pas le réseau, pas la charge serveur : le verrou de session.READ_ONLY_SESSIONest votre ami.
PARTIE B — Sécurité côté code
Changement de registre. Tout ce qui suit répond à une seule question : comment écrire une page Moodle qui ne soit ni une faille XSS, ni une faille CSRF, ni un contournement de droits. Moodle fournit une API pour chaque maillon de la chaîne ; votre travail est de n’en sauter aucun. La chaîne complète, que nous allons dérouler maillon par maillon :
Requête HTTP
→ nettoyage des paramètres (required_param / optional_param) [§10]
→ authentification (require_login) [§7]
→ résolution du contexte (\core\context\...) [§6]
→ autorisation (require_capability) [§8]
→ anti-CSRF pour les actions (require_sesskey) [§9]
→ traitement (DB, events...)
→ sortie échappée (s / format_string / format_text / Mustache) [§11]6. Les contextes : la colonne vertébrale de la sécurité
Rappel du chapitre sur les rôles : tout dans Moodle se passe « quelque part », et ce quelque part est un contexte. Les contextes forment un arbre :
System (le site entier, id de contexte 1)
├── User (un profil utilisateur)
├── Course category (une catégorie de cours)
│ ├── Course category (sous-catégorie...)
│ └── Course (un cours)
│ ├── Module (une activité : un quiz, un forum précis...)
│ │ └── Block (un bloc dans l'activité)
│ └── Block (un bloc dans le cours)
└── Block (un bloc au niveau du site)Les permissions se résolvent en remontant l’arbre : un rôle attribué au niveau d’une
catégorie s’applique à tous les cours qu’elle contient, etc. Côté code, chaque niveau a
sa classe, avec un constructeur statique instance() :
// Depuis Moodle 4.2, les classes de contexte vivent dans \core\context :
$syscontext = \core\context\system::instance(); // le site
$usercontext = \core\context\user::instance($userid); // un profil
$catcontext = \core\context\coursecat::instance($categoryid);
$coursecontext = \core\context\course::instance($courseid);
$modcontext = \core\context\module::instance($cmid); // ATTENTION : id de
// course_modules, PAS
// l'id de l'instance !
$blockcontext = \core\context\block::instance($blockinstanceid);
// Les alias legacy (pré-4.2) restent VALIDES en 5.2 et omniprésents dans le
// code existant — sachez les lire, écrivez la forme namespacée :
$coursecontext = context_course::instance($courseid); // alias, toujours OK
$modcontext = context_module::instance($cmid); // alias, toujours OK
// En revanche, get_context_instance(CONTEXT_COURSE, $id) est MORT depuis
// Moodle 2.6. Si un tutoriel en ligne l'utilise, fuyez : il date de 2012.Chaque objet contexte expose :
$context->id; // l'id dans la table {context} — c'est CET id qu'on
// stocke en base et qu'on passe aux API de fichiers.
$context->contextlevel; // 10=system, 30=user, 40=coursecat, 50=course,
// 70=module, 80=block (constantes CONTEXT_*).
$context->instanceid; // l'id de l'objet du niveau (courseid, cmid...).
$context->path; // ex. '/1/57/128' : les ids des ancêtres, de la
// racine à lui. C'est ce path qui rend les requêtes
// "tous les contextes enfants" triviales (LIKE '/1/57/%').
$parent = $context->get_parent_context(); // le père direct, ou false (system).
$coursectx = $modcontext->get_course_context(); // remonte jusqu'au contexte cours
// (paramètre false = ne pas exiger
// qu'il existe : retourne false).
$children = $context->get_child_contexts(); // tous les descendants.⚠️ Piège :
\core\context\module::instance($cmid)attend le course module id (l’id dans la tablecourse_modules, celui que vous voyez dans les URLsmod/quiz/view.php?id=123), et non l’id de l’instance d’activité (l’id dans la tablequiz, celui de?q=45). Confondre les deux est l’erreur n°1 des débutants : le code semble marcher sur un site de dev où les deux ids coïncident par hasard, puis explose (ou pire, vérifie les droits sur la mauvaise activité) en production. Utilisezget_coursemodule_from_id()/get_coursemodule_from_instance()pour convertir.
💡 Pour un dev React : le contexte est le paramètre
scopede tout votre middleware d’auth. Si vous avez déjà écrit du RBAC hiérarchique (permissions d’organisation → projet → ressource, façon GitHub org/repo), l’arbre des contextes est exactement cela, et$context->pathest la matérialisation du chemin qui permet à la vérification de remonter la hiérarchie en une requête. Ne vérifiez jamais un droit « en général » : vérifiez-le dans un contexte, le plus précis possible.
7. Authentification : require_login() et les constantes de page
7.1 Ce que fait vraiment require_login()
Chaque page de Moodle destinée à un navigateur doit appeler require_login() (ou sa
variante require_course_login()), immédiatement après le nettoyage des paramètres. Sa
signature :
require_login($courseorid = null, $autologinguest = true, $cm = null,
$setwantsurltome = true, $preventredirect = false);L’appeler « juste pour vérifier le login » sous-estime radicalement ce qu’elle fait.
Dans l’ordre, require_login($course, true, $cm) vérifie et applique :
- La session est valide et l’utilisateur est connecté — sinon, redirection vers la
page de login (en mémorisant l’URL demandée dans
$SESSION->wantsurlpour y revenir après connexion). - Connexion invité automatique si le site l’autorise et
$autologinguestest true. - Le compte n’est pas en cours de suppression, le site n’est pas en maintenance (sauf admins).
- Le mot de passe n’est pas à changer obligatoirement — sinon redirection forcée.
- Le profil est complet (champs obligatoires remplis) — sinon redirection vers l’édition du profil.
- Les politiques du site sont acceptées (RGPD, chartes via
tool_policy) — sinon redirection vers l’acceptation. - Si un
$courseest fourni : le cours est visible (ou l’utilisateur amoodle/course:viewhiddencourses), et l’utilisateur y a accès — inscription active, accès invité autorisé par le cours, ou capability d’accès sans inscription (moodle/course:view). - Si un
$cmest fourni : le module est visible et disponible pour cet utilisateur (visibilité, restrictions d’accès/availability, groupes le cas échéant).
Autrement dit : require_login($course, true, $cm) est un middleware complet
d’authentification et de contrôle d’accès grossier. Les capabilities (§8) affinent
ensuite ce que l’utilisateur peut faire ; require_login établit qu’il peut être là.
<?php
// mod/monmodule/view.php — l'ouverture canonique d'une page d'activité.
require(__DIR__ . '/../../config.php');
$id = required_param('id', PARAM_INT); // course module id.
// Résout en une fois le cm, le cours et l'instance — lève une exception propre
// si l'un des trois est incohérent :
[$course, $cm] = get_course_and_cm_from_cmid($id, 'monmodule');
// LE trio d'ouverture : login + accès au cours + accès au module.
require_login($course, true, $cm);
$context = \core\context\module::instance($cm->id);
require_capability('mod/monmodule:view', $context);7.2 require_course_login() : le cas des ressources « publiques »
Certaines pages doivent rester accessibles sans compte quand le site autorise la
navigation anonyme ($CFG->forcelogin désactivé) : typiquement les ressources et pages
placées sur la page d’accueil du site (le « cours » site). C’est le rôle de
require_course_login(), qui a la même signature mais ne force le login que si le
site l’exige globalement ou que le cours n’est pas le site lui-même. Vous la trouverez
en tête de mod/resource/view.php, mod/page/view.php, etc. Pour vos plugins :
n’utilisez cette variante que si « visible anonymement sur la frontpage » est un
comportement voulu ; dans le doute, require_login().
7.3 isloggedin(), isguestuser()
// Rarement pour bloquer (require_login le fait mieux), plutôt pour adapter l'UI :
if (isloggedin() && !isguestuser()) {
// Un vrai utilisateur authentifié.
}
// PIÈGE : isloggedin() vaut true pour l'invité ! L'invité EST "loggué"
// (compte guest). Le test complet "vrai compte" est TOUJOURS le duo ci-dessus.7.4 Les constantes de page — avant config.php, toujours
Certaines constantes modifient le comportement du bootstrap de Moodle. Elles doivent
être définies avant le require config.php, sinon elles sont ignorées :
<?php
// AVANT config.php — trop tard après, le setup est déjà passé.
define('AJAX_SCRIPT', true); // Page appelée en AJAX : les erreurs et
// redirections sont renvoyées en JSON au lieu
// de HTML, pas de rendu de thème.
define('NO_DEBUG_DISPLAY', true); // Supprime l'affichage du debug dans la sortie
// (indispensable pour les endpoints qui renvoient
// du JSON, des images, des fichiers : un warning
// HTML au milieu d'un JSON casse tout).
define('NO_MOODLE_COOKIES', true); // Pas de session du tout. Pour les endpoints
// véritablement publics/stateless (rss, quelques
// callbacks). ATTENTION : sans session, pas de
// $USER, pas de sesskey, pas de require_login.
define('CLI_SCRIPT', true); // Scripts ligne de commande uniquement : bloque
// tout accès web, pas de session, $USER = admin CLI.
define('READ_ONLY_SESSION', true); // Vu en §5.4 : ne verrouille pas la session.
require(__DIR__ . '/../../config.php'); // et SEULEMENT MAINTENANT.⚠️ Piège : oublier
require_login()sur une page « qui n’affiche rien de sensible » est le début classique d’une CVE. La page anodine d’aujourd’hui reçoit un paramètre de plus dans six mois, et personne ne repense à l’authentification. La revue du plugin directory rejette systématiquement toute page sansrequire_login()(ou justification explicite du typeNO_MOODLE_COOKIES+ contenu réellement public). La règle est absolue : tout fichier PHP accessible par le web appellerequire_loginou déclare explicitement pourquoi il ne le fait pas.
8. Capabilities côté code : has_capability() et compagnie
Les capabilities (déclarées dans le db/access.php de chaque plugin — leur déclaration
est traitée dans la partie « développement de plugins » de cette documentation) sont les
permissions atomiques de Moodle : mod/quiz:attempt, moodle/course:update,
local/catalogue:manage… Un rôle en agrège des dizaines ; côté code, on ne teste
jamais un rôle, toujours une capability, dans un contexte.
8.1 Les deux fonctions fondamentales
$context = \core\context\module::instance($cm->id);
// 1. Tester — retourne bool, pour ADAPTER le comportement/l'affichage :
if (has_capability('mod/quiz:viewreports', $context)) {
echo $OUTPUT->single_button($reporturl, get_string('viewreports', 'quiz'));
}
// Troisième paramètre optionnel : tester pour un AUTRE utilisateur que
// l'utilisateur courant ($USER par défaut) :
if (has_capability('mod/quiz:attempt', $context, $student->id)) { ... }
// 2. Exiger — lève une required_capability_exception (page d'erreur propre,
// HTTP 403 en AJAX) si absente. Pour PROTÉGER une action :
require_capability('mod/quiz:deleteattempts', $context);
// Tout ce qui suit cette ligne peut supposer le droit acquis.La distinction d’usage est nette : has_capability() pour construire l’interface
(montrer ou cacher un bouton), require_capability() en tête du script qui exécute
l’action. Et il faut les deux — cacher le bouton n’a jamais empêché personne de
forger l’URL.
8.2 Variantes multiples
// Au moins une des capabilities (OU logique) :
if (has_any_capability(['mod/quiz:manage', 'mod/quiz:viewreports'], $context)) { ... }
// Toutes les capabilities (ET logique) :
if (has_all_capabilities(['moodle/course:update', 'moodle/course:manageactivities'],
$context)) { ... }8.3 is_siteadmin() — et pourquoi ne (presque) jamais l’appeler
if (is_siteadmin()) { ... } // ← à éviter dans 99 % des cas.Les administrateurs de site passent automatiquement tous les tests
has_capability() (sauf pour les rares capabilities à risque XSS quand ils ne les ont
pas). Tester is_siteadmin() directement est donc presque toujours une erreur de
conception : cela court-circuite le système de rôles, empêche de déléguer la
fonctionnalité à un rôle « manager » sur mesure, et rend la permission introuvable dans
l’interface d’administration des rôles. La bonne démarche : créer votre propre
capability dans db/access.php (ex. local/catalogue:manage), l’attribuer par défaut
au rôle manager, et tester celle-ci. Les seuls usages légitimes d’is_siteadmin() sont
les écrans qui concernent la gestion des admins eux-mêmes.
8.4 Trouver les utilisateurs qui ont un droit
L’antique get_users_by_capability() est dépréciée : elle ignorait les inscriptions
et produisait des résultats surprenants. Les remplaçantes :
// Dans un contexte de cours/module : les utilisateurs INSCRITS qui ont la
// capability. C'est presque toujours ce que vous voulez :
$graders = get_enrolled_users($context, 'mod/assign:grade');
// Variante SQL pour composer avec vos propres jointures :
[$sql, $params] = get_enrolled_sql($context, 'mod/assign:grade');
$rows = $DB->get_records_sql(
"SELECT u.id, u.firstname, u.lastname
FROM {user} u
JOIN ($sql) je ON je.id = u.id
WHERE u.deleted = 0", $params);
// Pour les cas hors inscription, les API du composant core access
// (\core\access, get_with_capability_sql...) prennent le relais.8.5 Le pattern : vérifier au plus près de l’action ET à l’affichage
// À l'affichage (UX) : on ne montre que ce qui est permis.
if (has_capability('local/catalogue:delete', $context)) {
$deleteurl = new moodle_url('/local/catalogue/delete.php',
['id' => $entry->id, 'sesskey' => sesskey()]);
echo html_writer::link($deleteurl, get_string('delete'));
}
// ...et dans delete.php (SÉCURITÉ) : on re-vérifie TOUT, car l'URL peut être
// forgée, mise en favori, rejouée après un changement de rôle :
require_login($course);
require_capability('local/catalogue:delete', $context);
require_sesskey();💡 Pour un dev React :
has_capability/require_capability, c’est votre RBAC + middleware.has_capabilityest le hookuseCan('post:delete', resource)qui pilote le rendu conditionnel ;require_capabilityest le middleware serveur (ou la policy tRPC/NestJS guard) qui protège la mutation. Et la règle est la même que sur le web moderne : le rendu conditionnel côté client n’est pas de la sécurité — seule la vérification dans le handler compte, l’affichage n’est que du confort.
📚 Aller plus loin : l’API d’accès complète (dont les subtilités de résolution prevent/prohibit, les role switches et
has_capabilitypour les utilisateurs non connectés) est documentée sur moodledev.io/docs/5.2/apis/subsystems/access .
9. CSRF : le sesskey
9.1 Le problème et la solution Moodle
Une attaque CSRF (Cross-Site Request Forgery) consiste à faire exécuter à votre
navigateur — déjà authentifié sur Moodle — une requête que vous n’avez pas voulue :
une image <img src="https://moodle.exemple.fr/local/catalogue/delete.php?id=42">
dans un mail suffit, puisque le navigateur envoie docilement le cookie de session.
La parade de Moodle est un jeton par session, le sesskey : une chaîne aléatoire générée à la création de la session, que le serveur exige sur toute requête qui modifie un état. L’attaquant, qui ne peut pas lire votre session, ne peut pas le deviner.
// Côté génération — obtenir le sesskey de la session courante :
$sk = sesskey();
// L'ajouter à une URL d'action construite à la main :
$deleteurl = new moodle_url('/local/catalogue/delete.php', [
'id' => $entry->id,
'sesskey' => sesskey(),
]);
// L'ajouter à un <form> écrit à la main (rare — préférez moodleform) :
echo html_writer::empty_tag('input', [
'type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey(),
]);// Côté traitement — DEUX fonctions :
// require_sesskey() : lève une exception si le paramètre sesskey de la
// requête est absent ou invalide. C'est la forme à utiliser en tête des
// scripts d'action :
require_sesskey();
// confirm_sesskey() : retourne bool, pour les rares cas où on veut gérer
// l'échec soi-même :
if (!confirm_sesskey()) {
throw new moodle_exception('invalidsesskey');
}9.2 Là où c’est automatique
Bonne nouvelle : les API de haut niveau gèrent le sesskey pour vous.
moodleform(l’API de formulaires, vue dans la partie plugins) injecte un champsesskeycaché dans chaque formulaire et le vérifie dansget_data(). Si vous utilisez moodleform, vous êtes couvert sans une ligne de code.$OUTPUT->single_button($url, $label)rend un mini-formulaire POST avec sesskey intégré — c’est la façon canonique de rendre un bouton d’action.- Les services web AJAX (
core_external) vérifient le sesskey sur chaque appel.
Vous n’écrivez donc sesskey()/require_sesskey() à la main que pour les liens
d’action GET et les formulaires artisanaux.
9.3 Exemple : une action GET dangereuse, corrigée
Le code naïf que l’on voit trop souvent :
// ❌ VULNÉRABLE : action destructive sur simple GET, sans sesskey, sans
// confirmation. Une balise <img> hostile suffit à supprimer l'entrée.
$id = required_param('id', PARAM_INT);
$DB->delete_records('local_catalogue_entries', ['id' => $id]);
redirect(new moodle_url('/local/catalogue/index.php'));La version correcte, avec sesskey et écran de confirmation (une action destructive mérite les deux — le sesskey protège du CSRF, la confirmation protège du clic malheureux et des préchargeurs de liens) :
// ✅ CORRECT — local/catalogue/delete.php
require(__DIR__ . '/../../config.php');
$id = required_param('id', PARAM_INT);
$confirm = optional_param('confirm', 0, PARAM_BOOL);
$entry = $DB->get_record('local_catalogue_entries', ['id' => $id], '*', MUST_EXIST);
$context = \core\context\system::instance();
require_login();
require_capability('local/catalogue:delete', $context);
$PAGE->set_url(new moodle_url('/local/catalogue/delete.php', ['id' => $id]));
$PAGE->set_context($context);
$returnurl = new moodle_url('/local/catalogue/index.php');
if ($confirm) {
// Étape 2 : l'utilisateur a confirmé. Le sesskey n'est vérifié QU'ICI,
// sur la requête qui modifie réellement l'état.
require_sesskey();
$DB->delete_records('local_catalogue_entries', ['id' => $id]);
redirect($returnurl, get_string('entrydeleted', 'local_catalogue'),
null, \core\output\notification::NOTIFY_SUCCESS);
}
// Étape 1 : écran de confirmation. Le bouton "oui" est un single_button
// (POST + sesskey automatique) vers cette même page avec confirm=1.
echo $OUTPUT->header();
$yesurl = new moodle_url($PAGE->url, ['confirm' => 1]);
echo $OUTPUT->confirm(
get_string('confirmdelete', 'local_catalogue', format_string($entry->name)),
$yesurl, // rendu en single_button POST avec sesskey.
$returnurl // bouton "non" : simple lien de retour.
);
echo $OUTPUT->footer();💡 Pour un dev React : le sesskey est exactement le token CSRF « synchronizer pattern » que Next.js vous a fait oublier (les Server Actions embarquent le leur, et les API SameSite ont réduit la surface). Moodle ne compte pas uniquement sur
SameSite: le jeton est vérifié explicitement. Mentalement :sesskey()=csrfTokeninjecté dans le form,require_sesskey()= la vérification middleware. Et la règle REST reste vraie ici : un GET ne devrait jamais muter — quand Moodle mute sur un lien GET, le sesskey est le strict minimum, la confirmation POST est le vrai correctif.
10. Nettoyage des entrées : required_param() et les types PARAM_*
10.1 La règle absolue
On ne touche JAMAIS $_GET, $_POST, $_REQUEST directement dans du code Moodle.
Jamais. Les trois fonctions d’accès aux paramètres sont :
// Paramètre OBLIGATOIRE : lève une exception (page d'erreur propre) s'il
// est absent. Le deuxième argument est le TYPE de nettoyage.
$id = required_param('id', PARAM_INT);
// Paramètre OPTIONNEL : valeur par défaut si absent.
$page = optional_param('page', 0, PARAM_INT);
$search = optional_param('search', '', PARAM_TEXT);
// Tableaux de paramètres (name="ids[]" côté HTML) — les fonctions simples
// REFUSENT les tableaux, il faut la variante dédiée :
$ids = optional_param_array('ids', [], PARAM_INT); // chaque élément nettoyé.Ces fonctions font trois choses : elles fusionnent GET et POST, elles nettoient
la valeur selon le type demandé (via clean_param()), et elles échouent bruyamment
sur les incohérences (tableau reçu là où un scalaire est attendu, obligatoire absent).
💡 Pour un dev React :
required_param('id', PARAM_INT)est votrez.coerce.number().int().parse(searchParams.id)— un schéma de validation appliqué aux entrées HTTP, qui throw en cas d’absence, avec une nuance importante : la plupart des types PARAM_* nettoient (ils retirent ce qui ne va pas, comme un.transform()) plutôt qu’ils ne rejettent (comme un.refine()strict). UnPARAM_INTsur"12abc"donne12, pas une erreur. Si vous voulez du strict, comparez la valeur nettoyée à la valeur brute, ou utilisez moodleform qui valide réellement.
10.2 Tour des types PARAM_* importants
Le fichier lib/moodlelib.php en définit des dizaines ; voici ceux que vous utiliserez
réellement, avec leur comportement exact :
| Type | Accepte / transforme | Exemple |
|---|---|---|
PARAM_INT | Entier ; cast, tronque le non-numérique | "42" → 42, "12abc" → 12, "abc" → 0 |
PARAM_FLOAT | Flottant, point décimal uniquement | "3.14" → 3.14 — voir piège ci-dessous |
PARAM_BOOL | Booléen souple | "1", "true", "on", "yes" → true |
PARAM_ALPHA | Lettres a-zA-Z uniquement, supprime le reste | "abc123" → "abc" |
PARAM_ALPHANUM | Lettres + chiffres | "ab-12" → "ab12" |
PARAM_ALPHANUMEXT | Lettres, chiffres, _ et - | idéal pour les slugs, noms d’action |
PARAM_TEXT | Texte multilingue, supprime les balises HTML | "<b>hi</b>" → "hi" |
PARAM_NOTAGS | Comme TEXT, strip agressif de tout tag | recherche, titres saisis |
PARAM_RAW | Aucun nettoyage | contenu d’éditeur HTML (nettoyé à la sortie !) |
PARAM_RAW_TRIMMED | Aucun nettoyage, juste trim() | idem, sans espaces parasites |
PARAM_EMAIL | Email valide, sinon chaîne vide | "a@b.fr" ok, "nope" → "" |
PARAM_URL | URL bien formée (http/https/ftp) | sinon "" |
PARAM_LOCALURL | URL relative au site ou pointant vers lui | anti open-redirect : indispensable pour les paramètres returnurl |
PARAM_FILE | Nom de fichier sûr : supprime /, \, .. | "../etc/passwd" → "etcpasswd" |
PARAM_PATH | Chemin relatif sûr : garde /, neutralise .. | fichiers dans un dossier |
PARAM_SAFEDIR | a-zA-Z0-9_- : noms de répertoires/plugins | inclusion dynamique sûre |
PARAM_COMPONENT | Nom de composant frankenstyle (mod_quiz) | valider un paramètre component |
PARAM_SEQUENCE | Chiffres et virgules : "1,4,12" | listes d’ids compactes |
Quelques-uns méritent un commentaire :
PARAM_LOCALURLest un type de sécurité, pas de format : tout paramètrereturnurl/redirectqui sera passé àredirect()doit être nettoyé avec lui, sinon vous offrez un open redirect (?returnurl=https://phishing.evil) que les campagnes de phishing adorent.PARAM_SAFEDIRetPARAM_COMPONENTprotègent les inclusions et instanciations dynamiques : si vous faites un jourrequire($CFG->dirroot . "/mod/{$modname}/lib.php"),$modnamedoit être passé parPARAM_SAFEDIR(traversal de chemin sinon).PARAM_RAWn’est pas un aveu d’échec : c’est le type correct pour du contenu riche saisi dans un éditeur, parce que la stratégie de Moodle (§11) est de nettoyer à la sortie viaformat_text(). Nettoyer agressivement à l’entrée détruirait le HTML légitime des enseignants.
10.3 Le piège PARAM_TEXT
PARAM_TEXT semble être « le type pour du texte », et il l’est — mais il supprime
les balises. Si vous l’utilisez pour récupérer le contenu d’un éditeur HTML, vous
détruisez silencieusement la mise en forme de l’utilisateur : listes, liens, images —
tout disparaît, sans erreur. Le duo correct : PARAM_RAW à l’entrée + format_text()
à la sortie. PARAM_TEXT est fait pour les champs une-ligne-sans-HTML : un titre, un
terme de recherche, un nom.
⚠️ Piège —
PARAM_FLOATet les nombres saisis par l’humain :PARAM_FLOATexige le point comme séparateur décimal. Un utilisateur français tape12,5dans un champ de note ou de prix → nettoyage → résultat faux ou tronqué. Pour toute valeur numérique saisie dans un formulaire (notes, montants, seuils), la chaîne correcte est : récupérer enPARAM_RAW_TRIMMED(ou via moodleform avecsetType(..., PARAM_RAW)) puis convertir avecunformat_float($value), qui comprend le séparateur décimal de la locale de l’utilisateur. Symétriquement, affichez avecformat_float().PARAM_FLOATreste correct pour les valeurs générées par du code (coordonnées dans une URL, valeurs de sliders).
10.4 clean_param() à la main
Quand la donnée ne vient pas de la requête HTTP (fichier importé, API externe, CLI), on applique le même nettoyage manuellement :
$cleanname = clean_param($row['name'], PARAM_TEXT); // une valeur
$cleanids = clean_param_array($row['ids'], PARAM_INT); // un tableauC’est la même fonction que required_param() utilise en interne — le vocabulaire
PARAM_* est donc utilisable sur toutes vos frontières de confiance, pas seulement
HTTP.
11. Sortie et XSS : s(), format_string(), format_text()
11.1 La règle d’or
La stratégie de Moodle contre le XSS tient en une phrase :
On nettoie légèrement à l’ENTRÉE (types PARAM_*), on échappe systématiquement à la SORTIE.
C’est le contraire de l’intuition « je nettoie tout à fond en entrée et j’affiche tranquille ». Pourquoi ? Parce que la même donnée sort dans des contextes différents (HTML, attribut, JavaScript, e-mail texte, export CSV), et que seul le point de sortie sait comment échapper correctement. Conséquence pratique : toute variable qui atterrit dans du HTML passe par une fonction d’échappement ou de formatage. Sans exception.
Même echo $USER->firstname; est un bug en puissance : un prénom peut légalement
contenir <, &, une apostrophe — et sur un site où l’auto-inscription est ouverte,
un prénom peut contenir <script>. Le core lui-même écrit echo s($USER->firstname)
ou passe par fullname($USER) + format_string().
11.2 Les trois niveaux d’échappement/formatage
Niveau 1 — s() et p() : l’échappement brut.
// s() = htmlspecialchars(ENT_QUOTES) qui retourne ; p() = qui echo.
echo '<input type="text" name="q" value="' . s($search) . '">';
p($search); // équivalent à echo s($search);À utiliser pour : valeurs d’attributs HTML, données techniques, tout ce qui doit s’afficher exactement tel quel sans passer par les filtres. C’est votre échappement par défaut quand aucun des deux suivants ne s’applique.
Niveau 2 — format_string() : les titres et « one-liners ».
// Pour tout contenu court saisi par un humain : nom de cours, titre
// d'activité, nom de catégorie... Applique les FILTRES (multilangue
// {mlang}, glossaire...) puis échappe le résultat.
echo html_writer::tag('h2',
format_string($course->fullname, true, ['context' => $coursecontext]));format_string() fait deux choses que s() ne fait pas : elle exécute les filtres
de contenu (dont le filtre multilangue — sans elle, vos utilisateurs verraient les
balises {mlang} brutes), et elle le fait dans un contexte donné (les filtres
peuvent être activés/désactivés par cours). Passez toujours le contexte dans les
options ; sans lui, Moodle devine (mal, et avec un coût).
Niveau 3 — format_text() : les contenus riches.
// Pour tout contenu MULTILIGNE riche : description de cours, intro
// d'activité, message de forum, contenu de page... Le format est stocké
// EN BASE à côté du texte (colonnes xxx et xxxformat).
echo format_text(
$page->content, // le HTML/texte stocké (entré en PARAM_RAW !)
$page->contentformat, // FORMAT_HTML | FORMAT_MOODLE | FORMAT_PLAIN | FORMAT_MARKDOWN
[
'context' => $context, // OBLIGATOIRE. Pilote filtres ET nettoyage.
'trusted' => false, // true = contenu d'un utilisateur ayant la
// capability trusttext : moins nettoyé. Ne
// mettez true que si vous savez EXACTEMENT
// pourquoi.
'noclean' => false, // true = PAS DE NETTOYAGE HTML DU TOUT.
// Réservé à du contenu généré par le code
// lui-même. Sur du contenu utilisateur,
// noclean=true = faille XSS, point.
]
);format_text() est le pipeline complet : conversion du format (Markdown → HTML,
texte → HTML avec <br>), nettoyage HTML (suppression des scripts, handlers
onclick, styles dangereux…), puis application des filtres (MathJax, multimédia,
multilangue…). Les quatre formats :
FORMAT_HTML: du HTML (sortie d’éditeur TinyMCE/Atto) ; nettoyé.FORMAT_MOODLE: l’historique « texte avec un peu de HTML », auto-paragraphé.FORMAT_PLAIN: texte brut, tout est échappé, retours ligne →<br>.FORMAT_MARKDOWN: converti en HTML puis nettoyé.
💡 Pour un dev React :
format_text()est votredangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(content)) }}— conversion + sanitisation + injection — en une fonction, avec en prime le pipeline de filtres (imaginez des plugins remark/rehype configurables par contexte).format_string()est le même outil pour du inline.s()est le comportement JSX par défaut ({value}échappe tout seul). Etnoclean: trueest l’équivalent exact d’appelerdangerouslySetInnerHTMLsans DOMPurify : le nom vous crie la même chose dans les deux frameworks.
11.3 L’ordre des opérations avec les fichiers embarqués
Le contenu riche stocké en base contient des pseudo-URLs @@PLUGINFILE@@/image.png
(l’API Fichiers, vue dans sa propre partie). La réécriture en vraies URLs doit se faire
avant format_text() :
// 1. D'ABORD réécrire les @@PLUGINFILE@@ en URLs pluginfile.php réelles :
$content = file_rewrite_pluginfile_urls(
$page->content, 'pluginfile.php', $context->id,
'mod_page', 'content', $page->revision
);
// 2. ENSUITE formater/nettoyer/filtrer :
echo format_text($content, $page->contentformat, ['context' => $context]);Dans l’autre ordre, le nettoyeur et les filtres verraient des URLs invalides (et le cache de filtres mémoriserait le résultat cassé).
11.4 Mustache : {{}} vs {{{}}}
Les templates Mustache (le moteur de rendu de Moodle, chapitre Output de cette partie) appliquent la même règle d’or :
{{! deux accolades : ÉCHAPPÉ automatiquement — le défaut, comme {expr} en JSX }}
<h3>{{title}}</h3>
{{! trois accolades : BRUT, non échappé — uniquement pour du HTML déjà
passé par format_text() côté PHP. Chaque {{{ }}} doit pouvoir être
justifié en revue de code. }}
<div class="description">{{{formatteddescription}}}</div>La discipline : côté PHP, on prépare le contexte du template en formatant les champs
riches ('formatteddescription' => format_text(...)) et on laisse les champs simples
bruts (ils seront échappés par {{}}). On ne « pré-échappe » jamais un champ destiné à
{{}} — double échappement garanti (&amp;).
⚠️ Piège — le double formatage : appliquer
format_string()à une chaîne, la stocker, puis la réafficher viaformat_string()(ou l’injecter dans{{}}) produit des&visibles à l’écran. Symétrie de la règle d’or : on stocke brut (tel que saisi), on formate une seule fois, au moment de l’affichage. Si vous voyez des entités HTML à l’écran, vous avez échappé deux fois ; si vous voyez du HTML interprété là où il ne devrait pas l’être, zéro fois. Les deux sont des bugs.
12. Le pattern de page sécurisée complet
Synthèse. Voici la page d’administration d’un plugin local_catalogue : elle liste des
entrées et permet leur suppression. Elle intègre tous les maillons du chapitre — et
de la partie : paramètres nettoyés, require_login, contexte, capability, sesskey +
confirmation, transaction, event, notification, sortie échappée. Chaque ligne compte.
<?php
// local/catalogue/manage.php
//
// Page de gestion du catalogue : listing + suppression d'entrées.
// Le squelette de TOUTE page d'action Moodle bien écrite.
// ── 0. Bootstrap ─────────────────────────────────────────────────────────
// Pas de constante spéciale ici : page HTML classique avec session.
require(__DIR__ . '/../../config.php');
require_once($CFG->libdir . '/tablelib.php'); // si on utilisait flexible_table.
// ── 1. Paramètres : nettoyés AVANT toute autre chose ─────────────────────
// L'action est un mot-clé contrôlé : ALPHANUMEXT suffit et neutralise toute
// tentative d'injection dans les comparaisons/URLs qui suivent.
$action = optional_param('action', '', PARAM_ALPHANUMEXT);
$entryid = optional_param('entryid', 0, PARAM_INT);
$confirm = optional_param('confirm', 0, PARAM_BOOL);
$page = optional_param('page', 0, PARAM_INT);
// ── 2. Authentification + contexte + autorisation ────────────────────────
// Plugin de niveau site : le contexte est system. Un plugin de cours aurait
// fait require_login($course) + \core\context\course::instance($courseid).
require_login(); // session valide, politiques acceptées, etc.
$context = \core\context\system::instance();
require_capability('local/catalogue:manage', $context); // 403 propre sinon.
// ── 3. Mise en place de la page (obligatoire AVANT toute sortie) ─────────
$pageurl = new moodle_url('/local/catalogue/manage.php', ['page' => $page]);
$PAGE->set_url($pageurl);
$PAGE->set_context($context);
$PAGE->set_title(get_string('managecatalogue', 'local_catalogue'));
$PAGE->set_heading(get_string('managecatalogue', 'local_catalogue'));
// ── 4. Traitement des actions : AVANT le premier echo ────────────────────
// (une redirection après du HTML déjà envoyé est une erreur "headers sent").
if ($action === 'delete' && $entryid) {
// L'entrée doit exister : MUST_EXIST lève une exception 404 propre,
// et on dispose de l'objet pour l'écran de confirmation et l'event.
$entry = $DB->get_record('local_catalogue_entries',
['id' => $entryid], '*', MUST_EXIST);
if ($confirm) {
// ── L'action destructive elle-même ──
// Anti-CSRF : on ne vérifie le sesskey QUE sur la requête qui mute.
require_sesskey();
// Transaction : la suppression touche deux tables ; tout ou rien.
$transaction = $DB->start_delegated_transaction();
$DB->delete_records('local_catalogue_ratings', ['entryid' => $entry->id]);
$DB->delete_records('local_catalogue_entries', ['id' => $entry->id]);
$transaction->allow_commit();
// Event (cf. chapitre 06) : trace dans les logs, permet aux
// observers (et à nos invalidations de cache !) de réagir.
\local_catalogue\event\entry_deleted::create([
'objectid' => $entry->id,
'context' => $context,
'other' => ['name' => $entry->name],
])->trigger();
// Invalidation MUC (cf. §4) : le listing est caché.
\core_cache\helper::purge_by_event('local_catalogue_fiches_changed');
// PRG (Post/Redirect/Get) : notification + redirection. Jamais de
// ré-affichage direct après une mutation (F5 = re-suppression).
redirect($pageurl,
get_string('entrydeleted', 'local_catalogue'),
null, \core\output\notification::NOTIFY_SUCCESS);
}
// ── Écran de confirmation (première étape du delete) ──
echo $OUTPUT->header();
// format_string sur la donnée utilisateur injectée dans le message !
$message = get_string('confirmdelete', 'local_catalogue',
format_string($entry->name, true, ['context' => $context]));
$yesurl = new moodle_url($PAGE->url, [
'action' => 'delete', 'entryid' => $entry->id, 'confirm' => 1,
]);
// $OUTPUT->confirm rend "oui" en single_button POST + sesskey auto.
echo $OUTPUT->confirm($message, $yesurl, $pageurl);
echo $OUTPUT->footer();
exit; // Rien d'autre à afficher sur cet écran.
}
// ── 5. Affichage du listing : chaque donnée sortie est échappée ──────────
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string('entries', 'local_catalogue'));
$entries = $DB->get_records('local_catalogue_entries', null, 'name ASC',
'id, name, shortdesc, timemodified', $page * 30, 30);
if (!$entries) {
// Les notifications standard échappent leur message ; on reste prudent.
echo $OUTPUT->notification(get_string('noentries', 'local_catalogue'),
\core\output\notification::NOTIFY_INFO);
} else {
$table = new html_table();
$table->head = [
get_string('name'),
get_string('description'),
get_string('lastmodified'),
get_string('actions'),
];
foreach ($entries as $entry) {
// Titre saisi par un humain → format_string (filtres + échappement).
$name = format_string($entry->name, true, ['context' => $context]);
// Description courte, une ligne, sans HTML autorisé → s().
$desc = s($entry->shortdesc);
// Date : API de formatage localisée, pas de date() brut.
$when = userdate($entry->timemodified,
get_string('strftimedatetimeshort', 'langconfig'));
// Lien d'action : sesskey PAS nécessaire ici (le lien ne mute pas,
// il mène à l'écran de confirmation) mais capability à l'affichage :
$actions = '';
if (has_capability('local/catalogue:delete', $context)) {
$deleteurl = new moodle_url($PAGE->url,
['action' => 'delete', 'entryid' => $entry->id]);
$actions = html_writer::link($deleteurl, get_string('delete'),
['class' => 'text-danger']);
}
$table->data[] = [$name, $desc, $when, $actions];
}
echo html_writer::table($table);
// Pagination standard.
$total = $DB->count_records('local_catalogue_entries');
echo $OUTPUT->paging_bar($total, $page, 30, $pageurl);
}
echo $OUTPUT->footer();Relisez la structure, elle est toujours la même : params → login → contexte → capability → set_url/set_context → traitement (avec sesskey) → redirect → affichage échappé. Une fois ce squelette dans les doigts, écrire une page Moodle sûre devient mécanique — et c’est le but : la sécurité par routine, pas par héroïsme.
⚠️ Piège : l’ordre
$PAGE->set_url()/$PAGE->set_context()avant$OUTPUT->header()n’est pas décoratif — sans eux, header() lève un debugging ou devine un contexte faux, et les logs/events attachés à la page pointent au mauvais endroit. De même, toutredirect()doit intervenir avant le moindre octet de sortie. Structure stricte : tout le traitement d’abord, tout l’affichage ensuite — comme dans un handler Next.js où l’on ne peut plus modifier les headers après avoir commencé à streamer le body.
13. Checklist sécurité finale et outillage
À dérouler sur chaque fichier PHP accessible par le web que vous écrivez :
Entrées
- Aucun accès direct à
$_GET/$_POST/$_REQUEST/$_COOKIE. - Chaque paramètre passe par
required_param/optional_param(_array) avec le typePARAM_*le plus restrictif possible. - Les
returnurlsont enPARAM_LOCALURL; les noms de plugins/dossiers enPARAM_SAFEDIR/PARAM_COMPONENT; le contenu d’éditeur enPARAM_RAW. - Les nombres saisis par l’humain passent par
unformat_float().
Authentification / autorisation
-
require_login()(avec$course/$cmsi pertinent) sur chaque page — ou une justification écrite de son absence. - Un contexte précis est résolu (
\core\context\moduleplutôt que system dès que possible). -
require_capability()protège chaque action ;has_capability()conditionne chaque élément d’UI correspondant. Pas deis_siteadmin()de confort. - Les requêtes qui listent des utilisateurs respectent les inscriptions
(
get_enrolled_users, pas l’API dépréciée).
Mutations
- Toute requête qui modifie un état exige le sesskey (
require_sesskey()ou moodleform/single_button qui le gèrent). - Les actions destructives ont un écran de confirmation.
- Pattern PRG : mutation →
redirect(), jamais de ré-affichage direct. - Écritures multi-tables sous transaction ; events déclenchés ; caches invalidés.
Sorties
- Zéro
echode variable nue :s()/p(),format_string()(+ contexte) ouformat_text()(+ contexte) partout. -
file_rewrite_pluginfile_urls()avantformat_text(). - Pas de
noclean/trustedsans justification écrite. - Dans Mustache :
{{{ }}}uniquement sur du contenu déjà passé parformat_text(). - SQL : uniquement des requêtes paramétrées
$DB(jamais de concaténation — cf. chapitre DML).
Outillage — automatisez tout ça :
- Moodle Code Checker (
moodle-cs, règles phpcs officielles) : détecte les accès superglobaux directs, les sorties non échappées évidentes, les conventions. À brancher dans votre IDE et votre CI. - moodle-plugin-ci : la chaîne CI officielle pour plugins (GitHub Actions) — phpcs, phpunit, behat, validation des chaînes de langue, mustache lint, etc. Un plugin sérieux tourne dessus à chaque commit.
- La revue du plugin directory : toute soumission sur moodle.org/plugins passe une revue humaine qui vérifie précisément cette checklist. La préparer, c’est simplement avoir appliqué ce chapitre.
📚 Aller plus loin : le guide sécurité officiel — moodledev.io/general/development/policies/security — couvre en détail chaque classe de vulnérabilité (XSS, CSRF, SQL injection, path traversal, open redirect…) avec les API Moodle correspondantes, et la procédure de signalement responsable des failles. Complément indispensable de ce chapitre.
Résumé — points clés
MUC et sessions
- Le MUC sépare la définition (déclarée par le dev dans
db/caches.php: mode, simplekeys/simpledata, staticacceleration, invalidationevents) du store (choisi par l’admin : file par défaut, Redis en prod). Depuis 4.4, classes dans\core_cache(alias legacycache,cache_storevalides). - Trois modes : application (partagé), session (par utilisateur), request
(memoization). API :
cache::make(),get/set/delete, variantes_many,purge(). Le pattern get-or-compute est roi ; le cache n’est jamais une source de vérité. Évitez les caches ad hoc (make_from_params) et lettl. - Invalidation :
delete()ciblé,purge_by_definition(),purge_by_event()+invalidationevents, purge globale (admin/cli/purge_caches.php— réflexe quotidien du dev), et purge automatique à l’upgrade. Stratégie robuste : clés versionnées (modèlecacherevdu core). Connaîtrecoursemodinfo/get_fast_modinfo()et l’obligation derebuild_course_cache(). - Sessions :
$SESSION(clés préfixées frankenstyle), stockage DB par défaut / Redis en prod,\core\session\manager(kill_user_sessions…). Le verrou de session sérialise les requêtes parallèles d’un même utilisateur :READ_ONLY_SESSIONet\core\session\manager::write_close()sont les parades.
Sécurité côté code
- La chaîne complète, dans l’ordre, sur chaque page : params nettoyés → require_login → contexte précis → require_capability → sesskey sur mutation → traitement → redirect → sortie échappée.
- Contextes :
\core\context\{system,user,coursecat,course,module,block}::instance()(namespace depuis 4.2, aliascontext_courseetc. valides) ;module::instance()prend le cmid.$context->id,path,get_parent_context(),get_course_context(). require_login($course, true, $cm)fait bien plus que vérifier le login : visibilité du cours et du module, inscription, profil complet, politiques. Constantes (AJAX_SCRIPT,CLI_SCRIPT,NO_MOODLE_COOKIES,NO_DEBUG_DISPLAY,READ_ONLY_SESSION) avantconfig.php.- Capabilities :
has_capabilitypour l’UI,require_capabilitypour l’action — les deux. Jamaisis_siteadmin()de confort ; créez vos capabilities.get_enrolled_users($context, $capability)remplaceget_users_by_capability. - CSRF :
sesskey()dans les URLs/forms manuels,require_sesskey()au traitement ; automatique dans moodleform et single_button. Action destructive = sesskey + confirmation. - Entrées : jamais
$_GET/$_POST;PARAM_*le plus strict possible ;PARAM_LOCALURLpour les returnurl ;PARAM_RAW(pasPARAM_TEXT!) pour le contenu d’éditeur ;unformat_float()pour les nombres saisis. - Sorties : nettoyage léger à l’entrée, échappement systématique à la sortie —
s()/p(),format_string()(titres, avec contexte),format_text()(contenu riche, contexte obligatoire,noclean= danger),file_rewrite_pluginfile_urls()avant. Mustache :{{}}échappe,{{{}}}seulement sur du contenu déjà formaté.
Navigation : ← 06 — Events, Hooks, Tasks, Messages · 00 — Index de la partie