From 6360b4c34e966305e9fa3d2c986dad361573a217 Mon Sep 17 00:00:00 2001 From: DrMelone <27028174+Classic298@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:06:57 +0200 Subject: [PATCH] fix: restrict shared chat file access to require valid share_id Previously, any authenticated user could access files attached to any shared chat by guessing the file UUID. The has_access_to_file function granted access if a file appeared in ANY shared chat, without verifying the requester had legitimate access to that specific share. Backend: has_access_to_file now requires either chat ownership or a matching share_id to grant shared-chat file access. All file endpoints accept an optional share_id query parameter. Frontend: A shareId store is set when viewing a shared chat (/s/{id}) and cleared on navigation away. Markdown components and the token replacement utility append ?share_id= to file content URLs when set, enabling authorized file access for shared chat viewers. --- backend/open_webui/routers/files.py | 42 ++++++++++++++----- .../open_webui/utils/access_control/files.py | 17 +++++--- .../chat/Messages/Markdown/HTMLToken.svelte | 4 +- .../Markdown/MarkdownInlineTokens.svelte | 3 +- .../Messages/Markdown/MarkdownTokens.svelte | 4 +- src/lib/components/common/Image.svelte | 11 ++++- src/lib/stores/index.ts | 1 + src/lib/utils/index.ts | 9 +++- src/routes/s/[id]/+page.svelte | 9 +++- 9 files changed, 74 insertions(+), 26 deletions(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 6027545190..1cfa2cc041 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -412,7 +412,12 @@ async def delete_all_files(user=Depends(get_admin_user), db: Session = Depends(g @router.get('/{id}', response_model=Optional[FileModel]) -async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_file_by_id( + id: str, + share_id: Optional[str] = Query(None), + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): file = Files.get_file_by_id(id, db=db) if not file: @@ -421,7 +426,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session = detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db): return file else: raise HTTPException( @@ -434,6 +439,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session = async def get_file_process_status( id: str, stream: bool = Query(False), + share_id: Optional[str] = Query(None), user=Depends(get_verified_user), db: Session = Depends(get_session), ): @@ -445,7 +451,7 @@ async def get_file_process_status( detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db): if stream: MAX_FILE_PROCESSING_DURATION = 3600 * 2 @@ -495,7 +501,12 @@ async def get_file_process_status( @router.get('/{id}/data/content') -async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_file_data_content_by_id( + id: str, + share_id: Optional[str] = Query(None), + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): file = Files.get_file_by_id(id, db=db) if not file: @@ -504,7 +515,7 @@ async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user), detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db): return {'content': file.data.get('content', '')} else: raise HTTPException( @@ -587,6 +598,7 @@ async def get_file_content_by_id( id: str, user=Depends(get_verified_user), attachment: bool = Query(False), + share_id: Optional[str] = Query(None), db: Session = Depends(get_session), ): file = Files.get_file_by_id(id, db=db) @@ -597,7 +609,7 @@ async def get_file_content_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) @@ -646,7 +658,12 @@ async def get_file_content_by_id( @router.get('/{id}/content/html') -async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_html_file_content_by_id( + id: str, + share_id: Optional[str] = Query(None), + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): file = Files.get_file_by_id(id, db=db) if not file: @@ -662,7 +679,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) @@ -693,7 +710,12 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), @router.get('/{id}/content/{file_name}') -async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_file_content_by_id( + id: str, + share_id: Optional[str] = Query(None), + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): file = Files.get_file_by_id(id, db=db) if not file: @@ -702,7 +724,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: S detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, share_id=share_id, db=db): file_path = file.path # Handle Unicode filenames diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index a7e35fd506..59bd148117 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -18,6 +18,7 @@ def has_access_to_file( file_id: str | None, access_type: str, user: UserModel, + share_id: str | None = None, db: Session | None = None, ) -> bool: """ @@ -25,7 +26,7 @@ def has_access_to_file( - Knowledge bases (ownership or access grants) - Shared workspace models that attach the file directly - Channels the user is a member of - - Shared chats + - Shared chats (requires valid share_id or chat ownership) NOTE: This does NOT check direct file ownership — callers should check file.user_id == user.id separately before calling this. @@ -65,11 +66,17 @@ def has_access_to_file( if access_type == 'read' and channels: return True - # Check if the file is associated with any chats the user has access to - # TODO: Granular access control for chats + # Check if the file is associated with any shared chats the user can access. + # Access is granted only when: + # 1. The caller provides a valid share_id that matches a shared chat + # containing this file (proves they have the share link), OR + # 2. The caller owns the shared chat containing this file. chats = Chats.get_shared_chats_by_file_id(file_id, db=db) - if chats: - return True + for chat in chats: + if chat.user_id == user.id: + return True + if share_id and access_type == 'read' and chat.share_id == share_id: + return True # Check if the file is directly attached to a shared workspace model for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db): diff --git a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte index da9690c631..6c759eaa2a 100644 --- a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte @@ -3,7 +3,7 @@ import type { Token } from 'marked'; import { WEBUI_BASE_URL } from '$lib/constants'; - import { settings } from '$lib/stores'; + import { settings, shareId } from '$lib/stores'; export let id: string; export let token: Token; @@ -109,7 +109,7 @@ {#if fileId}