fix:image url validation and signout post (#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.
This commit is contained in:
@@ -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_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
|
# WEBUI_BUILD_HASH
|
||||||
####################################
|
####################################
|
||||||
|
|||||||
@@ -785,7 +785,7 @@ async def signup(
|
|||||||
raise HTTPException(500, detail='An internal error occurred during 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)):
|
async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)):
|
||||||
# get auth token from headers or cookies
|
# get auth token from headers or cookies
|
||||||
token = None
|
token = None
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ from fastapi import (
|
|||||||
Depends,
|
Depends,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
Request,
|
Request,
|
||||||
status,
|
|
||||||
Response,
|
Response,
|
||||||
|
status,
|
||||||
)
|
)
|
||||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
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.auth import get_admin_user, get_verified_user
|
||||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
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.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 open_webui.internal.db import get_async_session
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -484,10 +485,14 @@ async def get_model_profile_image(
|
|||||||
|
|
||||||
if profile_image_url:
|
if profile_image_url:
|
||||||
if profile_image_url.startswith('http'):
|
if profile_image_url.startswith('http'):
|
||||||
return Response(
|
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
|
||||||
status_code=status.HTTP_302_FOUND,
|
return Response(
|
||||||
headers={'Location': profile_image_url},
|
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'):
|
elif profile_image_url.startswith('data:image'):
|
||||||
try:
|
try:
|
||||||
header, base64_data = profile_image_url.split(',', 1)
|
header, base64_data = profile_image_url.split(',', 1)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from open_webui.models.users import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
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
|
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)
|
user = await Users.get_user_by_id(user_id)
|
||||||
if user:
|
if user:
|
||||||
if user.profile_image_url:
|
if user.profile_image_url:
|
||||||
# check if it's url or base64
|
|
||||||
if user.profile_image_url.startswith('http'):
|
if user.profile_image_url.startswith('http'):
|
||||||
return Response(
|
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
|
||||||
status_code=status.HTTP_302_FOUND,
|
return Response(
|
||||||
headers={'Location': user.profile_image_url},
|
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'):
|
elif user.profile_image_url.startswith('data:image'):
|
||||||
try:
|
try:
|
||||||
header, base64_data = user.profile_image_url.split(',', 1)
|
header, base64_data = user.profile_image_url.split(',', 1)
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export const userSignOut = async () => {
|
|||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, {
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
import { safeImageUrl } from '$lib/utils/safeImageUrl';
|
||||||
|
|
||||||
export let className = 'size-8';
|
export let className = 'size-8';
|
||||||
export let src = `${WEBUI_BASE_URL}/static/favicon.png`;
|
export let src = `${WEBUI_BASE_URL}/static/favicon.png`;
|
||||||
@@ -7,14 +8,7 @@
|
|||||||
|
|
||||||
<img
|
<img
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
src={src === ''
|
src={safeImageUrl(src)}
|
||||||
? `${WEBUI_BASE_URL}/static/favicon.png`
|
|
||||||
: src.startsWith(WEBUI_BASE_URL) ||
|
|
||||||
src.startsWith('https://www.gravatar.com/avatar/') ||
|
|
||||||
src.startsWith('data:') ||
|
|
||||||
src.startsWith('/')
|
|
||||||
? src
|
|
||||||
: `${WEBUI_BASE_URL}/user.png`}
|
|
||||||
class=" {className} object-cover rounded-full"
|
class=" {className} object-cover rounded-full"
|
||||||
alt="profile"
|
alt="profile"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
import { safeImageUrl } from '$lib/utils/safeImageUrl';
|
||||||
|
|
||||||
import { settings } from '$lib/stores';
|
import { settings } from '$lib/stores';
|
||||||
import ImagePreview from './ImagePreview.svelte';
|
import ImagePreview from './ImagePreview.svelte';
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
let _src = '';
|
let _src = '';
|
||||||
$: _src = src.startsWith('/') ? `${WEBUI_BASE_URL}${src}` : src;
|
$: _src = safeImageUrl(src.startsWith('/') ? `${WEBUI_BASE_URL}${src}` : src);
|
||||||
|
|
||||||
let showImagePreview = false;
|
let showImagePreview = false;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
|
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
|
||||||
|
import { safeImageUrl } from '$lib/utils/safeImageUrl';
|
||||||
|
|
||||||
export interface ImageOptions {
|
export interface ImageOptions {
|
||||||
/**
|
/**
|
||||||
@@ -137,12 +138,12 @@ export const Image = Node.create<ImageOptions>({
|
|||||||
if (editorFiles && node.attrs.src.startsWith('data://')) {
|
if (editorFiles && node.attrs.src.startsWith('data://')) {
|
||||||
const file = editorFiles.find((f) => f.id === fileId);
|
const file = editorFiles.find((f) => f.id === fileId);
|
||||||
if (file) {
|
if (file) {
|
||||||
img.setAttribute('src', file.url || '');
|
img.setAttribute('src', safeImageUrl(file.url || ''));
|
||||||
} else {
|
} else {
|
||||||
img.setAttribute('src', '/image-placeholder.png');
|
img.setAttribute('src', '/image-placeholder.png');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
img.setAttribute('src', node.attrs.src || '');
|
img.setAttribute('src', safeImageUrl(node.attrs.src || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
img.setAttribute('alt', node.attrs.alt || '');
|
img.setAttribute('alt', node.attrs.alt || '');
|
||||||
@@ -153,7 +154,7 @@ export const Image = Node.create<ImageOptions>({
|
|||||||
if (files && node.attrs.src.startsWith('data://')) {
|
if (files && node.attrs.src.startsWith('data://')) {
|
||||||
const file = editorFiles.find((f) => f.id === fileId);
|
const file = editorFiles.find((f) => f.id === fileId);
|
||||||
if (file) {
|
if (file) {
|
||||||
img.setAttribute('src', file.url || '');
|
img.setAttribute('src', safeImageUrl(file.url || ''));
|
||||||
} else {
|
} else {
|
||||||
img.setAttribute('src', '/image-placeholder.png');
|
img.setAttribute('src', '/image-placeholder.png');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user