Skip to Content
MoodlePartie 5 — Architecture du cœur de Moodle07 — Cache (MUC), sessions et sécurité côté code

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

  1. Architecture du MUC : définitions, stores, loaders
  2. Déclarer une définition de cache : db/caches.php
  3. Utiliser le cache : cache::make() et le pattern get-or-compute
  4. Invalidation : purges, événements, clés versionnées
  5. Les sessions : $SESSION, stockage, verrouillage

Partie B — Sécurité côté code

  1. Les contextes : la colonne vertébrale de la sécurité
  2. Authentification : require_login() et les constantes de page
  3. Capabilities côté code : has_capability() et compagnie
  4. CSRF : le sesskey
  5. Nettoyage des entrées : required_param() et les types PARAM_*
  6. Sortie et XSS : s(), format_string(), format_text()
  7. Le pattern de page sécurisée complet
  8. Checklist sécurité finale et outillage

Résumé — points clés


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, queryKey scheme, 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 le queryClient. L’invalidation par événement (invalidationevents) est l’analogue direct de queryClient.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 :

ModeConstantePortéeAnalogie
Applicationcache_store::MODE_APPLICATIONPartagé entre tous les utilisateurs et toutes les requêtesRedis partagé, cache CDN
Sessioncache_store::MODE_SESSIONPar utilisateur, survit d’une requête à l’autresessionStorage, state serveur par session
Requestcache_store::MODE_REQUESTDurée d’une seule requête PHPMemoization, 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 cache calendar_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 (voir canuselocalstore en §2).
  • static : mémoire PHP de la requête courante. C’est le store qui sert de base au mode request.

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\context en 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 :

  • mode est la seule clé obligatoire. Tout le reste a des valeurs par défaut prudentes (simplekeys => false, simpledata => false, pas d’accélération statique).
  • simplekeys et simpledata sont des promesses, pas des options cosmétiques. Si vous déclarez simplekeys => true puis passez une clé contenant un /, le MUC lèvera une exception en mode debug. Si vous déclarez simpledata => true puis 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.php et c’est réglé.

⚠️ Piège : staticacceleration sans simpledata => true a un coût caché — le loader clone les objets à chaque get() 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éclarez simpledata => true pour court-circuiter ce clonage. Et inversement : si vous stockez des objets sans accélération statique, chaque get() désérialise une copie fraîche, donc pas de problème de partage — mais un get() 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() retourne false pour signaler l’absence. Si vous avez besoin de cacher des valeurs qui peuvent légitimement valoir false (ou que false est 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]), stocker 0/1, ou stocker une chaîne sentinelle. C’est exactement le même problème que Map.get() retournant undefined en JS quand on stocke undefined comme 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 la queryKey, le bloc « calcul » est la queryFn, et staticacceleration joue 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 :

  1. 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.
  2. 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 sert get_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 cours

get_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 les get_string()). Purgé par --lang.
  • core/langmenu : la liste des langues installées.
  • core/config : la table config_plugins entière — c’est pour ça que get_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_modules ou course_sections en 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 de course/lib.php), elles s’en chargent. Règle : SQL direct sur les tables de structure de cours = interdiction, ou au minimum rebuild_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 UPDATE implicite posé sur l’utilisateur à chaque requête. Votre front peut paralléliser tant qu’il veut avec Promise.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_SESSION est 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 table course_modules, celui que vous voyez dans les URLs mod/quiz/view.php?id=123), et non l’id de l’instance d’activité (l’id dans la table quiz, 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. Utilisez get_coursemodule_from_id() / get_coursemodule_from_instance() pour convertir.

💡 Pour un dev React : le contexte est le paramètre scope de 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->path est 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 :

  1. 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->wantsurl pour y revenir après connexion).
  2. Connexion invité automatique si le site l’autorise et $autologinguest est true.
  3. Le compte n’est pas en cours de suppression, le site n’est pas en maintenance (sauf admins).
  4. Le mot de passe n’est pas à changer obligatoirement — sinon redirection forcée.
  5. Le profil est complet (champs obligatoires remplis) — sinon redirection vers l’édition du profil.
  6. Les politiques du site sont acceptées (RGPD, chartes via tool_policy) — sinon redirection vers l’acceptation.
  7. Si un $course est fourni : le cours est visible (ou l’utilisateur a moodle/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).
  8. Si un $cm est 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 sans require_login() (ou justification explicite du type NO_MOODLE_COOKIES + contenu réellement public). La règle est absolue : tout fichier PHP accessible par le web appelle require_login ou 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_capability est le hook useCan('post:delete', resource) qui pilote le rendu conditionnel ; require_capability est 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_capability pour 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 champ sesskey caché dans chaque formulaire et le vérifie dans get_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() = csrfToken injecté 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 votre z.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). Un PARAM_INT sur "12abc" donne 12, 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 :

TypeAccepte / transformeExemple
PARAM_INTEntier ; cast, tronque le non-numérique"42"42, "12abc"12, "abc"0
PARAM_FLOATFlottant, point décimal uniquement"3.14"3.14 — voir piège ci-dessous
PARAM_BOOLBooléen souple"1", "true", "on", "yes"true
PARAM_ALPHALettres a-zA-Z uniquement, supprime le reste"abc123""abc"
PARAM_ALPHANUMLettres + chiffres"ab-12""ab12"
PARAM_ALPHANUMEXTLettres, chiffres, _ et -idéal pour les slugs, noms d’action
PARAM_TEXTTexte multilingue, supprime les balises HTML"<b>hi</b>""hi"
PARAM_NOTAGSComme TEXT, strip agressif de tout tagrecherche, titres saisis
PARAM_RAWAucun nettoyagecontenu d’éditeur HTML (nettoyé à la sortie !)
PARAM_RAW_TRIMMEDAucun nettoyage, juste trim()idem, sans espaces parasites
PARAM_EMAILEmail valide, sinon chaîne vide"a@b.fr" ok, "nope"""
PARAM_URLURL bien formée (http/https/ftp)sinon ""
PARAM_LOCALURLURL relative au site ou pointant vers luianti open-redirect : indispensable pour les paramètres returnurl
PARAM_FILENom de fichier sûr : supprime /, \, .."../etc/passwd""etcpasswd"
PARAM_PATHChemin relatif sûr : garde /, neutralise ..fichiers dans un dossier
PARAM_SAFEDIRa-zA-Z0-9_- : noms de répertoires/pluginsinclusion dynamique sûre
PARAM_COMPONENTNom de composant frankenstyle (mod_quiz)valider un paramètre component
PARAM_SEQUENCEChiffres et virgules : "1,4,12"listes d’ids compactes

Quelques-uns méritent un commentaire :

  • PARAM_LOCALURL est un type de sécurité, pas de format : tout paramètre returnurl/redirect qui 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_SAFEDIR et PARAM_COMPONENT protègent les inclusions et instanciations dynamiques : si vous faites un jour require($CFG->dirroot . "/mod/{$modname}/lib.php"), $modname doit être passé par PARAM_SAFEDIR (traversal de chemin sinon).
  • PARAM_RAW n’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 via format_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_FLOAT et les nombres saisis par l’humain : PARAM_FLOAT exige le point comme séparateur décimal. Un utilisateur français tape 12,5 dans 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 en PARAM_RAW_TRIMMED (ou via moodleform avec setType(..., PARAM_RAW)) puis convertir avec unformat_float($value), qui comprend le séparateur décimal de la locale de l’utilisateur. Symétriquement, affichez avec format_float(). PARAM_FLOAT reste 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 tableau

C’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 votre dangerouslySetInnerHTML={{ __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). Et noclean: true est l’équivalent exact d’appeler dangerouslySetInnerHTML sans 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;amp;).

⚠️ Piège — le double formatage : appliquer format_string() à une chaîne, la stocker, puis la réafficher via format_string() (ou l’injecter dans {{}}) produit des &amp; 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, tout redirect() 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 type PARAM_* le plus restrictif possible.
  • Les returnurl sont en PARAM_LOCALURL ; les noms de plugins/dossiers en PARAM_SAFEDIR/PARAM_COMPONENT ; le contenu d’éditeur en PARAM_RAW.
  • Les nombres saisis par l’humain passent par unformat_float().

Authentification / autorisation

  • require_login() (avec $course/$cm si pertinent) sur chaque page — ou une justification écrite de son absence.
  • Un contexte précis est résolu (\core\context\module plutôt que system dès que possible).
  • require_capability() protège chaque action ; has_capability() conditionne chaque élément d’UI correspondant. Pas de is_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 echo de variable nue : s()/p(), format_string() (+ contexte) ou format_text() (+ contexte) partout.
  • file_rewrite_pluginfile_urls() avant format_text().
  • Pas de noclean/trusted sans justification écrite.
  • Dans Mustache : {{{ }}} uniquement sur du contenu déjà passé par format_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 legacy cache, cache_store valides).
  • 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 le ttl.
  • 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èle cacherev du core). Connaître coursemodinfo/get_fast_modinfo() et l’obligation de rebuild_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_SESSION et \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, alias context_course etc. 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) avant config.php.
  • Capabilities : has_capability pour l’UI, require_capability pour l’action — les deux. Jamais is_siteadmin() de confort ; créez vos capabilities. get_enrolled_users($context, $capability) remplace get_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_LOCALURL pour les returnurl ; PARAM_RAW (pas PARAM_TEXT !) pour le contenu d’éditeur ; unformat_float() pour les nombres saisis.
  • Sorties : nettoyage léger à l’entrée, échappement systématique à la sorties()/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