diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index deca6193dd..d6cf24a83c 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -249,6 +249,19 @@ ENABLE_STAR_SESSIONS_MIDDLEWARE = os.environ.get('ENABLE_STAR_SESSIONS_MIDDLEWAR ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'true' +#################################### +# ENABLE_PROFILE_IMAGE_URL_FORWARDING +#################################### + +# When True (default), the user and model profile-image endpoints +# honour external http(s) URLs stored in profile_image_url by issuing a +# 302 redirect to the original origin. Set to False to suppress the +# redirect (prevents client-side IP/UA/Referer leaks to attacker- +# controlled origins) and fall through to the default image instead. +ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get( + 'ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True' +).lower() == 'true' + #################################### # WEBUI_BUILD_HASH #################################### diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index af0e455146..5c2be8f22d 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -785,7 +785,7 @@ async def signup( raise HTTPException(500, detail='An internal error occurred during signup.') -@router.get('/signout') +@router.post('/signout') async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)): # get auth token from headers or cookies token = None diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index a5f39c768f..1ced11b358 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -28,8 +28,8 @@ from fastapi import ( Depends, HTTPException, Request, - status, Response, + status, ) from fastapi.responses import RedirectResponse, StreamingResponse @@ -37,6 +37,7 @@ from fastapi.responses import RedirectResponse, StreamingResponse from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING from open_webui.internal.db import get_async_session from sqlalchemy.ext.asyncio import AsyncSession @@ -484,10 +485,14 @@ async def get_model_profile_image( if profile_image_url: if profile_image_url.startswith('http'): - return Response( - status_code=status.HTTP_302_FOUND, - headers={'Location': profile_image_url}, - ) + if ENABLE_PROFILE_IMAGE_URL_FORWARDING: + return Response( + status_code=status.HTTP_302_FOUND, + headers={'Location': profile_image_url}, + ) + # When forwarding is disabled, fall through to the + # default image to prevent client-side IP/UA/Referer + # leaks via 302 redirect to external origins. elif profile_image_url.startswith('data:image'): try: header, base64_data = profile_image_url.split(',', 1) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 2d204ac18f..bcf11936e2 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -29,7 +29,7 @@ from open_webui.models.users import ( ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import STATIC_DIR +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, STATIC_DIR from open_webui.internal.db import get_async_session @@ -478,12 +478,15 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u user = await Users.get_user_by_id(user_id) if user: if user.profile_image_url: - # check if it's url or base64 if user.profile_image_url.startswith('http'): - return Response( - status_code=status.HTTP_302_FOUND, - headers={'Location': user.profile_image_url}, - ) + if ENABLE_PROFILE_IMAGE_URL_FORWARDING: + return Response( + status_code=status.HTTP_302_FOUND, + headers={'Location': user.profile_image_url}, + ) + # When forwarding is disabled, fall through to the + # default image to prevent client-side IP/UA/Referer + # leaks via 302 redirect to external origins. elif user.profile_image_url.startswith('data:image'): try: header, base64_data = user.profile_image_url.split(',', 1) diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index b8494ceedf..8f82f18b1b 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -328,7 +328,7 @@ export const userSignOut = async () => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, { - method: 'GET', + method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/lib/components/chat/Messages/ProfileImage.svelte b/src/lib/components/chat/Messages/ProfileImage.svelte index d837ab05ab..b4d9056442 100644 --- a/src/lib/components/chat/Messages/ProfileImage.svelte +++ b/src/lib/components/chat/Messages/ProfileImage.svelte @@ -1,5 +1,6 @@ diff --git a/src/lib/components/common/RichTextInput/Image/image.ts b/src/lib/components/common/RichTextInput/Image/image.ts index fbabbb83da..08e27fc088 100644 --- a/src/lib/components/common/RichTextInput/Image/image.ts +++ b/src/lib/components/common/RichTextInput/Image/image.ts @@ -1,4 +1,5 @@ import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'; +import { safeImageUrl } from '$lib/utils/safeImageUrl'; export interface ImageOptions { /** @@ -137,12 +138,12 @@ export const Image = Node.create({ if (editorFiles && node.attrs.src.startsWith('data://')) { const file = editorFiles.find((f) => f.id === fileId); if (file) { - img.setAttribute('src', file.url || ''); + img.setAttribute('src', safeImageUrl(file.url || '')); } else { img.setAttribute('src', '/image-placeholder.png'); } } else { - img.setAttribute('src', node.attrs.src || ''); + img.setAttribute('src', safeImageUrl(node.attrs.src || '')); } img.setAttribute('alt', node.attrs.alt || ''); @@ -153,7 +154,7 @@ export const Image = Node.create({ if (files && node.attrs.src.startsWith('data://')) { const file = editorFiles.find((f) => f.id === fileId); if (file) { - img.setAttribute('src', file.url || ''); + img.setAttribute('src', safeImageUrl(file.url || '')); } else { img.setAttribute('src', '/image-placeholder.png'); } diff --git a/src/lib/utils/safeImageUrl.ts b/src/lib/utils/safeImageUrl.ts new file mode 100644 index 0000000000..82a834ca07 --- /dev/null +++ b/src/lib/utils/safeImageUrl.ts @@ -0,0 +1,33 @@ +import { WEBUI_BASE_URL } from '$lib/constants'; + +const PLACEHOLDER_IMAGE = '/favicon.png'; + +/** + * Validates an image URL against an allowlist of safe patterns and returns + * the URL if trusted, or a placeholder otherwise. + * + * Allowed patterns: + * - Relative paths (starting with '/') + * - data:image/* URIs + * - Same-origin URLs (starting with WEBUI_BASE_URL) + * - Gravatar URLs (https://www.gravatar.com/avatar/) + * + * All other URLs (including arbitrary http(s):// origins) are rejected to + * prevent client-side IP/UA/Referer leaks to attacker-controlled servers. + */ +export function safeImageUrl(url: string): string { + if (!url || url === '') { + return `${WEBUI_BASE_URL}${PLACEHOLDER_IMAGE}`; + } + + if ( + url.startsWith(WEBUI_BASE_URL) || + url.startsWith('https://www.gravatar.com/avatar/') || + url.startsWith('data:') || + url.startsWith('/') + ) { + return url; + } + + return `${WEBUI_BASE_URL}${PLACEHOLDER_IMAGE}`; +}