Skip to content

Commit f5f4b58

Browse files
Classic298claude
andauthored
fix: harden model profile image against SVG stored XSS (open-webui#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) <noreply@anthropic.com>
1 parent 78b1637 commit f5f4b58

2 files changed

Lines changed: 23 additions & 5 deletions

File tree

backend/open_webui/models/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
1010
from open_webui.models.groups import Groups
1111
from open_webui.models.users import User, UserModel, UserResponse, Users
12-
from pydantic import BaseModel, ConfigDict, Field, model_validator
12+
from open_webui.utils.validate import validate_profile_image_url
13+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
1314
from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update
1415
from sqlalchemy.dialects.postgresql import JSONB
1516
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,6 +36,13 @@ class ModelMeta(BaseModel):
3536

3637
model_config = ConfigDict(extra='allow')
3738

39+
@field_validator('profile_image_url', mode='before')
40+
@classmethod
41+
def check_profile_image_url(cls, v: str | None) -> str | None:
42+
if v is None:
43+
return v
44+
return validate_profile_image_url(v)
45+
3846
@model_validator(mode='before')
3947
@classmethod
4048
def normalize_tags(cls, data):

backend/open_webui/routers/models.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from fastapi.responses import RedirectResponse, StreamingResponse
2121
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
2222
from open_webui.constants import ERROR_MESSAGES
23-
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING
23+
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES
2424
from open_webui.internal.db import get_async_session
2525
from open_webui.models.access_grants import AccessGrants
2626
from open_webui.models.groups import Groups
@@ -503,9 +503,19 @@ async def get_model_profile_image(
503503
header, base64_data = profile_image_url.split(',', 1)
504504
image_data = base64.b64decode(base64_data)
505505
image_buffer = io.BytesIO(image_data)
506-
media_type = header.split(';')[0].lstrip('data:')
507-
508-
headers = {'Content-Disposition': 'inline'}
506+
media_type = header.split(';')[0].lstrip('data:').lower()
507+
508+
# only serve known-safe raster types inline; reject SVG/unknown (can run script on our origin)
509+
if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
510+
return RedirectResponse(
511+
url='/static/favicon.png',
512+
status_code=status.HTTP_302_FOUND,
513+
)
514+
515+
headers = {
516+
'Content-Disposition': 'inline',
517+
'X-Content-Type-Options': 'nosniff',
518+
}
509519
if updated_at:
510520
headers['ETag'] = f'"{updated_at}"'
511521

0 commit comments

Comments
 (0)