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}
{:else if token.type === 'iframe'}