mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-11 22:52:54 +00:00
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:
parent
6fdd19bf14
commit
6360b4c34e
9 changed files with 74 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue