CVE-2026-45671
Description
Open WebUI is a self-hosted artificial intelligence platform designed to operate entirely offline. Prior to 0.9.0, any authenticated user can permanently delete files owned by other users via DELETE /api/v1/files/{id} when the target file is referenced in any shared chat. The has_access_to_file() authorization gate unconditionally grants access through its shared-chat branch. It checks neither the requesting user's identity nor the type of operation being performed. File UUIDs (which would otherwise be impractical to guess) are disclosed to any user with read access to a knowledge base via GET /api/v1/knowledge/{id}/files. This vulnerability is fixed in 0.9.0.
Affected products
1- Range: <= 0.8.12
Patches
12e52ad8ff2f8refac: shared chat
7 files changed · +818 −241
backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py+164 −0 added@@ -0,0 +1,164 @@ +"""Add shared_chat table and migrate existing shares + +Revision ID: c1d2e3f4a5b6 +Revises: e1f2a3b4c5d6 +Create Date: 2026-04-16 23:00:00.000000 + +""" + +import time +import uuid + +from alembic import op +import sqlalchemy as sa + +revision = 'c1d2e3f4a5b6' +down_revision = 'e1f2a3b4c5d6' +branch_labels = None +depends_on = None + +# Lightweight table references for data migration (no ORM models needed) +chat_t = sa.table( + 'chat', + sa.column('id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('share_id', sa.Text), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), + sa.column('archived', sa.Boolean), + sa.column('meta', sa.JSON), +) + +shared_chat_t = sa.table( + 'shared_chat', + sa.column('id', sa.Text), + sa.column('chat_id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), +) + +chat_message_t = sa.table( + 'chat_message', + sa.column('chat_id', sa.Text), +) + +access_grant_t = sa.table( + 'access_grant', + sa.column('id', sa.Text), + sa.column('resource_type', sa.Text), + sa.column('resource_id', sa.Text), + sa.column('principal_type', sa.Text), + sa.column('principal_id', sa.Text), + sa.column('permission', sa.Text), + sa.column('created_at', sa.BigInteger), +) + + +def upgrade(): + conn = op.get_bind() + + # 1. Create shared_chat table + op.create_table( + 'shared_chat', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('chat', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + ) + + # 2. Migrate existing shared-* rows + shared_rows = conn.execute( + sa.select( + chat_t.c.id, + chat_t.c.user_id, + chat_t.c.title, + chat_t.c.chat, + chat_t.c.created_at, + chat_t.c.updated_at, + ).where(chat_t.c.user_id.like('shared-%')) + ).fetchall() + + for row in shared_rows: + share_token = row.id + original_chat_id = row.user_id.replace('shared-', '', 1) + + # Verify original chat still exists + original = conn.execute( + sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id) + ).fetchone() + + if not original: + continue + + # Insert snapshot into shared_chat + conn.execute(shared_chat_t.insert().values( + id=share_token, + chat_id=original_chat_id, + user_id=original.user_id, + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + )) + + # Create user:*:read grant for backward compat + conn.execute(access_grant_t.insert().values( + id=str(uuid.uuid4()), + resource_type='shared_chat', + resource_id=original_chat_id, + principal_type='user', + principal_id='*', + permission='read', + created_at=row.created_at or int(time.time()), + )) + + # 3. Clean up old phantom rows + conn.execute( + chat_message_t.delete().where( + chat_message_t.c.chat_id.in_( + sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')) + ) + ) + ) + conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%'))) + + +def downgrade(): + conn = op.get_bind() + + shared_rows = conn.execute( + sa.select( + shared_chat_t.c.id, + shared_chat_t.c.chat_id, + shared_chat_t.c.user_id, + shared_chat_t.c.title, + shared_chat_t.c.chat, + shared_chat_t.c.created_at, + shared_chat_t.c.updated_at, + ) + ).fetchall() + + for row in shared_rows: + conn.execute(chat_t.insert().values( + id=row.id, + user_id=f'shared-{row.chat_id}', + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + archived=False, + meta={}, + )) + + conn.execute( + access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat') + ) + op.drop_table('shared_chat')
backend/open_webui/models/chats.py+58 −121 modified@@ -555,77 +555,51 @@ async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, async def insert_shared_chat_by_chat_id( self, chat_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: + """Create a shared snapshot for a chat. Returns the original chat with share_id set.""" + from open_webui.models.shared_chats import SharedChats + async with get_async_db_context(db) as db: - # Get the existing chat to share chat = await db.get(Chat, chat_id) - # Check if chat exists if not chat: return None - # Check if the chat is already shared + + # If already shared, just update the existing snapshot if chat.share_id: - return await self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db) - # Create a new chat with the same data, but with a new ID - shared_chat = ChatModel( - **{ - 'id': str(uuid.uuid4()), - 'user_id': f'shared-{chat_id}', - 'title': chat.title, - 'chat': chat.chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - 'created_at': chat.created_at, - 'updated_at': int(time.time()), - } - ) - shared_result = Chat(**shared_chat.model_dump()) - db.add(shared_result) - await db.commit() - await db.refresh(shared_result) + return await self.update_shared_chat_by_chat_id(chat_id, db=db) - # Update the original chat with the share_id - await db.execute(update(Chat).filter_by(id=chat_id).values(share_id=shared_chat.id)) + shared = await SharedChats.create(chat_id, chat.user_id, db=db) + if not shared: + return None + + # Set share_id on the original chat + chat.share_id = shared.id await db.commit() - return shared_chat if shared_result else None + await db.refresh(chat) + return ChatModel.model_validate(chat) async def update_shared_chat_by_chat_id( self, chat_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: + """Re-snapshot the shared chat with current chat data.""" + from open_webui.models.shared_chats import SharedChats + try: async with get_async_db_context(db) as db: chat = await db.get(Chat, chat_id) - result = await db.execute(select(Chat).filter_by(user_id=f'shared-{chat_id}')) - shared_chat = result.scalars().first() - - if shared_chat is None: + if not chat or not chat.share_id: return await self.insert_shared_chat_by_chat_id(chat_id, db=db) - shared_chat.title = chat.title - shared_chat.chat = chat.chat - shared_chat.meta = chat.meta - shared_chat.pinned = chat.pinned - shared_chat.folder_id = chat.folder_id - shared_chat.updated_at = int(time.time()) - await db.commit() - await db.refresh(shared_chat) - - return ChatModel.model_validate(shared_chat) + await SharedChats.update(chat.share_id, db=db) + return ChatModel.model_validate(chat) except Exception: return None async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: - try: - async with get_async_db_context(db) as db: - # Get shared chat IDs - result = await db.execute(select(Chat.id).filter_by(user_id=f'shared-{chat_id}')) - shared_ids = [row[0] for row in result.all()] - - if shared_ids: - await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids))) - await db.execute(delete(Chat).filter_by(user_id=f'shared-{chat_id}')) - await db.commit() + """Delete shared snapshot for a chat.""" + from open_webui.models.shared_chats import SharedChats - return True + try: + return await SharedChats.delete_by_chat_id(chat_id, db=db) except Exception: return False @@ -746,53 +720,12 @@ async def get_shared_chat_list_by_user_id( limit: int = 50, db: Optional[AsyncSession] = None, ) -> list[SharedChatResponse]: - async with get_async_db_context(db) as db: - stmt = ( - select(Chat.id, Chat.title, Chat.share_id, Chat.updated_at, Chat.created_at) - .filter_by(user_id=user_id) - .filter(Chat.share_id.isnot(None)) - ) - - if filter: - query_key = filter.get('query') - if query_key: - stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%')) - - order_by = filter.get('order_by') - direction = filter.get('direction') - - if order_by and direction: - if not getattr(Chat, order_by, None): - raise ValueError('Invalid order_by field') - - if direction.lower() == 'asc': - stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id) - elif direction.lower() == 'desc': - stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id) - else: - raise ValueError('Invalid direction for ordering') - else: - stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) - - if skip: - stmt = stmt.offset(skip) - if limit: - stmt = stmt.limit(limit) + """Delegate to SharedChats for listing shared chats by user.""" + from open_webui.models.shared_chats import SharedChats - result = await db.execute(stmt) - all_chats = result.all() - return [ - SharedChatResponse.model_validate( - { - 'id': chat[0], - 'title': chat[1], - 'share_id': chat[2], - 'updated_at': chat[3], - 'created_at': chat[4], - } - ) - for chat in all_chats - ] + return await SharedChats.get_by_user_id( + user_id, filter=filter, skip=skip, limit=limit, db=db + ) async def get_chat_list_by_user_id( self, @@ -925,15 +858,23 @@ async def get_chat_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Op return None async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: - try: - async with get_async_db_context(db) as db: - result = await db.execute(select(Chat).filter_by(share_id=id)) - chat = result.scalars().first() + """Look up a shared chat snapshot by its share token.""" + from open_webui.models.shared_chats import SharedChats - if chat: - return await self.get_chat_by_id(id, db=db) - else: - return None + try: + shared = await SharedChats.get_by_id(id, db=db) + if shared: + # Return a ChatModel-compatible view of the snapshot + return ChatModel( + id=shared.id, + user_id=shared.user_id, + title=shared.title, + chat=shared.chat, + created_at=shared.created_at, + updated_at=shared.updated_at, + share_id=shared.id, + ) + return None except Exception: return None @@ -1568,20 +1509,17 @@ async def move_chats_by_user_id_and_folder_id( return False async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete all shared chat snapshots created by a user.""" + from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable + try: async with get_async_db_context(db) as db: - result = await db.execute(select(Chat.id).filter_by(user_id=user_id)) - id_rows = result.all() - shared_chat_ids = [f'shared-{row[0]}' for row in id_rows] - - if shared_chat_ids: - # Get shared chat IDs to delete associated messages - shared_result = await db.execute(select(Chat.id).filter(Chat.user_id.in_(shared_chat_ids))) - shared_ids = [row[0] for row in shared_result.all()] - if shared_ids: - await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids))) - await db.execute(delete(Chat).filter(Chat.user_id.in_(shared_chat_ids))) - await db.commit() + # Delete shared_chat rows for this user's chats + await db.execute(delete(SharedChatTable).filter_by(user_id=user_id)) + + # Clear share_id on all of this user's chats + await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None)) + await db.commit() return True except Exception: @@ -1651,16 +1589,15 @@ async def delete_chat_file(self, chat_id: str, file_id: str, db: Optional[AsyncS except Exception: return False - async def get_shared_chats_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]: + async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]: + """Return IDs of chats that contain this file and have an active share link.""" async with get_async_db_context(db) as db: result = await db.execute( - select(Chat) + select(Chat.id) .join(ChatFile, Chat.id == ChatFile.chat_id) .filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None)) ) - all_chats = result.scalars().all() - - return [ChatModel.model_validate(chat) for chat in all_chats] + return [row[0] for row in result.all()] async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]: """Update the tasks list on a chat."""
backend/open_webui/models/shared_chats.py+222 −0 added@@ -0,0 +1,222 @@ +import logging +import time +import uuid +from typing import Optional + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON + +log = logging.getLogger(__name__) + +#################### +# SharedChat DB Schema +#################### + + +class SharedChat(Base): + __tablename__ = 'shared_chat' + + id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL + chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Text, nullable=False) # Who created this share + + title = Column(Text) + chat = Column(JSON) # Snapshot of chat JSON at share time + + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class SharedChatModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + chat_id: str + user_id: str + + title: str + chat: dict + + created_at: int + updated_at: int + + +class SharedChatResponse(BaseModel): + id: str + chat_id: str + title: str + share_id: Optional[str] = None # Alias for id, for backward compat + updated_at: int + created_at: int + + +#################### +# Table Operations +#################### + + +class SharedChatsTable: + async def create( + self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """ + Create a snapshot of the chat for link sharing. + Returns the SharedChatModel with the share token as its id. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + chat = await db.get(Chat, chat_id) + if not chat: + return None + + share_id = str(uuid.uuid4()) + now = int(time.time()) + + shared_chat = SharedChat( + id=share_id, + chat_id=chat_id, + user_id=user_id, + title=chat.title, + chat=chat.chat, + created_at=now, + updated_at=now, + ) + db.add(shared_chat) + await db.commit() + await db.refresh(shared_chat) + + return SharedChatModel.model_validate(shared_chat) + + async def update( + self, share_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """ + Re-snapshot: update the shared chat with the current state of the original chat. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + shared_chat = await db.get(SharedChat, share_id) + if not shared_chat: + return None + + chat = await db.get(Chat, shared_chat.chat_id) + if not chat: + return None + + shared_chat.title = chat.title + shared_chat.chat = chat.chat + shared_chat.updated_at = int(time.time()) + + await db.commit() + await db.refresh(shared_chat) + return SharedChatModel.model_validate(shared_chat) + + async def get_by_id( + self, share_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """Get a shared chat by its share token.""" + async with get_async_db_context(db) as db: + shared_chat = await db.get(SharedChat, share_id) + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_chat_id( + self, chat_id: str, db: Optional[AsyncSession] = None + ) -> Optional[SharedChatModel]: + """Get the shared chat for a given original chat. Returns the most recent one.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(SharedChat) + .filter_by(chat_id=chat_id) + .order_by(SharedChat.updated_at.desc()) + .limit(1) + ) + shared_chat = result.scalars().first() + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_user_id( + self, + user_id: str, + filter: Optional[dict] = None, + skip: int = 0, + limit: int = 50, + db: Optional[AsyncSession] = None, + ) -> list[SharedChatResponse]: + """List all shared chats created by a user.""" + async with get_async_db_context(db) as db: + stmt = select(SharedChat).filter_by(user_id=user_id) + + if filter: + query_key = filter.get('query') + if query_key: + stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%')) + + order_by = filter.get('order_by') + direction = filter.get('direction') + + if order_by and direction: + col = getattr(SharedChat, order_by, None) + if not col: + raise ValueError('Invalid order_by field') + if direction.lower() == 'asc': + stmt = stmt.order_by(col.asc()) + elif direction.lower() == 'desc': + stmt = stmt.order_by(col.desc()) + else: + raise ValueError('Invalid direction for ordering') + else: + stmt = stmt.order_by(SharedChat.updated_at.desc()) + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + return [ + SharedChatResponse( + id=sc.chat_id, + chat_id=sc.chat_id, + title=sc.title, + share_id=sc.id, + updated_at=sc.updated_at, + created_at=sc.created_at, + ) + for sc in result.scalars().all() + ] + + async def delete_by_id( + self, share_id: str, db: Optional[AsyncSession] = None + ) -> bool: + """Delete a shared chat by its share token.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(id=share_id)) + await db.commit() + return True + except Exception: + return False + + async def delete_by_chat_id( + self, chat_id: str, db: Optional[AsyncSession] = None + ) -> bool: + """Delete all shared chats for a given original chat.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(chat_id=chat_id)) + await db.commit() + return True + except Exception: + return False + + +SharedChats = SharedChatsTable()
backend/open_webui/routers/chats.py+192 −49 modified@@ -17,13 +17,14 @@ ChatResponse, Chats, ChatTitleIdResponse, - SharedChatResponse, ChatStatsExport, AggregateChatStats, ChatBody, ChatHistoryStats, MessageStats, ) +from open_webui.models.shared_chats import SharedChats, SharedChatResponse +from open_webui.models.access_grants import AccessGrants from open_webui.models.tags import TagModel, Tags from open_webui.models.folders import Folders from open_webui.internal.db import get_async_session @@ -35,7 +36,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants log = logging.getLogger(__name__) @@ -807,7 +808,7 @@ async def get_shared_session_user_chat_list( if direction: filter['direction'] = direction - return await Chats.get_shared_chat_list_by_user_id( + return await SharedChats.get_by_user_id( user.id, filter=filter, skip=skip, @@ -828,17 +829,32 @@ async def get_shared_chat_by_id( if user.role == 'pending': raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS): - chat = await Chats.get_chat_by_share_id(share_id, db=db) - elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(share_id, db=db) - - if chat: - return ChatResponse(**chat.model_dump()) - else: + chat = await Chats.get_chat_by_share_id(share_id, db=db) + + if not chat: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + # Look up the original chat_id to check access grants + shared = await SharedChats.get_by_id(share_id, db=db) + if shared: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + if not has_grant: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + return ChatResponse(**chat.model_dump()) + ############################ # GetChatsByTags @@ -878,11 +894,25 @@ async def get_user_chat_list_by_tag_name( async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + # Check if user has access via access grants (shared_chat grants) + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_id(id, db=db) + else: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=id, + permission='read', + db=db, + ) + if has_grant: + chat = await Chats.get_chat_by_id(id, db=db) + if chat: return ChatResponse(**chat.model_dump()) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -1158,39 +1188,58 @@ async def clone_shared_chat_by_id( else: chat = await Chats.get_chat_by_share_id(id, db=db) - if chat: - updated_chat = { - **chat.chat, - 'originalChatId': chat.id, - 'branchPointMessageId': chat.chat['history']['currentId'], - 'title': f'Clone of {chat.title}', - } + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, + ) - chats = await Chats.import_chats( - user.id, - [ - ChatImportForm( - **{ - 'chat': updated_chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - } - ) - ], + # Enforce access grants + shared = await SharedChats.get_by_id(id, db=db) + if shared and user.role != 'admin': + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', db=db, ) - - if chats: - chat = chats[0] - return ChatResponse(**chat.model_dump()) - else: + if not has_grant: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ERROR_MESSAGES.DEFAULT(), + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + updated_chat = { + **chat.chat, + 'originalChatId': chat.id, + 'branchPointMessageId': chat.chat['history']['currentId'], + 'title': f'Clone of {chat.title}', + } + + chats = await Chats.import_chats( + user.id, + [ + ChatImportForm( + **{ + 'chat': updated_chat, + 'meta': chat.meta, + 'pinned': chat.pinned, + 'folder_id': chat.folder_id, + } ) + ], + db=db, + ) + + if chats: + chat = chats[0] + return ChatResponse(**chat.model_dump()) else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) ############################ @@ -1241,16 +1290,28 @@ async def share_chat_by_id( if chat: if chat.share_id: - shared_chat = await Chats.update_shared_chat_by_chat_id(chat.id, db=db) - return ChatResponse(**shared_chat.model_dump()) - - shared_chat = await Chats.insert_shared_chat_by_chat_id(chat.id, db=db) - if not shared_chat: + # Re-snapshot existing share + shared = await SharedChats.update(chat.share_id, db=db) + if shared: + # Re-fetch the original chat to return + chat = await Chats.get_chat_by_id(id, db=db) + return ChatResponse(**chat.model_dump()) + + # Create new share + shared = await SharedChats.create(id, user.id, db=db) + if not shared: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) + # Set share_id on the original chat + chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db) + if not chat: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT(), ) - return ChatResponse(**shared_chat.model_dump()) + return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -1260,7 +1321,7 @@ async def share_chat_by_id( ############################ -# DeletedSharedChatById +# DeleteSharedChatById ############################ @@ -1273,17 +1334,99 @@ async def delete_shared_chat_by_id( if not chat.share_id: return False - result = await Chats.delete_shared_chat_by_chat_id(id, db=db) - update_result = await Chats.update_chat_share_id_by_id(id, None, db=db) + await SharedChats.delete_by_chat_id(id, db=db) + await Chats.update_chat_share_id_by_id(id, None, db=db) - return result and update_result != None + # Revoke all access grants for this shared chat + await AccessGrants.set_access_grants('shared_chat', id, [], db=db) + + return True else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) +############################ +# UpdateSharedChatAccessById +############################ + + +class ChatAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse]) +async def update_shared_chat_access_by_id( + request: Request, + id: str, + form_data: ChatAccessGrantsForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if chat.user_id != user.id and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_chats', + ) + + await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db) + + return ChatResponse(**chat.model_dump()) + + +############################ +# GetSharedChatAccessById +############################ + + +@router.get('/shared/{id}/access', response_model=list) +async def get_shared_chat_access_by_id( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if chat.user_id != user.id and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db) + return [ + { + 'id': g.id, + 'principal_type': g.principal_type, + 'principal_id': g.principal_id, + 'permission': g.permission, + } + for g in grants + ] + + ############################ # UpdateChatFolderIdById ############################
backend/open_webui/utils/access_control/files.py+12 −4 modified@@ -66,10 +66,18 @@ async def has_access_to_file( return True # Check if the file is associated with any chats the user has access to - # TODO: Granular access control for chats - chats = await Chats.get_shared_chats_by_file_id(file_id, db=db) - if chats: - return True + shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db) + if shared_chat_ids: + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='shared_chat', + resource_ids=shared_chat_ids, + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + if accessible_ids: + return True # Check if the file is directly attached to a shared workspace model for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
src/lib/apis/chats/index.ts+71 −0 modified@@ -953,6 +953,77 @@ export const deleteSharedChatById = async (token: string, id: string) => { return res; }; +export const updateChatAccessGrants = async ( + token: string, + id: string, + accessGrants: object[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + access_grants: accessGrants + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getChatAccessGrants = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateChatById = async (token: string, id: string, chat: object) => { let error = null;
src/lib/components/chat/ShareChatModal.svelte+99 −67 modified@@ -3,24 +3,32 @@ import { models, config } from '$lib/stores'; import { toast } from 'svelte-sonner'; - import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats'; + import { + deleteSharedChatById, + getChatById, + shareChatById, + getChatAccessGrants, + updateChatAccessGrants + } from '$lib/apis/chats'; import { copyToClipboard } from '$lib/utils'; import Modal from '../common/Modal.svelte'; import Link from '../icons/Link.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; + import AccessControl from '$lib/components/workspace/common/AccessControl.svelte'; export let chatId; let chat = null; let shareUrl = null; + let accessGrants: any[] = []; const i18n = getContext('i18n'); const shareLocalChat = async () => { const _chat = chat; const sharedChat = await shareChatById(localStorage.token, chatId); - shareUrl = `${window.location.origin}/s/${sharedChat.id}`; + shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`; console.log(shareUrl); chat = await getChatById(localStorage.token, chatId); @@ -54,6 +62,25 @@ ); }; + const loadAccessGrants = async () => { + if (!chatId) return; + try { + accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? []; + } catch (e) { + console.error('Failed to load access grants', e); + accessGrants = []; + } + }; + + const saveAccessGrants = async () => { + try { + await updateChatAccessGrants(localStorage.token, chatId, accessGrants); + toast.success($i18n.t('Access updated')); + } catch (e) { + toast.error(`${e}`); + } + }; + export let show = false; const isDifferentChat = (_chat) => { @@ -73,8 +100,10 @@ if (isDifferentChat(_chat)) { chat = _chat; } + await loadAccessGrants(); } else { chat = null; + accessGrants = []; console.log(chat); } })(); @@ -97,8 +126,8 @@ </div> {#if chat} - <div class="px-5 pt-4 pb-5 w-full flex flex-col justify-center"> - <div class=" text-sm dark:text-gray-300 mb-1"> + <div class="px-5 pt-4 pb-5 w-full flex flex-col"> + <div class="text-sm dark:text-gray-300"> {#if chat.share_id} <a href="/s/{chat.share_id}" target="_blank" >{$i18n.t('You have shared this chat')} @@ -124,70 +153,73 @@ {/if} </div> - <div class="flex justify-end"> - <div class="flex flex-col items-end space-x-1 mt-3"> - <div class="flex gap-1"> - {#if $config?.features.enable_community_sharing} - <button - class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full" - type="button" - on:click={() => { - shareChat(); - show = false; - }} - > - {$i18n.t('Share to Open WebUI Community')} - </button> - {/if} - - <button - class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full" - type="button" - id="copy-and-share-chat-button" - on:click={async () => { - const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - - if (isSafari) { - // Oh, Safari, you're so special, let's give you some extra love and attention - console.log('isSafari'); - - const getUrlPromise = async () => { - const url = await shareLocalChat(); - return new Blob([url], { type: 'text/plain' }); - }; - - navigator.clipboard - .write([ - new ClipboardItem({ - 'text/plain': getUrlPromise() - }) - ]) - .then(() => { - console.log('Async: Copying to clipboard was successful!'); - return true; - }) - .catch((error) => { - console.error('Async: Could not copy text: ', error); - return false; - }); - } else { - copyToClipboard(await shareLocalChat()); - } - - toast.success($i18n.t('Copied shared chat URL to clipboard!')); - show = false; - }} - > - <Link /> - - {#if chat.share_id} - {$i18n.t('Update and Copy Link')} - {:else} - {$i18n.t('Copy Link')} - {/if} - </button> - </div> + {#if chat.share_id} + <div class="mt-3"> + <AccessControl + bind:accessGrants + accessRoles={['read']} + onChange={saveAccessGrants} + /> </div> + {/if} + + <div class="flex justify-end gap-1 mt-3"> + {#if $config?.features.enable_community_sharing} + <button + class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full" + type="button" + on:click={() => { + shareChat(); + }} + > + {$i18n.t('Share to Open WebUI Community')} + </button> + {/if} + + <button + class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full" + type="button" + id="copy-and-share-chat-button" + on:click={async () => { + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + + if (isSafari) { + console.log('isSafari'); + + const getUrlPromise = async () => { + const url = await shareLocalChat(); + return new Blob([url], { type: 'text/plain' }); + }; + + navigator.clipboard + .write([ + new ClipboardItem({ + 'text/plain': getUrlPromise() + }) + ]) + .then(() => { + console.log('Async: Copying to clipboard was successful!'); + return true; + }) + .catch((error) => { + console.error('Async: Could not copy text: ', error); + return false; + }); + } else { + copyToClipboard(await shareLocalChat()); + } + + toast.success($i18n.t('Copied shared chat URL to clipboard!')); + }} + > + <Link /> + + {#if chat.share_id} + {$i18n.t('Update and Copy Link')} + {:else} + {$i18n.t('Copy Link')} + {/if} + </button> </div> </div> {/if}
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/advisories/GHSA-26g9-27vm-x3q8ghsaADVISORY
- github.com/open-webui/open-webui/commit/2e52ad8ff2f8d9ed9f38f76e9bc19c8f92d91fc3ghsa
- github.com/open-webui/open-webui/releases/tag/v0.9.0ghsa
- github.com/open-webui/open-webui/security/advisories/GHSA-26g9-27vm-x3q8nvd
- nvd.nist.gov/vuln/detail/CVE-2026-45671ghsa
News mentions
0No linked articles in our index yet.