Skip to content

Race Condition in Livechat Room Creation allows multiple open rooms per visitor #38148

@harshjdhv

Description

@harshjdhv

Description:

A race condition exists in the Livechat room creation logic (getRoom), allowing a single visitor to have multiple open rooms simultaneously.

When establishing a new Livechat session—either via the getRoom internal method or the GET /api/v1/livechat/room endpoint—the server checks for an existing open room and, if none is found, proceeds to create a new one. However, this "Check-Then-Act" sequence is not atomic.

If multiple requests for the same visitor arrive within the same millisecond (e.g., due to network lag, double-clicking, or a custom widget implementation), they effectively race past the "Does a room exist?" check. Both requests receive a "No" response and proceed to create separate rooms, resulting in a single visitor owning two distinct open: true rooms in the rocketchat_room collection.

This violates the expected invariant that a visitor should only have one active conversation at a time.

Steps to reproduce:

  1. Use the reproduction script provided below (Node.js).
  2. The script registers a new visitor.
  3. It immediately fires two concurrent GET /api/v1/livechat/room requests for that visitor.
  4. Observe the response: Both requests return success, but with different room._id values.
  5. Check the database: The rocketchat_room collection will show multiple documents with v.token: <visitor_token>, t: 'l', and open: true.

Reproduction Script (reproduce_race.js):

const crypto = require('crypto');
const BASE_URL = 'http://localhost:3000';

// Note: Node 18+ required for native fetch, or install node-fetch
if (typeof fetch !== 'function') throw new Error('Fetch not found');

const visitorToken = crypto.randomUUID();

async function runTest() {
    console.log(`Testing with Visitor Token: ${visitorToken}`);

    // 1. Register Visitor
    await fetch(`${BASE_URL}/api/v1/livechat/visitor`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ visitor: { token: visitorToken } })
    });

    // 2. Fire Concurrent Room Requests
    const params = new URLSearchParams({ token: visitorToken });
    const url = `${BASE_URL}/api/v1/livechat/room?${params.toString()}`;

    const [resA, resB] = await Promise.all([ fetch(url), fetch(url) ]);
    const jsonA = await resA.json();
    const jsonB = await resB.json();

    const roomA = jsonA?.room?._id;
    const roomB = jsonB?.room?._id;

    if (roomA && roomB && roomA !== roomB) {
        console.log('🚨 RACE CONDITION CONFIRMED');
        console.log(`Room A: ${roomA}`);
        console.log(`Room B: ${roomB}`);
    } else {
        console.log('✅ Safe (No duplicates created)');
    }
}

runTest();

Expected behavior:

The server should serialize room creation or fail one of the requests.

  • Request A: Creates Room X.
  • Request B: Waits/Checks, sees Room X exists, and returns Room X.
  • Result: The visitor has exactly one open room.

Actual behavior:

The server processes both requests in parallel.

  • Request A: Sees no room -> Creates Room X.
  • Request B: Sees no room -> Creates Room Y.
  • Result: The visitor has two open rooms (Room X and Room Y).

Server Setup Information:

  • Version of Rocket.Chat Server: Develop (Latest)
  • Deployment Method: Source / Local
  • NodeJS Version: v22.16.0
  • MongoDB Version: N/A (Standard ReplicaSet)

Additional context

Root Cause Analysis:
The vulnerability lies in apps/meteor/app/livechat/server/lib/rooms.ts:

  1. getRoom calls LivechatRooms.findOneById(...) (or strict RID check).
  2. It fails to check findOneOpenByVisitorToken if the specific RID isn't found.
  3. Even if it did, the gap between "Check" and "Create" (QueueManager.requestRoom) is unprotected by any lock or unique constraint.

Suggested Fixes for Discussion:

  1. Database Constraint (Strongest): Add a partial unique index on { "v.token": 1 } where { open: true }. This prevents duplicates at the engine level but may require migration for legacy data.
  2. Application Lock: Wrap the creation logic in a Locker or Mutex keyed by visitorToken.
  3. Optimistic Upsert: Refactor creation to use findOneAndUpdate with $setOnInsert, ensuring atomicity.

Relevant logs:

Output from the reproduction script:

Testing with Visitor Token: 6e3a7317-0760-49fe-a209-2d1cfa8c06ec
...
Response A room: 2EYJDB2cK94t4wyCL
Response B room: Bq54Dj8QGkZHAZSkE

🚨 RACE CONDITION CONFIRMED
The server created TWO different rooms for the same visitor!

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions