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}`;
+}