mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-07 02:58:21 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
78b1637a03
commit
f5f4b58958
2 changed files with 22 additions and 4 deletions
|
|
@ -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.access_grants import AccessGrantModel, AccessGrants
|
||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import User, UserModel, UserResponse, Users
|
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 import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -35,6 +36,13 @@ class ModelMeta(BaseModel):
|
||||||
|
|
||||||
model_config = ConfigDict(extra='allow')
|
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')
|
@model_validator(mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_tags(cls, data):
|
def normalize_tags(cls, data):
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from fastapi import (
|
||||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
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.internal.db import get_async_session
|
||||||
from open_webui.models.access_grants import AccessGrants
|
from open_webui.models.access_grants import AccessGrants
|
||||||
from open_webui.models.groups import Groups
|
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)
|
header, base64_data = profile_image_url.split(',', 1)
|
||||||
image_data = base64.b64decode(base64_data)
|
image_data = base64.b64decode(base64_data)
|
||||||
image_buffer = io.BytesIO(image_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:
|
if updated_at:
|
||||||
headers['ETag'] = f'"{updated_at}"'
|
headers['ETag'] = f'"{updated_at}"'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue