From f5f4b58958984e5955fdab36f9481eeb830153af Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 29 May 2026 00:41:55 +0200 Subject: [PATCH] fix: harden model profile image against SVG stored XSS (#25060) ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes. Co-authored-by: Claude Opus 4.7 (1M context) --- backend/open_webui/models/models.py | 10 +++++++++- backend/open_webui/routers/models.py | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 43efa561ea..671fa6c3c9 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -9,7 +9,8 @@ from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse, Users -from pydantic import BaseModel, ConfigDict, Field, model_validator +from open_webui.utils.validate import validate_profile_image_url +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession @@ -35,6 +36,13 @@ class ModelMeta(BaseModel): model_config = ConfigDict(extra='allow') + @field_validator('profile_image_url', mode='before') + @classmethod + def check_profile_image_url(cls, v: str | None) -> str | None: + if v is None: + return v + return validate_profile_image_url(v) + @model_validator(mode='before') @classmethod def normalize_tags(cls, data): diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 2da2e0c0b7..f3737cb66d 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -20,7 +20,7 @@ from fastapi import ( from fastapi.responses import RedirectResponse, StreamingResponse from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.constants import ERROR_MESSAGES -from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups @@ -503,9 +503,19 @@ async def get_model_profile_image( header, base64_data = profile_image_url.split(',', 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) - media_type = header.split(';')[0].lstrip('data:') + media_type = header.split(';')[0].lstrip('data:').lower() - headers = {'Content-Disposition': 'inline'} + # only serve known-safe raster types inline; reject SVG/unknown (can run script on our origin) + if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES: + return RedirectResponse( + url='/static/favicon.png', + status_code=status.HTTP_302_FOUND, + ) + + headers = { + 'Content-Disposition': 'inline', + 'X-Content-Type-Options': 'nosniff', + } if updated_at: headers['ETag'] = f'"{updated_at}"'