Open WebUI: Stored XSS to Account Takeover via Model Profile Images
Stored XSS to Account Takeover via Model Profile Images in Open WebUI
Affected: Open WebUI <= 0.9.5
Bypass of: GHSA-3wgj-c2hg-vm6q, GHSA-3856-3vxq-m6fc
---
TL;DR
Open WebUI patched SVG XSS in user profile images and webhook profile images but forgot to apply the same fix to model profile images. The ModelMeta class has no validate_profile_image_url field validator, and the model image serving endpoint has no MIME allowlist or nosniff header. Any authenticated user with workspace.models permission (enabled by default) can store a data:image/svg+xml;base64,... payload in a model's profile image and achieve full account takeover of anyone who navigates to the image URL.
---
Past of the issue
In early 2025, two security advisories landed for Open WebUI:
- GHSA-3wgj-c2hg-vm6q SVG XSS via user profile images
- GHSA-3856-3vxq-m6fc SVG XSS via webhook profile images
The patches were clean. A validate_profile_image_url function was introduced in backend/open_webui/utils/validate.py a compiled regex that restricts data: URIs to safe raster formats (image/png, image/jpeg, image/gif, image/webp), explicitly excluding image/svg+xml because SVG can carry embedded <script> tags. On the output side, users.py added a MIME allowlist check and X-Content-Type-Options: nosniff.
The fix was applied to UserUpdateForm,...
Affected versions
| Package | Affected | Fixed in |
|---|---|---|
| open-webui PyPI | < 0.9.6 | 0.9.6 |
Details and references
# Stored XSS to Account Takeover via Model Profile Images in Open WebUI **Affected:** Open WebUI <= 0.9.5 **Bypass of:** GHSA-3wgj-c2hg-vm6q, GHSA-3856-3vxq-m6fc --- ## TL;DR Open WebUI patched SVG XSS in user profile images and webhook profile images but forgot to apply the same fix to **model** profile images. The `ModelMeta` class has no `validate_profile_image_url` field validator, and the model image serving endpoint has no MIME allowlist or `nosniff` header. Any authenticated user with `workspace.models` permission (enabled by default) can store a `data:image/svg+xml;base64,...` payload in a model's profile image and achieve full account takeover of anyone who navigates to the image URL. --- ## Past of the issue In early 2025, two security advisories landed for Open WebUI: - **GHSA-3wgj-c2hg-vm6q** SVG XSS via user profile images - **GHSA-3856-3vxq-m6fc** SVG XSS via webhook profile images The patches were clean. A `validate_profile_image_url` function was introduced in `backend/open_webui/utils/validate.py` a compiled regex that restricts `data:` URIs to safe raster formats (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), explicitly excluding `image/svg+xml` because SVG can carry embedded `<script>` tags. On the output side, `users.py` added a MIME allowlist check and `X-Content-Type-Options: nosniff`. The fix was applied to `UserUpdateForm`, `UpdateProfileForm`, and later to `ChannelWebhookForm`. Three models patched. Case closed. Except there was a fourth endpoint. ## The Gap Open WebUI has a concept of "Models" user-created model configurations with metadata including a profile image. The metadata lives in `ModelMeta`: ```python # backend/open_webui/models/models.py, line 37-47 class ModelMeta(BaseModel): profile_image_url: Optional[str] = '/static/favicon.png' description: Optional[str] = None capabilities: Optional[dict] = None model_config = ConfigDict(extra='allow') ``` No `@field_validator`. No import of `validate_profile_image_url`. `ModelMeta` accepts any string as `profile_image_url` including `data:image/svg+xml;base64,...`. The serving endpoint at `GET /api/v1/models/model/profile/image` has the same gap: ```python # backend/open_webui/routers/models.py, line 503-518 elif profile_image_url.startswith('data: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:') headers = {'Content-Disposition': 'inline'} # ... return StreamingResponse( image_buffer, media_type=media_type, headers=headers, ) ``` No MIME allowlist. No `nosniff`. No CSP. The SVG is served inline with `Content-Type: image/svg+xml` on the application's origin. Compare this with the **patched** user endpoint: ```python # backend/open_webui/routers/users.py, line 497-509 media_type = header.split(';')[0].lstrip('data:').lower() if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES: # <-- ABSENT in models.py return FileResponse(f'{STATIC_DIR}/user.png') return StreamingResponse( image_buffer, media_type=media_type, headers={ 'Content-Disposition': 'inline', 'X-Content-Type-Options': 'nosniff', # <-- ABSENT in models.py }, ) ``` The fix exists. It just was never applied here. ## Comparison Table | Endpoint | Input Validation | MIME Allowlist | nosniff | Status | |----------|:---:|:---:|:---:|--------| | `GET /users/{id}/profile/image` | YES | YES | YES | **Patched** | | `GET /webhooks/{id}/profile/image` | YES | no | no | Partially patched | | `GET /models/model/profile/image` | **NO** | **NO** | **NO** | **Vulnerable** | ## Three Write Vectors The malicious SVG data URI can be injected through any of three endpoints all pass `ModelForm` containing `ModelMeta` without validation: 1. **`POST /api/v1/models/create`** (line 195) any user with `workspace.