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.
This commit is contained in:
DrMelone 2026-04-07 23:06:57 +02:00
parent 6fdd19bf14
commit 6360b4c34e
9 changed files with 74 additions and 26 deletions

View file

@ -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

View file

@ -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):

View file

@ -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}
<iframe
class="w-full my-2"
src={`${WEBUI_BASE_URL}/api/v1/files/${fileId}/content/html`}
src={`${WEBUI_BASE_URL}/api/v1/files/${fileId}/content/html${$shareId ? `?share_id=${encodeURIComponent($shareId)}` : ''}`}
title="Content"
frameborder="0"
sandbox="allow-scripts allow-downloads{($settings?.iframeSandboxAllowForms ?? false)

View file

@ -9,6 +9,7 @@
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
import { shareId } from '$lib/stores';
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import Image from '$lib/components/common/Image.svelte';
@ -113,7 +114,7 @@
{/if}
{:else if token.type === 'iframe'}
<iframe
src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content"
src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content{$shareId ? `?share_id=${encodeURIComponent($shareId)}` : ''}"
title={token.fileId}
width="100%"
frameborder="0"

View file

@ -10,7 +10,7 @@
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
import { settings } from '$lib/stores';
import { settings, shareId } from '$lib/stores';
import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte';
import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte';
@ -468,7 +468,7 @@
<HtmlToken {id} {token} {onSourceClick} />
{:else if token.type === 'iframe'}
<iframe
src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content"
src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content{$shareId ? `?share_id=${encodeURIComponent($shareId)}` : ''}"
title={token.fileId}
width="100%"
frameborder="0"

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
import { settings } from '$lib/stores';
import { settings, shareId } from '$lib/stores';
import ImagePreview from './ImagePreview.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { getContext } from 'svelte';
@ -19,7 +19,14 @@
const i18n = getContext('i18n');
let _src = '';
$: _src = src.startsWith('/') ? `${WEBUI_BASE_URL}${src}` : src;
$: {
let resolved = src.startsWith('/') ? `${WEBUI_BASE_URL}${src}` : src;
if ($shareId && resolved.includes('/api/v1/files/') && resolved.includes('/content')) {
const separator = resolved.includes('?') ? '&' : '?';
resolved = `${resolved}${separator}share_id=${encodeURIComponent($shareId)}`;
}
_src = resolved;
}
let showImagePreview = false;
</script>

View file

@ -52,6 +52,7 @@ export const shortCodesToEmojis = writable(
export const TTSWorker = writable(null);
export const chatId = writable('');
export const shareId: Writable<string | null> = writable(null);
export const chatTitle = writable('');
export const channels = writable([]);

View file

@ -2,6 +2,8 @@ import type { Writable } from 'svelte/store';
import { v4 as uuidv4 } from 'uuid';
import sha256 from 'js-sha256';
import { WEBUI_BASE_URL } from '$lib/constants';
import { shareId } from '$lib/stores';
import { get } from 'svelte/store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -59,8 +61,11 @@ export const replaceTokens = (content, char, user) => {
{ regex: /{{user}}/gi, replacement: user },
{
regex: /{{VIDEO_FILE_ID_([a-f0-9-]+)}}/gi,
replacement: (_, fileId) =>
`<video src="${WEBUI_BASE_URL}/api/v1/files/${fileId}/content" controls></video>`
replacement: (_, fileId) => {
const sid = get(shareId);
const query = sid ? `?share_id=${encodeURIComponent(sid)}` : '';
return `<video src="${WEBUI_BASE_URL}/api/v1/files/${fileId}/content${query}" controls></video>`;
}
},
{
regex: /{{HTML_FILE_ID_([a-f0-9-]+)}}/gi,

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import { onMount, onDestroy, tick, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import dayjs from 'dayjs';
import { settings, chatId, WEBUI_NAME, models, config } from '$lib/stores';
import { settings, chatId, shareId, WEBUI_NAME, models, config } from '$lib/stores';
import { convertMessagesToHistory, createMessagesList } from '$lib/utils';
import { getChatByShareId, cloneSharedChatById } from '$lib/apis/chats';
@ -86,6 +86,7 @@
)
);
await chatId.set($page.params.id);
await shareId.set($page.params.id);
chat = await getChatByShareId(localStorage.token, $chatId).catch(async (error) => {
await goto('/');
return null;
@ -139,6 +140,10 @@
goto(`/c/${res.id}`);
}
};
onDestroy(() => {
shareId.set(null);
});
</script>
<svelte:head>