Skip to content

Commit cfd2888

Browse files
authored
fix:image url validation and signout post (open-webui#24420)
* refac(routers): reject external URLs in profile/model image handlers * refac(ui): centralize image URL validation in safeImageUrl helper * refac(auths): make signout POST-only * refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING Restore the 302 redirect for external http(s) profile image URLs in the user and model profile-image endpoints, but gate it behind a new ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True). Existing deployments that rely on external profile image forwarding continue to work unchanged. Operators who want to suppress the redirect (to prevent client-side IP/UA/Referer leaks) can set the flag to False.
1 parent adda205 commit cfd2888

9 files changed

Lines changed: 75 additions & 25 deletions

File tree

backend/open_webui/env.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,19 @@ def parse_section(section):
249249

250250
ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'true'
251251

252+
####################################
253+
# ENABLE_PROFILE_IMAGE_URL_FORWARDING
254+
####################################
255+
256+
# When True (default), the user and model profile-image endpoints
257+
# honour external http(s) URLs stored in profile_image_url by issuing a
258+
# 302 redirect to the original origin. Set to False to suppress the
259+
# redirect (prevents client-side IP/UA/Referer leaks to attacker-
260+
# controlled origins) and fall through to the default image instead.
261+
ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get(
262+
'ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True'
263+
).lower() == 'true'
264+
252265
####################################
253266
# WEBUI_BUILD_HASH
254267
####################################

backend/open_webui/routers/auths.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,7 @@ async def signup(
785785
raise HTTPException(500, detail='An internal error occurred during signup.')
786786

787787

788-
@router.get('/signout')
788+
@router.post('/signout')
789789
async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)):
790790
# get auth token from headers or cookies
791791
token = None

backend/open_webui/routers/models.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,16 @@
2828
Depends,
2929
HTTPException,
3030
Request,
31-
status,
3231
Response,
32+
status,
3333
)
3434
from fastapi.responses import RedirectResponse, StreamingResponse
3535

3636

3737
from open_webui.utils.auth import get_admin_user, get_verified_user
3838
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
3939
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
40+
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING
4041
from open_webui.internal.db import get_async_session
4142
from sqlalchemy.ext.asyncio import AsyncSession
4243

@@ -484,10 +485,14 @@ async def get_model_profile_image(
484485

485486
if profile_image_url:
486487
if profile_image_url.startswith('http'):
487-
return Response(
488-
status_code=status.HTTP_302_FOUND,
489-
headers={'Location': profile_image_url},
490-
)
488+
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
489+
return Response(
490+
status_code=status.HTTP_302_FOUND,
491+
headers={'Location': profile_image_url},
492+
)
493+
# When forwarding is disabled, fall through to the
494+
# default image to prevent client-side IP/UA/Referer
495+
# leaks via 302 redirect to external origins.
491496
elif profile_image_url.startswith('data:image'):
492497
try:
493498
header, base64_data = profile_image_url.split(',', 1)

backend/open_webui/routers/users.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
)
3030

3131
from open_webui.constants import ERROR_MESSAGES
32-
from open_webui.env import STATIC_DIR
32+
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, STATIC_DIR
3333
from open_webui.internal.db import get_async_session
3434

3535

@@ -478,12 +478,15 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
478478
user = await Users.get_user_by_id(user_id)
479479
if user:
480480
if user.profile_image_url:
481-
# check if it's url or base64
482481
if user.profile_image_url.startswith('http'):
483-
return Response(
484-
status_code=status.HTTP_302_FOUND,
485-
headers={'Location': user.profile_image_url},
486-
)
482+
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
483+
return Response(
484+
status_code=status.HTTP_302_FOUND,
485+
headers={'Location': user.profile_image_url},
486+
)
487+
# When forwarding is disabled, fall through to the
488+
# default image to prevent client-side IP/UA/Referer
489+
# leaks via 302 redirect to external origins.
487490
elif user.profile_image_url.startswith('data:image'):
488491
try:
489492
header, base64_data = user.profile_image_url.split(',', 1)

src/lib/apis/auths/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ export const userSignOut = async () => {
328328
let error = null;
329329

330330
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, {
331-
method: 'GET',
331+
method: 'POST',
332332
headers: {
333333
'Content-Type': 'application/json'
334334
},

src/lib/components/chat/Messages/ProfileImage.svelte

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
<script lang="ts">
22
import { WEBUI_BASE_URL } from '$lib/constants';
3+
import { safeImageUrl } from '$lib/utils/safeImageUrl';
34
45
export let className = 'size-8';
56
export let src = `${WEBUI_BASE_URL}/static/favicon.png`;
67
</script>
78

89
<img
910
aria-hidden="true"
10-
src={src === ''
11-
? `${WEBUI_BASE_URL}/static/favicon.png`
12-
: src.startsWith(WEBUI_BASE_URL) ||
13-
src.startsWith('https://www.gravatar.com/avatar/') ||
14-
src.startsWith('data:') ||
15-
src.startsWith('/')
16-
? src
17-
: `${WEBUI_BASE_URL}/user.png`}
11+
src={safeImageUrl(src)}
1812
class=" {className} object-cover rounded-full"
1913
alt="profile"
2014
draggable="false"

src/lib/components/common/Image.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { WEBUI_BASE_URL } from '$lib/constants';
3+
import { safeImageUrl } from '$lib/utils/safeImageUrl';
34
45
import { settings } from '$lib/stores';
56
import ImagePreview from './ImagePreview.svelte';
@@ -19,7 +20,7 @@
1920
const i18n = getContext('i18n');
2021
2122
let _src = '';
22-
$: _src = src.startsWith('/') ? `${WEBUI_BASE_URL}${src}` : src;
23+
$: _src = safeImageUrl(src.startsWith('/') ? `${WEBUI_BASE_URL}${src}` : src);
2324
2425
let showImagePreview = false;
2526
</script>

src/lib/components/common/RichTextInput/Image/image.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
2+
import { safeImageUrl } from '$lib/utils/safeImageUrl';
23

34
export interface ImageOptions {
45
/**
@@ -137,12 +138,12 @@ export const Image = Node.create<ImageOptions>({
137138
if (editorFiles && node.attrs.src.startsWith('data://')) {
138139
const file = editorFiles.find((f) => f.id === fileId);
139140
if (file) {
140-
img.setAttribute('src', file.url || '');
141+
img.setAttribute('src', safeImageUrl(file.url || ''));
141142
} else {
142143
img.setAttribute('src', '/image-placeholder.png');
143144
}
144145
} else {
145-
img.setAttribute('src', node.attrs.src || '');
146+
img.setAttribute('src', safeImageUrl(node.attrs.src || ''));
146147
}
147148

148149
img.setAttribute('alt', node.attrs.alt || '');
@@ -153,7 +154,7 @@ export const Image = Node.create<ImageOptions>({
153154
if (files && node.attrs.src.startsWith('data://')) {
154155
const file = editorFiles.find((f) => f.id === fileId);
155156
if (file) {
156-
img.setAttribute('src', file.url || '');
157+
img.setAttribute('src', safeImageUrl(file.url || ''));
157158
} else {
158159
img.setAttribute('src', '/image-placeholder.png');
159160
}

src/lib/utils/safeImageUrl.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { WEBUI_BASE_URL } from '$lib/constants';
2+
3+
const PLACEHOLDER_IMAGE = '/favicon.png';
4+
5+
/**
6+
* Validates an image URL against an allowlist of safe patterns and returns
7+
* the URL if trusted, or a placeholder otherwise.
8+
*
9+
* Allowed patterns:
10+
* - Relative paths (starting with '/')
11+
* - data:image/* URIs
12+
* - Same-origin URLs (starting with WEBUI_BASE_URL)
13+
* - Gravatar URLs (https://www.gravatar.com/avatar/)
14+
*
15+
* All other URLs (including arbitrary http(s):// origins) are rejected to
16+
* prevent client-side IP/UA/Referer leaks to attacker-controlled servers.
17+
*/
18+
export function safeImageUrl(url: string): string {
19+
if (!url || url === '') {
20+
return `${WEBUI_BASE_URL}${PLACEHOLDER_IMAGE}`;
21+
}
22+
23+
if (
24+
url.startsWith(WEBUI_BASE_URL) ||
25+
url.startsWith('https://www.gravatar.com/avatar/') ||
26+
url.startsWith('data:') ||
27+
url.startsWith('/')
28+
) {
29+
return url;
30+
}
31+
32+
return `${WEBUI_BASE_URL}${PLACEHOLDER_IMAGE}`;
33+
}

0 commit comments

Comments
 (0)