Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

  1. 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 ») :
js
// 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' }]
})
  1. Activer l'authentification dans /etc/mongod.conf, puis redémarrer le service (sudo systemctl restart mongod) :
yaml
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ôleDroitsPour qui
readLire toutes les collections d'une baseUn outil de reporting, un compte de consultation
readWriteLire et écrire, créer des index et des collections, en supprimerUne application, dans le cas simple
dbAdminIndex, statistiques, validation, sans lire les donnéesUn administrateur de base
userAdminCréer des utilisateurs et des rôles dans une baseÀ réserver : il peut se donner tous les droits
dbOwnerreadWrite + dbAdmin + userAdmin sur une baseLe responsable d'une base
backup, restoreSauvegarder et restaurer toutes les basesLe compte du script de sauvegarde
clusterMonitorLire l'état et les statistiques du serveurLa supervision
rootToutL'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 :

js
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 :

js
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 :

text
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) :

CompteRôleBaseUtilisé par
adminrootadminL'administrateur, ponctuellement, avec un mot de passe fort
appliappliFrais (sur mesure)appli_fraisL'API, mot de passe dans le gestionnaire de secrets du serveur
sauvegardebackup, restoreadminLe script de sauvegarde nocturne
reportingreadappli_fraisL'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. bindIp limité à l'adresse locale ou privée, pare-feu qui n'autorise que le serveur de l'API, port Docker publié sur 127.0.0.1. Sur Atlas, la liste d'accès IP (Network Access) ne contient que les adresses de tes serveurs, pas 0.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: requireTLS dans mongod.conf, avec un certificat (certificats et PKI). Atlas impose TLS ; une chaîne mongodb+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.

js
// 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 :

json
{ "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.

Requête légitime"email": "lucas.martin@…","mdp": "Tr0p-Facile"findOne({ email: '…',mdp: 'Tr0p-Facile' })deux comparaisons à des valeursRequête malveillante"email": "lucas.martin@…","mdp": { "$ne": null }findOne({ email: '…',mdp: { $ne: null } })« mot de passe différent de null » : vrai,l'attaquant est connecté sans le connaîtreLa correction1. valider le typemdp: z.string()objet refusé : 4222. chercher le comptefindOne({ email })sans le mot de passe3. vérifier l'empreinteargon2.verify(…)dans le codeEn plus : mongoose.set('sanitizeFilter', true) neutralise tout opérateur reçu.
L'injection NoSQL : un objet avec un opérateur à la place d'une valeur

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 :

  1. Valider le type de chaque entrée. Un schéma Zod qui attend z.string() refuse l'objet { "$ne": null } avec une erreur 422, avant toute requête (chapitre 12). C'est la défense principale : une donnée validée n'est plus qu'une donnée.
  2. 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.
  3. Activer sanitizeFilter dans Mongoose : mongoose.set('sanitizeFilter', true) enveloppe dans un $eq tout objet à clé $ qui arrive dans un filtre, ce qui le rend inoffensif (et provoque une CastError sur un champ de type chaîne). Les opérateurs écrits par ton propre code s'écrivent alors avec mongoose.trusted(…).
  4. 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.
  5. Bannir $where, $function et $accumulator avec une donnée externe : ils exécutent du JavaScript sur le serveur. Atlas les refuse sur son offre gratuite ; sur ton serveur, l'option security.javascriptEnabled: false les 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 :

js
// 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: … }). Le role n'y est pas : la requête est refusée en 422 (« Clé non reconnue : "role" »), et le refus apparaît dans les journaux.
  • Ne jamais passer req.body à Mongoose : passe req.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 de FORFAITS, chapitre 12).
  • Dans l'autre sens, ne pas tout renvoyer : select: false sur 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 }), et 404 si 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/statistiques pour un visiteur. La parade : le middleware exigerRole('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 MongoDBCe que fait l'API d'AppliFrais
API1 BOLAfindById(req.params.id) sans vérifier le propriétairePropriétaire dans le filtre, 404 sinon
API2 Authentification défaillanteJetons sans expiration, clé faible, mots de passe en clairJWT courts signés avec une clé forte (cours Node.js), empreintes Argon2id
API3 BOPLAreq.body passé à create ou findByIdAndUpdate ; empreintes renvoyéesSchémas Zod stricts, select: false, projections
API4 Consommation illimitée?limite=1000000, corps énormes, regex coûteuseslimite plafonnée à 100, express.json({ limit: '100kb' }), pas de regex construite avec une saisie ; limitation de débit (cours API REST)
API5 BFLARoute du comptable accessible à un visiteurexigerRole('comptable')
API6 Accès sans limite à un flux métierCréer des fiches en boucle, rejouer une mise en paiementIndex unique visiteur_1_mois_1, transaction et index unique sur paiements.fiche
API7 SSRFL'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 configurationMongoDB sans authentification ou exposé ; traces d'erreur renvoyées au clientAuthentification, bindIp, compte au moindre privilège, 500 sans détail
API9 Inventaire défaillantUne ancienne version de l'API oubliée, branchée sur la même baseVersionnement /v1, anciennes versions retirées
API10 Consommation non sûre d'API tiercesLes données d'une API partenaire enregistrées sans contrôleValider 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
js
// 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.

js
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.mdp peut être { "$ne": null }. Valider avec z.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: v renvoie 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.body peut contenir etat, montantValide, visiteur. Schéma Zod strict des seuls champs modifiables.
  • Validation absente : sans runValidators: true, Mongoose ne vérifie pas la mise à jour ; et req.params.id n'est pas validé (une CastError donnera une 500 si aucun middleware d'erreurs ne la traite).
  • null renvoyé en 200 si la fiche n'existe pas : il faut un 404.

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.

yaml
# mongod.conf
net:
  port: 27017
  bindIp: 0.0.0.0
security:
  authorization: disabled
bash
# .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
  1. bindIp: 0.0.0.0 : la base écoute sur toutes les interfaces, donc sur l'adresse publique. Limiter à 127.0.0.1 si l'API est sur la même machine, ou à l'adresse privée, et filtrer le port 27017 au pare-feu.
  2. authorization: disabled : n'importe qui peut tout lire et tout supprimer. authorization: enabled.
  3. Le compte admin (sans doute root) est utilisé par l'API : créer un compte dédié au moindre privilège.
  4. Mot de passe trivial (admin) : un mot de passe long et aléatoire.
  5. Le .env est 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 .gitignore et fournir un .env.example.
  6. Pas de TLS entre l'API et une base distante : net.tls.mode: requireTLS et ?tls=true dans la chaîne de connexion.
yaml
net:
  port: 27017
  bindIp: 127.0.0.1
security:
  authorization: enabled
  javascriptEnabled: false     # désactive $where, $function et $accumulator
bash
# .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é ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Sauvegarder et restaurer MongoDB : mongodump et mongorestoreRevoir le sommaire du cours