Chapitre 1322 minIntermédiaire
Sécuriser MongoDB : authentification, rôles et injection NoSQL
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- activer l'authentification et créer des comptes au moindre privilège ;
- éviter d'exposer la base sur le réseau ;
- expliquer et empêcher l'injection NoSQL ;
- protéger une API contre l'affectation de masse et les accès aux données des autres.
3 exercices corrigés1 schéma
Sécuriser MongoDB, c'est protéger les données à deux niveaux. Côté serveur : activer l'authentification, donner à chaque compte les seuls droits dont il a besoin, ne jamais exposer la base sur Internet, chiffrer les échanges et tenir le serveur à jour. Côté application : empêcher l'injection NoSQL, l'affectation de masse et les accès aux données des autres. Ce chapitre applique ces mesures à AppliFrais ; les principes généraux de la sécurité applicative (OWASP Top 10, contrôle d'accès, journalisation) sont dans le cours de sécurité, et ceux des API dans le cours API REST.
Pourquoi c'est sérieux
MongoDB a longtemps démarré, par défaut, sans mot de passe. Depuis 2017, des vagues d'attaques automatisées parcourent Internet à la recherche de serveurs MongoDB exposés sans authentification : des dizaines de milliers de bases ont été vidées, et remplacées par une simple collection contenant une demande de rançon. Aucune faille logicielle n'était en cause : seulement une mauvaise configuration, le risque API8 de l'OWASP. Depuis la version 3.6, MongoDB n'écoute plus que sur 127.0.0.1 par défaut ; mais une seule ligne de configuration (ou un ports: "27017:27017" dans Docker) suffit à rouvrir la porte.
Les symptômes d'une base compromise de cette façon sont caractéristiques : collections disparues, une collection au nom inattendu avec un message en anglais demandant un paiement, connexions depuis des adresses inconnues dans le journal du serveur. La réponse passe par la gestion d'incident, la restauration d'une sauvegarde (chapitre 14) et, si des données personnelles ont fuité, la notification à la CNIL dans les 72 heures.
Activer l'authentification
Une installation native démarre sans contrôle d'accès. Pour l'activer :
- Créer d'abord un administrateur. Tant qu'aucun utilisateur n'existe, MongoDB autorise une connexion depuis la machine elle-même pour créer le premier (l'« exception localhost ») :
// mongosh, sur le serveur
use admin
db.createUser({
user: 'admin',
pwd: passwordPrompt(), // demande le mot de passe sans l'afficher ni l'enregistrer
roles: [{ role: 'root', db: 'admin' }]
})
- Activer l'authentification dans
/etc/mongod.conf, puis redémarrer le service (sudo systemctl restart mongod) :
security:
authorization: enabled
net:
port: 27017
bindIp: 127.0.0.1 # l'API est sur la même machine ; sinon, l'adresse privée du serveur
Désormais, une connexion sans identifiants ne peut plus rien lire (not authorized on appli_frais to execute command { aggregate: "fiches" … }), et une fois le premier utilisateur créé, l'exception localhost se referme (Command createUser requires authentication). Avec l'image Docker, les variables MONGO_INITDB_ROOT_USERNAME et MONGO_INITDB_ROOT_PASSWORD font ces deux étapes au premier démarrage (chapitre 3) ; Atlas impose toujours l'authentification.
Des comptes et des rôles : le moindre privilège
MongoDB attribue les droits par rôles (RBAC, contrôle d'accès à base de rôles, le principe). Un rôle regroupe des privilèges (des actions sur des ressources) ; un utilisateur reçoit un ou plusieurs rôles. Les rôles intégrés les plus utiles :
| Rôle | Droits | Pour qui |
|---|---|---|
read | Lire toutes les collections d'une base | Un outil de reporting, un compte de consultation |
readWrite | Lire et écrire, créer des index et des collections, en supprimer | Une application, dans le cas simple |
dbAdmin | Index, statistiques, validation, sans lire les données | Un administrateur de base |
userAdmin | Créer des utilisateurs et des rôles dans une base | À réserver : il peut se donner tous les droits |
dbOwner | readWrite + dbAdmin + userAdmin sur une base | Le responsable d'une base |
backup, restore | Sauvegarder et restaurer toutes les bases | Le compte du script de sauvegarde |
clusterMonitor | Lire l'état et les statistiques du serveur | La supervision |
root | Tout | L'administrateur, pour l'administration seulement |
Un rôle se donne dans une base : { role: 'readWrite', db: 'appli_frais' } ne donne aucun droit sur les autres bases. Le compte de l'API d'AppliFrais se crée donc dans sa base, avec les droits de cette base seulement :
use appli_frais
db.createUser({
user: 'appli',
pwd: passwordPrompt(),
roles: [{ role: 'readWrite', db: 'appli_frais' }]
})
Sa chaîne de connexion devient mongodb://appli:<mot-de-passe>@localhost:27017/appli_frais : sans authSource, l'authentification se fait dans la base de la chaîne, appli_frais, où le compte a été créé.
Un rôle sur mesure
readWrite permet encore de supprimer une collection entière (drop). Pour aller plus loin dans le moindre privilège, on crée un rôle qui ne donne que les actions dont l'API a besoin, collection par collection :
use appli_frais
db.createRole({
role: 'appliFrais',
privileges: [
{ resource: { db: 'appli_frais', collection: 'fiches' }, actions: ['find', 'insert', 'update', 'remove'] },
{ resource: { db: 'appli_frais', collection: 'visiteurs' }, actions: ['find', 'update'] },
{ resource: { db: 'appli_frais', collection: 'paiements' }, actions: ['find', 'insert'] }
],
roles: []
})
db.createUser({ user: 'appli', pwd: passwordPrompt(), roles: [{ role: 'appliFrais', db: 'appli_frais' }] })
Avec ce compte, l'API lit et écrit les fiches, mais ne peut ni supprimer la collection, ni créer un visiteur, ni effacer un paiement, ni toucher aux comptes :
db.fiches.drop() → not authorized on appli_frais to execute command { drop: "fiches" … }
db.visiteurs.insertOne(…) → not authorized on appli_frais to execute command { insert: "visiteurs" … }
db.paiements.deleteOne(…) → not authorized on appli_frais to execute command { delete: "paiements" … }
Un paiement ne s'efface plus par l'application, même en cas de faille : c'est une garantie d'intégrité et de preuve, deux critères du DICP.
Pour une organisation, on écrit une matrice d'habilitations, comme pour une base relationnelle (traduire une matrice en droits SQL) :
| Compte | Rôle | Base | Utilisé par |
|---|---|---|---|
admin | root | admin | L'administrateur, ponctuellement, avec un mot de passe fort |
appli | appliFrais (sur mesure) | appli_frais | L'API, mot de passe dans le gestionnaire de secrets du serveur |
sauvegarde | backup, restore | admin | Le script de sauvegarde nocturne |
reporting | read | appli_frais | L'outil de tableaux de bord de la direction |
Le réseau, le chiffrement et les mises à jour
- Ne jamais exposer le port 27017 sur Internet.
bindIplimité à l'adresse locale ou privée, pare-feu qui n'autorise que le serveur de l'API, port Docker publié sur127.0.0.1. Sur Atlas, la liste d'accès IP (Network Access) ne contient que les adresses de tes serveurs, pas0.0.0.0/0. - Chiffrer les échanges avec TLS dès que l'API et la base ne sont pas sur la même machine :
net.tls.mode: requireTLSdansmongod.conf, avec un certificat (certificats et PKI). Atlas impose TLS ; une chaînemongodb+srv://l'active automatiquement. - Chiffrer au repos : le chiffrement des fichiers de données est intégré à Atlas et à l'édition Enterprise ; avec Community, on chiffre le disque du serveur (LUKS, BitLocker). Pour quelques champs très sensibles, le chiffrement côté client (Queryable Encryption) chiffre la donnée dans l'application, avant qu'elle n'atteigne le serveur.
- Mettre à jour. Chaque version corrective corrige des failles : la 9.0.4, publiée le 13 octobre 2026, en corrige cinq (CVE). Suis les bulletins de sécurité de MongoDB et applique les correctifs, comme pour le reste du système.
- Journaliser les connexions et les échecs d'authentification (le journal de
mongod), et les surveiller (journalisation). L'audit détaillé des opérations est réservé à Atlas et à l'édition Enterprise.
L'injection NoSQL
L'injection NoSQL consiste, pour un attaquant, à faire passer un opérateur MongoDB à la place d'une simple valeur dans un filtre, pour en changer le sens. Elle ne passe pas par une chaîne de texte comme l'injection SQL : en JavaScript, un filtre est un objet, et une API qui recopie dans ce filtre un objet reçu en JSON recopie aussi ses opérateurs.
// VULNÉRABLE : le corps JSON est placé tel quel dans le filtre
const compte = await Visiteur.findOne({ email: req.body.email, mdp: req.body.mdp });
Un client normal envoie {"email": "lucas.martin@gsb.example", "mdp": "…"}. Un attaquant envoie :
{ "email": "lucas.martin@gsb.example", "mdp": { "$ne": null } }
Le filtre devient « adresse égale à celle de Lucas et mot de passe différent de null » : il est vrai pour Lucas, et l'attaquant est connecté sans connaître le mot de passe. Avec { "$regex": "^a" } sur l'adresse, il énumère les comptes ; avec { "$gt": "" } partout, il obtient le premier compte de la collection, souvent un administrateur.
Les symptômes : des connexions réussies sans mot de passe valide, des requêtes qui renvoient beaucoup plus de documents que prévu, des corps de requête contenant $ne, $gt, $regex ou $where dans les journaux de l'API ou du pare-feu applicatif (WAF). La détection passe par la revue de code (toute donnée de requête placée dans un filtre sans validation) et par les tests de sécurité automatisés (tests de sécurité).
Les contre-mesures, de la plus importante à la moins importante :
- Valider le type de chaque entrée. Un schéma Zod qui attend
z.string()refuse l'objet{ "$ne": null }avec une erreur422, avant toute requête (chapitre 12). C'est la défense principale : une donnée validée n'est plus qu'une donnée. - Ne jamais mettre un secret dans un filtre. On cherche le compte par son adresse, puis on compare l'empreinte du mot de passe dans le code (
argon2.verify, stocker un mot de passe). Le mot de passe ne touche plus la base. - Activer
sanitizeFilterdans Mongoose :mongoose.set('sanitizeFilter', true)enveloppe dans un$eqtout objet à clé$qui arrive dans un filtre, ce qui le rend inoffensif (et provoque uneCastErrorsur un champ de type chaîne). Les opérateurs écrits par ton propre code s'écrivent alors avecmongoose.trusted(…). - Avec le driver, comparer explicitement avec
$eq:{ email: { $eq: req.body.email } }traite la valeur comme une valeur, même si c'est un objet. - Bannir
$where,$functionet$accumulatoravec une donnée externe : ils exécutent du JavaScript sur le serveur. Atlas les refuse sur son offre gratuite ; sur ton serveur, l'optionsecurity.javascriptEnabled: falseles désactive complètement.
L'affectation de masse
L'affectation de masse (mass assignment) se produit quand une API recopie dans un document tous les champs reçus, y compris ceux que le client ne devrait jamais pouvoir fixer :
// VULNÉRABLE : le visiteur modifie son profil… et tout le reste
export async function modifierProfil(req, res) {
const v = await Visiteur.findByIdAndUpdate(req.utilisateur.id, req.body, { returnDocument: 'after' });
res.json(v);
}
Un visiteur envoie {"region": "Ain", "role": "comptable"} : il devient comptable et valide lui-même ses fiches. Sur une fiche, {"etat": "VA", "montantValide": 5000} produit le même effet. C'est le risque API3:2023, Broken Object Property Level Authorization, de l'OWASP API Security Top 10 : l'utilisateur a le droit de modifier l'objet, mais pas toutes ses propriétés.
Les parades :
- Un schéma strict par route, qui ne contient que les champs modifiables par ce rôle :
z.strictObject({ region: z.string().max(50), telephone: … }). Lerolen'y est pas : la requête est refusée en422(« Clé non reconnue : "role" »), et le refus apparaît dans les journaux. - Ne jamais passer
req.bodyà Mongoose : passereq.valide.body, ou construis l'objet champ par champ. - Fixer côté serveur ce qui relève du serveur : le propriétaire (
visiteur: req.utilisateur.id), l'état initial, les montants (les tarifs viennent deFORFAITS, chapitre 12). - Dans l'autre sens, ne pas tout renvoyer :
select: falsesur l'empreinte de mot de passe dans le schéma, et une projection sur chaque lecture (select('mois etat montantValide')). Renvoyer des champs internes au client, c'est l'autre moitié du risque API3.
Le contrôle d'accès dans les requêtes
Deux risques de l'OWASP API Top 10 concernent directement les filtres MongoDB :
- API1:2023, BOLA (Broken Object Level Authorization) : lire ou modifier l'objet d'un autre en changeant un identifiant dans l'URL. La parade d'AppliFrais : le propriétaire dans le filtre (
{ _id, visiteur: req.utilisateur.id }), et404si rien ne correspond (contrôle d'accès). - API5:2023, BFLA (Broken Function Level Authorization) : appeler une fonction réservée à un autre rôle, comme
GET /v1/fiches/statistiquespour un visiteur. La parade : le middlewareexigerRole('comptable')sur chaque route sensible, vérifié par un test automatisé par rôle.
Les ObjectId ne protègent pas : ils sont longs, mais pas secrets ni imprévisibles (ils contiennent la date et un compteur). Un identifiant qui ne se devine pas ne remplace jamais le contrôle d'accès.
L'OWASP API Security Top 10 appliqué à l'API
| Risque (2023) | Dans une API Node.js et MongoDB | Ce que fait l'API d'AppliFrais |
|---|---|---|
| API1 BOLA | findById(req.params.id) sans vérifier le propriétaire | Propriétaire dans le filtre, 404 sinon |
| API2 Authentification défaillante | Jetons sans expiration, clé faible, mots de passe en clair | JWT courts signés avec une clé forte (cours Node.js), empreintes Argon2id |
| API3 BOPLA | req.body passé à create ou findByIdAndUpdate ; empreintes renvoyées | Schémas Zod stricts, select: false, projections |
| API4 Consommation illimitée | ?limite=1000000, corps énormes, regex coûteuses | limite plafonnée à 100, express.json({ limit: '100kb' }), pas de regex construite avec une saisie ; limitation de débit (cours API REST) |
| API5 BFLA | Route du comptable accessible à un visiteur | exigerRole('comptable') |
| API6 Accès sans limite à un flux métier | Créer des fiches en boucle, rejouer une mise en paiement | Index unique visiteur_1_mois_1, transaction et index unique sur paiements.fiche |
| API7 SSRF | L'API télécharge une URL fournie par le client (un justificatif) | Pas d'URL fournie par le client ; sinon, liste blanche de domaines |
| API8 Mauvaise configuration | MongoDB sans authentification ou exposé ; traces d'erreur renvoyées au client | Authentification, bindIp, compte au moindre privilège, 500 sans détail |
| API9 Inventaire défaillant | Une ancienne version de l'API oubliée, branchée sur la même base | Versionnement /v1, anciennes versions retirées |
| API10 Consommation non sûre d'API tierces | Les données d'une API partenaire enregistrées sans contrôle | Valider aussi ce qui vient des autres API avec Zod |
Données personnelles et RGPD
Les fiches de frais et les profils des visiteurs sont des données personnelles : déplacements, montants, adresse électronique. Le RGPD s'applique à ta base MongoDB comme à une base relationnelle :
- minimisation : un schéma Mongoose qui ne garde que les champs utiles (le mode strict écarte le reste) ;
- durée de conservation : un index TTL supprime automatiquement les données temporaires (jetons, journaux techniques) ; pour les fiches, une suppression ou une anonymisation planifiée après la durée légale de conservation des pièces comptables ;
- droit d'accès : une agrégation qui rassemble toutes les données d'une personne ;
- sécurité : les mesures de ce chapitre, et des sauvegardes elles-mêmes protégées (chapitre 14).
Exercices
Exercice 1 sur 3 Créer les comptes Réussi À revoir
Écris les commandes mongosh qui créent, pour la base appli_frais : (1) le compte appli de l'API, qui peut lire et modifier les fiches et les visiteurs, mais ne peut supprimer ni visiteur ni collection ; (2) le compte stats d'un outil de tableaux de bord, qui ne peut que lire ; (3) le compte sauvegarde du script nocturne. Précise dans quelle base chacun est créé.
Voir la réponseMasquer la réponse de l'exercice 1
// 1. Dans appli_frais : un rôle sur mesure
use appli_frais
db.createRole({
role: 'appliFrais',
privileges: [
{ resource: { db: 'appli_frais', collection: 'fiches' }, actions: ['find', 'insert', 'update', 'remove'] },
{ resource: { db: 'appli_frais', collection: 'visiteurs' }, actions: ['find', 'update'] }
],
roles: []
})
db.createUser({ user: 'appli', pwd: passwordPrompt(), roles: [{ role: 'appliFrais', db: 'appli_frais' }] })
// 2. Dans appli_frais : le rôle intégré read
db.createUser({ user: 'stats', pwd: passwordPrompt(), roles: [{ role: 'read', db: 'appli_frais' }] })
// 3. Dans admin : backup et restore portent sur toute l'instance
use admin
db.createUser({ user: 'sauvegarde', pwd: passwordPrompt(),
roles: [{ role: 'backup', db: 'admin' }, { role: 'restore', db: 'admin' }] })
Le compte appli n'a ni insert ni remove sur visiteurs (la création des comptes passe par l'administrateur), et n'a aucune action d'administration comme dropCollection : la suppression d'une collection lui est refusée. remove sur fiches lui permet de supprimer une fiche en cours, ce que l'API autorise. Si le script de restauration n'est pas le même que celui de sauvegarde, sépare backup et restore dans deux comptes.
Tu avais trouvé ?
Exercice 2 sur 3 Analyser un contrôleur Réussi À revoir
Repère les failles de ce contrôleur et propose une correction.
router.post('/connexion', async (req, res) => {
const v = await Visiteur.findOne({ email: req.body.email, mdp: req.body.mdp });
if (!v) return res.status(401).json({ title: 'Identifiants invalides' });
res.json({ jeton: creerJeton(v), visiteur: v });
});
router.patch('/fiches/:id', authentifier, async (req, res) => {
const fiche = await Fiche.findByIdAndUpdate(req.params.id, req.body, { returnDocument: 'after' });
res.json(fiche);
});
Voir la réponseMasquer la réponse de l'exercice 2
Connexion.
- Injection NoSQL :
req.body.mdppeut être{ "$ne": null }. Valider avecz.strictObject({ email: z.email(), mdp: z.string().min(1).max(200) }). - Mot de passe en clair dans la base et comparé dans le filtre : chercher par adresse, puis
argon2.verify(v.mdpHash, mdp). - Fuite de données :
visiteur: vrenvoie tout le document, empreinte comprise. Renvoyer seulement{ id, nom, prenom, role }.
Modification.
- BOLA : n'importe quel utilisateur connecté modifie n'importe quelle fiche. Filtre
{ _id, visiteur: req.utilisateur.id, etat: 'CR' }. - Affectation de masse :
req.bodypeut conteniretat,montantValide,visiteur. Schéma Zod strict des seuls champs modifiables. - Validation absente : sans
runValidators: true, Mongoose ne vérifie pas la mise à jour ; etreq.params.idn'est pas validé (uneCastErrordonnera une500si aucun middleware d'erreurs ne la traite). nullrenvoyé en200si la fiche n'existe pas : il faut un404.
Tu avais trouvé ?
Exercice 3 sur 3 Revue de configuration Réussi À revoir
Un étudiant présente cette configuration de production pour son E6. Relève les problèmes et propose une configuration corrigée.
# mongod.conf
net:
port: 27017
bindIp: 0.0.0.0
security:
authorization: disabled
# .env de l'API, versionné dans le dépôt Git
MONGODB_URI=mongodb://admin:admin@51.68.12.34:27017/appli_frais?authSource=admin
Voir la réponseMasquer la réponse de l'exercice 3
bindIp: 0.0.0.0: la base écoute sur toutes les interfaces, donc sur l'adresse publique. Limiter à127.0.0.1si l'API est sur la même machine, ou à l'adresse privée, et filtrer le port 27017 au pare-feu.authorization: disabled: n'importe qui peut tout lire et tout supprimer.authorization: enabled.- Le compte
admin(sans douteroot) est utilisé par l'API : créer un compte dédié au moindre privilège. - Mot de passe trivial (
admin) : un mot de passe long et aléatoire. - Le
.envest versionné : le secret est dans l'historique Git, même s'il est supprimé ensuite. Changer le mot de passe, retirer le fichier du dépôt, l'ajouter au.gitignoreet fournir un.env.example. - Pas de TLS entre l'API et une base distante :
net.tls.mode: requireTLSet?tls=truedans la chaîne de connexion.
net:
port: 27017
bindIp: 127.0.0.1
security:
authorization: enabled
javascriptEnabled: false # désactive $where, $function et $accumulator
# .env (dans .gitignore)
MONGODB_URI=mongodb://appli:<mot de passe aléatoire de 32 caractères>@127.0.0.1:27017/appli_frais
Tu avais trouvé ?