Skip to content

Security bug: shared conversation forks retain a private agent reference from the source user #1327

@3em0

Description

@3em0

Server

  • Cloud (https://app.khoj.dev)
  • Self-Hosted Docker
  • Self-Hosted Python package
  • Self-Hosted source code

Clients

  • Web browser
  • Desktop/mobile app
  • Obsidian
  • Emacs
  • WhatsApp

OS

  • Windows
  • macOS
  • Linux
  • Android
  • iOS

Khoj version

Source snapshot e8631261400e0a04c5063e91e498b549976ffc53 from master (git describe --always: e863126). Released version range is unknown from the local checkout.

Describe the bug

Forking a public/shared conversation can create a new conversation for the recipient that still points to the original user's private Agent.

The share flow copies conversation.agent into PublicConversation, and the fork flow copies public_conversation.agent into the recipient's new private Conversation. Later chat execution accepts conversation.agent as the active agent without first checking whether the current user can access that agent.

I re-checked the document retrieval path and did not confirm direct private knowledge-base retrieval through the normal Notes search path: execute_search() has an AgentAdapters.ais_agent_accessible() guard that rejects private agents owned by another user. However, the inherited private agent is still used by chat behavior before/around retrieval, including agent persona/system prompt construction, input/output tool selection, and in some cases agent chat-model selection. That means a public conversation slug/fork can carry a security-sensitive private agent reference across users without reauthorization.

Current Behavior

Relevant local source evidence:

  • src/khoj/database/adapters/__init__.py:999 creates PublicConversation with agent=conversation.agent.
  • src/khoj/database/adapters/__init__.py:1544 creates the forked conversation for the recipient with agent=public_conversation.agent.
  • src/khoj/routers/api_chat.py:397 exposes the authenticated /api/chat/share/fork endpoint for a public conversation slug.
  • src/khoj/routers/api_chat.py:954 sets the active chat agent from conversation.agent without an accessibility check.
  • src/khoj/routers/helpers.py:371, :391, and :400 use the inherited agent's input_tools, output_modes, and personality for tool routing.
  • src/khoj/routers/helpers.py:1827 uses the inherited agent's name/personality to build the final chat system prompt.
  • src/khoj/database/adapters/__init__.py:1724 can use the inherited agent's chat_model for hidden agents or subscribed users.

Mitigating evidence:

  • src/khoj/routers/helpers.py:1491 checks AgentAdapters.ais_agent_accessible(agent, user) before normal document search.
  • src/khoj/database/adapters/__init__.py:760 returns False for a private agent whose creator is not the current user.

So the issue is not confirmed as direct private document retrieval in this snapshot. The confirmed issue is unauthorized retention and use of a private agent reference and its behavior/configuration across user boundaries.

Expected Behavior

Forking a shared conversation should not grant or retain access to private or otherwise inaccessible agents owned by another user.

Safe behavior would be one of:

  • Do not copy private/protected agent references into PublicConversation.
  • Do not copy inaccessible agent references when forking a public conversation.
  • Before chat execution, re-authorize conversation.agent against the current user. If inaccessible, replace it with the default public agent or reject the request.
  • Apply the same authorization rule anywhere agent persona, tools, output modes, chat model, memory namespace, or retrieval context are derived from conversation.agent.

Reproduction Steps

Controlled local verification was done against the source snapshot above, using only source inspection and synthetic user/agent assumptions.

  1. Create user A and user B.
  2. As user A, create a private agent with a distinctive persona and restricted input/output tool configuration.
  3. As user A, create a conversation using that private agent.
  4. As user A, share the conversation through /api/chat/share.
  5. As user B, fork that public conversation through /api/chat/share/fork.
  6. Observe from ConversationAdapters.create_conversation_from_public_conversation() that the new user-B-owned conversation can retain agent_id for user A's private agent.
  7. Continue the forked conversation as user B.
  8. Observe from event_generator() and helper functions that the inherited agent is used for persona/system prompt construction and tool/model selection without first checking whether user B can access that private agent.
  9. If Notes/document retrieval is attempted, the normal execute_search() path rejects the inaccessible private agent, so direct private knowledge-base result retrieval was not confirmed in this source snapshot.

Static verification checks passed locally:

PASS: public share stores original agent
PASS: forked conversation copies public agent to new user
PASS: share fork endpoint accepts public slug and creates private conversation
PASS: chat execution trusts conversation.agent directly for active agent assignment
PASS: tool routing uses inherited agent input/output modes and personality
PASS: final chat system prompt uses inherited private agent personality
PASS: knowledge-base search has an access-control guard
RESULT: inherited private agent reference is present; direct KB retrieval is guarded by an existing access check

Possible Workaround

Until fixed, avoid sharing conversations that use private or hidden agents. Operators can also disable public conversation sharing/forking or patch the share/fork flow to strip agents that are not public and accessible to the recipient.

Additional Information

Security impact: unauthorized cross-user retention and use of a private agent reference. This can expose or apply private agent configuration such as persona/instructions, tool availability, output modes, and potentially model selection across users. I would treat this as an authorization boundary issue, but with lower severity than direct private document disclosure because the normal document search path has an access-control guard in this snapshot.

Suggested fix:

agent = public_conversation.agent
if agent and not await AgentAdapters.ais_agent_accessible(agent, user):
    agent = await AgentAdapters.aget_default_agent()

Apply equivalent checks before using conversation.agent in chat execution, tool routing, memory scoping, chat-model selection, and retrieval.

Link to Discord or Github discussion

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    fixFix something that isn't working as expected

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions