See how open webui compares to other vendors in security performance
Summary Open WebUI vetted user-supplied URLs by resolving the hostname once and rejecting private, loopback and link-local addresses, then let the HTTP client resolve that hostname again at connect time. An attacker who controls the authoritative DNS for a hostname they submit can answer with a public address during the check and an internal one at connect, so the fetch reaches an address the check was meant to block. Every user-reachable fetch gated by that check was affected, and most of them hand the internal response back to the attacker.
Preconditions - An account on the instance. No admin rights and no non-default configuration. - Control of the authoritative DNS for a hostname the attacker submits, serving a TTL of 0 and alternating answers. - One of the affected entry points: URL ingest for retrieval, an imageurl in a chat completion, image editing, or the OAuth profile-picture fetch. - The OAuth path additionally needs OAuth login configured and a picture claim (OAUTHPICTURECLAIM, default picture) the user can influence, which is the case on self-service OIDC providers and providers with a user-editable avatar URL. On an existing account it also needs OAUTHUPDATEPICTUREONLOGIN, which is off by default. Deployments without OAuth are not affected on that path; the other paths need no configuration at all.
Impact The server can be made to issue requests to addresses only it can reach: cloud instance metadata such as 169.254.169.254, loopback-bound admin APIs, and internal network services. The response comes back to the attacker on most paths, as document content on the retrieval path, described by the vision model on the chat image path, and base64-encoded into the profile picture on the OAuth path; the image-edit path is blind. On the OAuth path the server also forwards the OAuth access token as a Bearer header to the fetched URL, so a rebind hands that token to the internal target. On a cloud host with IMDSv1 reachable this is enough to take instance IAM credentials.
Exploitation depends on winning the gap between the two resolutions, which the attacker influences but does not fully control. Admin-configured image-generation backends and the shared session pool are not affected and deliberately keep the default client, since an administrator may legitimately point those at an internal host.
Fix Fixed in v0.11.0 (#24759, #25775, #25960, #26699). The check now happens at the connection layer instead of ahead of it: a requests transport adapter resolves the hostname once and connects to that same validated address, and an aiohttp resolver applies the same global-IP check, exposed as a one-off session used by every fetch behind the URL check. Upgrading to v0.11.0 resolves this with no configuration change.
Root cause Affected components: - retrieval web loader (SafeWebBaseLoader) - retrieval content probe (getcontentfromurl) - chat image fetch (getimagebase64fromurl) - image edit fetch (loadurlimage) - OAuth profile-picture fetch (processpictureurl)
The URL check resolved the hostname and inspected the resulting IP, but nothing tied that decision to the connection that followed: the HTTP client resolved the name again on its own, and the second answer was never inspected. The check was therefore an opinion about a past lookup rather than a constraint on the actual connection, which is what a rebinding DNS server defeats. The first connection-layer guard covered only the retrieval loader, leaving the sibling probe, image and OAuth fetches on default clients until each was reported in turn.
Credits - @rezaduty — the rebinding time-of-check/time-of-use bypass and the retrieval loader path. - @nikchillz — the retrieval content-probe path. - @dhyabi2 — the chat imageurl path, where the internal response is read back through the vision model. - @geo-chen — the image-edit path. - @bogdancherniy11-sudo — the OAuth profile-picture path, where the rebind also discloses the forwarded OAuth access token.
Summary Any authenticated user with access to a terminal server could get script of their choosing to run in the Open WebUI origin itself. The HTML file preview rendered terminal-served files in an iframe whose sandbox always granted allow-same-origin alongside allow-scripts, and the file is served from a path on the application's own origin, so the sandbox provided no isolation at all. Script in a previewed file could read the victim's session token and take over the account.
Preconditions - At least one terminal server configured by an admin (TERMINALSERVERCONNECTIONS, empty by default) and reachable by the victim. Deployments with no terminal server configured are not affected. - The attacker needs a normal authenticated account with access to that terminal server, no admin rights. - No victim interaction beyond having the chat open: a displayfile tool call opens the preview automatically. - TERMINALPROXYHEADERS unset, which is the default. An operator who had already set a restrictive Content-Security-Policy through it was not exposed, since those headers are merged into every proxied response including the served file. - The iframeSandboxAllowSameOrigin user setting is off by default, but the affected branch ignored it entirely.
Impact The previewed document runs in the application origin, so it can reach the parent window, read the session token out of localStorage and exfiltrate it, which is full account takeover of the victim. If the victim is an admin, or any user holding workspace.functions, that takeover extends to server-side code execution through Functions. Getting the malicious file written and displayed still requires a prompt-injection or a social step, which is what keeps the complexity high rather than trivial. Instances with no terminal server configured were never affected, and neither was the srcdoc preview path.
Fix Fixed in 0.11.0 by 65a5fad7b (#26907). The serveUrl preview branch now gates allow-same-origin behind the same iframeSandboxAllowSameOrigin setting the srcdoc branch already used, so by default the preview loads at an opaque origin and cannot reach the parent context. Upgrading is sufficient, no configuration change is required, and HTML previews continue to render normally.
Root cause - src/lib/components/chat/FileNav/FilePreview.svelte, the serveUrl iframe branch, reached for HTML files served through /api/v1/terminals/{id}/files/serve/.... - Present from 0.9.0, where that branch was introduced, through 0.10.2.
The component grew two preview paths. The srcdoc path was hardened: same-origin became opt-in and a CSP was injected into the document. The serveUrl path, added later for files streamed from a terminal server, kept a static sandbox string with allow-same-origin baked into it. Because the terminal proxy is mounted under the application's own origin and forwards the upstream response without adding a Content-Security-Policy of its own unless the operator configured one, and no global CSP is set, the sandbox was the only isolation boundary left, and it was granting precisely the permission that dissolved it.
Proof of concept Write an HTML file containing a script that reads window.parent.localStorage.token to a terminal server the victim can reach, then trigger displayfile for that file. The chat handler opens the preview on the resulting terminal:displayfile event with no click, the script executes at the application origin, and the token is exfiltrated.
Credits Reported by @manus-use (researcher zx / Jace).
Summary
Open WebUI fetches user-supplied URLs on the server for RAG URL ingestion, URL-to-markdown conversion and web-search content retrieval, and decides whether a destination is allowed by asking whether its IP address is globally routable. That test operates on the literal IPv6 address and does not look at the IPv4 address embedded inside it. On a deployment whose network has a NAT64 gateway, any verified user can wrap an internal or cloud-metadata IPv4 address in the NAT64 well-known prefix, pass the filter, and receive the internal response body back through the API.
Preconditions
- Any verified (authenticated) user account. No admin role, no elevated permission. - Default configuration: ENABLELOCALWEBFETCH off, the default WEBFETCHFILTERLIST metadata blocklist in place. Neither prevents this, because the blocklist matches hostname strings and the NAT64 literal is not one of them. - The deployment's network must provide NAT64 translation for the well-known 64:ff9b::/96 prefix, which is the common default on IPv6-only and dual-stack cloud and Kubernetes networks. - Deployments on IPv4-only networks, or on any network without a NAT64 gateway, are not affected: the address has nowhere to route.
Impact
On an affected network a low-privilege user can read GET responses from services the server can reach but the internet cannot: cloud instance metadata including IAM role credentials, loopback-bound admin surfaces, and internal APIs in the same VPC or cluster. The response body is returned to the caller, so this is full-read, not blind. Exploitation is not universal, it depends entirely on the deployment's network providing NAT64 translation, which is why the score carries high attack complexity. Deployments without NAT64 lose nothing here.
Fix
Fixed in v0.11.0 by commit 1717b493d. Address classification now unwraps the IPv4 embedded in IPv6 transition encodings before deciding whether a destination is global, and applies that at all three checkpoints. NAT64-wrapped public destinations continue to work. Upgrading to v0.11.0 fully resolves the issue with no configuration change.
Root cause
- backend/openwebui/retrieval/web/utils.py — validateurl(), the pre-fetch check on the submitted URL. - backend/openwebui/retrieval/web/utils.py — ssrfsafenewconn() and SSRFSafeResolver, the connect-time re-checks that defeat DNS rebinding.
All three decided reachability from ipaddress.ipaddress(ip).isglobal applied to the literal address. That predicate answers whether an IPv6 address sits in globally-routable space, which is a different question from where the packet actually ends up once a transition gateway translates it. The NAT64 well-known prefix is by design a global prefix carrying an arbitrary IPv4 destination, so an internal target wrapped in it satisfies the check while reaching exactly what the check exists to prevent. Because the same predicate backed the connect-time re-checks, no later layer caught it either. The fix inspects every standardized transition encoding rather than only the NAT64 prefix, since the same reasoning error applies to each of them.
Proof of concept
Against the real POST /api/v1/retrieval/process/web flow on v0.10.2 as an authenticated user, with internal HTTP services returning a marker string. The plain forms are rejected with HTTP 400:
http://169.254.169.254/latest/meta-data/ -> 400 http://127.0.0.1/ -> 400 http://[::ffff:169.254.169.254]/ -> 400 http://metadata.google.internal/ -> 400
The NAT64 encodings of the same targets are accepted, and the response body is returned in the content field:
http://[64:ff9b::a9fe:a9fe]/latest/meta-data/iam/security-credentials/ -> 200, marker returned http://[64:ff9b::7f00:1]/admin/internal-status -> 200, marker returned
NAT64 translation was modelled by binding the translated addresses locally rather than by routing through a real NAT64 gateway; everything else, including the request flow and the validation code, is the unmodified v0.10.2 path. After the fix both URLs return 400 while http://[64:ff9b::808:808]/ (8.8.8.8, public) still returns 200, confirming no over-blocking.
Credits
- tonghuaroot — reported the transition-form gap in the address classification and supplied the fix approach.
Summary The built-in knowledge search tool returns knowledge bases the calling user has no access to. The tool works out which knowledge bases the caller may read and hands that set to the vector store as a search filter, and that filter is the only access control on the path. Most of the shipped vector backends accept the filter argument on their search method and never apply it, so the search returns matches from every knowledge base in the shared collection. The sibling query method on those same backends does apply a filter, which is why the omission is invisible from the calling code.
Preconditions - The deployment must use one of the affected vector backends, selected with VECTORDB, which defaults to chroma. Chroma applies the filter, so a default deployment is not affected. - Eleven of the fifteen shipped backends ignore the filter: both Qdrant clients, Elasticsearch, OpenSearch, both Milvus clients, openGauss, Oracle 23ai, Pinecone, S3 Vectors and Weaviate. Chroma, pgvector, MariaDB and Valkey apply it and were never affected. - The caller needs a model that can call tools, with the knowledge built-in tools left enabled, which is the default, and with no knowledge attached to the model itself. No elevated role or permission is required. - At least one knowledge base must exist that the caller cannot otherwise read.
Impact A user receives the identifier, name and description of knowledge bases that were never shared with them, and chooses how many results to ask for, so the set can be enumerated by varying the query. The affected collection stores one entry per knowledge base whose text is its name and description, so where those values are themselves sensitive, for example when they name a customer, a project or an investigation, that disclosure is the loss.
The exposure is confined to that metadata. Document text lives in separate per-knowledge-base collections reached by a different call that is scoped by collection rather than by this filter, so stored documents are not returned by this path, and no write access is gained. Because the default backend is unaffected, the population at risk is operators who deliberately moved to an external vector store, which in practice means larger deployments.
Fix Fixed in 0.11.1 by 1d6d4e6e6. Every affected backend now applies the caller-supplied filter in its search method, combined with the collection or tenant scoping that method already performed. Where a backend's filter builder could express only single-value equality it was extended to express set membership, since that is the form the knowledge tool sends, and a filter using any other operator is now rejected rather than dropped. Upgrading is sufficient and no configuration change is required. Deployments on Chroma, pgvector, MariaDB or Valkey were never affected and need no action.
Root cause - The search method of each affected vector client, which declares a filter parameter and never references it. - The built-in knowledge search tool, which relies on that filter as its only access check. - Present from 0.7.0, the release that introduced the tool, through 0.11.0.
Each client exposes a search method and a query method that appear interchangeable from the calling side and differ in whether the caller's filter survives. Search built its request with collection or tenant scoping only, so it was correctly scoped to the collection and entirely unscoped within it. The knowledge tool resolves the caller's readable knowledge bases correctly and passes them down, then reads the results without rechecking them, on the reasonable assumption that a filter handed to a vector store is applied. Because the access decision was delegated wholly to a parameter that most implementations discarded, the caller had no way to observe that it received more than it asked for.
That the same omission repeated across most backends points at the shared interface rather than at any one client: the base class defines the parameter without obliging an implementation to honour it, and each backend was written against the interface independently.
Proof of concept Run against the shipped client classes taken from the 0.11.0 and 0.11.3 release tags, with a real Qdrant engine behind them. Two entries were inserted into the shared knowledge base collection, one readable and one not, mirroring the payload the application writes. The entries were inserted directly rather than created through the application.
On 0.11.0, search given a filter naming only the readable knowledge base returned both, and the same call with no filter returned an identical result set, confirming the filter had no effect:
search(filter={'knowledgebaseid': {'$in': ['kb-allowed']}}) -> ['kb-allowed', 'kb-secret'] search(filter=None) -> ['kb-allowed', 'kb-secret']
On 0.11.3 the same filter returned only the readable entry, while the unfiltered control still returned both, confirming the collection held both and the filter is what excluded the second:
search(filter={'knowledgebaseid': {'$in': ['kb-allowed']}}) -> ['kb-allowed'] search(filter=None) -> ['kb-allowed', 'kb-secret']
The Chroma client, run the same way on both tags, returned only the readable entry in every case, which is the default backend being unaffected. The S3 Vectors client was additionally run against a stub that answers every query with the whole index regardless of the filter it is sent: on 0.11.0 it returned both entries, and on 0.11.3 it returned only the readable one.
The remaining backends were checked by driving each patched filter builder with the exact filter the knowledge tool sends and confirming the request it produces restricts to the readable knowledge base.
Credits Reported by @Classic298.
Summary Any member of a channel who can post to it could also replace the text of a message written by a different member. The channel branch of the chat completions endpoint checked that the caller may write to the channel, and that the targeted message belongs to that channel, but never checked that the caller actually wrote the message being edited. The message kept its original author, so the replacement text was displayed and stored as though the victim had written it. The dedicated channel message edit route performed the authorship check correctly and refused the same edit, so the two paths disagreed about who may modify a message.
Preconditions - Channels must be enabled. ENABLECHANNELS defaults to False, so a default deployment is not affected until an administrator turns channels on. - The attacker must be an approved user holding write access to the target channel, which is the ordinary state for any participant. No elevated role, group membership or additional permission is needed, and the attacker does not need to be the channel owner. - The attacker must know the id of the targeted message. That id is returned to every member who reads the channel, so it requires no further access. - Channels the attacker cannot post to are not affected, and neither are deployments with channels disabled.
Impact An attacker can rewrite what another member said, under that member's name, in a shared channel. The stored record and every later reader show the victim as the author of text they never wrote, so this is a loss of both integrity and non-repudiation: the victim cannot point to the record to show what they actually said. A conversation can be silently altered after the fact rather than only appended to.
How much control the attacker has over the replacement text depends on the configured model, because the text is produced by generating a completion into the victim's message. Reducing a message to unrelated or empty content works regardless of the model, while reproducing an exact attacker-chosen string requires a model that returns the supplied prompt closely enough. The attack grants no new role or permission, does not reach channels the attacker cannot already post to, and exposes no message content they could not already read.
Fix Fixed in 0.11.1 by commit 7d392bedc (#28631). The gate on the channel branch now also compares the targeted message's author against the calling user and refuses when they differ, which is the condition the dedicated channel message update route already applied. Administrators retain the ability to edit any message, on both paths, as before. Upgrading to 0.11.1 fully resolves this, and no configuration change is required.
Root cause Affected components: - the channel branch of the chat completions handler, which resolves a client-supplied message id for editing - the channel event emitter, which re-checks the message before persisting the update
Not affected: the dedicated channel message update route, which already held the correct check.
The channel branch was written to answer the question of whether the caller may write into this channel and whether the message belongs to it. Both of those checks were present and effective. The question it never asked was whether the caller wrote the message. Ownership of a channel message is carried by the message record rather than by the channel, so channel-level write access was treated as sufficient authority over every message inside it. The dedicated edit route grew the authorship comparison because editing is its only purpose, while the completions path reached the same operation through a general-purpose entry point and inherited only the coarser channel check.
Proof of concept Reproduced against a running instance built from the development branch, with channels enabled and an OpenAI-compatible upstream that echoes the submitted prompt so the replacement text is deterministic.
Setup, entirely through the ordinary API: an administrator creates two further accounts, Alice and Mallory, both with the plain user role and no additional permissions, creates a channel, and grants both accounts read and write access. Alice posts a message reading ALICE ORIGINAL MESSAGE. Nothing is written directly to the database.
Control, through the dedicated route:
POST /api/v1/channels/<channelid>/messages/<alicemessageid>/update Authorization: Bearer <mallorytoken> {"content": "PWNEDBYMALLORY"} -> 403
Attack, submitting the same target id to the completions endpoint:
POST /api/chat/completions Authorization: Bearer <mallorytoken> {"model": "<model>", "stream": true, "chatid": "channel:<channelid>", "id": "<alicemessageid>", "messages": [{"role": "user", "content": "PWNEDBYMALLORY"}]} -> 200
Reading the channel back afterwards shows the targeted message content replaced with the attacker's string while its recorded author is still Alice.
Credits - @Classic298 — identified that the completions path and the dedicated edit route disagreed on message authorship, and reproduced the cross-member overwrite end to end.
Summary Any authenticated user with access to a shared terminal server could get script of their choosing to run in the Open WebUI origin itself. The in-app port preview rendered the content of a previewed port in an iframe whose sandbox always granted allow-same-origin alongside allow-scripts, and that content is served from a path on the application's own origin, so the sandbox provided no isolation at all. Script served on a previewed port could read the victim's session token and take over the account.
Preconditions - At least one terminal server configured by an admin (TERMINALSERVERCONNECTIONS, empty by default) and reachable by both the attacker and the victim. Deployments with no terminal server configured are not affected. - The attacker needs a normal authenticated account with access to that terminal connection, no admin rights, plus the ability to start a process listening on a port there. - Personal terminals a user configures for themselves are not affected. Those carry an external URL, so the previewed document is cross-origin and the sandbox held. - The victim has to open the port list and click the attacker's port. - TERMINALPROXYHEADERS unset and no CONTENTSECURITYPOLICY set, which are the defaults. An operator who had already set a restrictive Content-Security-Policy through either was not exposed, since those headers apply to the proxied response. - The iframeSandboxAllowSameOrigin user setting is off by default, but the affected branch ignored it entirely.
Impact The previewed page runs in the application origin, so it can reach the parent window, read the session token out of localStorage and exfiltrate it, which is full account takeover of the victim. If the victim is an admin, or any user holding workspace.functions, that takeover extends to server-side code execution through Functions. Serving the page costs the attacker nothing beyond the terminal access they already hold, so the only real barrier is getting the victim to open that port. Instances with no terminal server configured were never affected, and neither were personal terminals pointed at an external URL.
Fix Fixed in 0.11.1 by 54d7a2237. The port preview now gates allow-same-origin behind a terminalPreviewAllowSameOrigin user setting that is off by default, so the preview loads at an opaque origin and cannot reach the parent context. Upgrading is sufficient and no configuration change is required. Previews of pages that need same-origin access still work if a user turns that setting on, and doing so re-enables the behaviour described here for that user.
Root cause - src/lib/components/chat/FileNav/PortPreview.svelte, the preview iframe, reached by clicking a port in the file navigator's port list. - Present from 0.8.11, the first release containing the component, through 0.11.0.
Every other iframe in the application had already been moved to an opt-in same-origin setting. The port preview kept a static sandbox string with allow-same-origin baked into it, and it was missed when the equivalent problem was fixed in the file preview for 0.11.0. Because the terminal proxy is mounted under the application's own origin and forwards the upstream response and its content type without adding a Content-Security-Policy of its own unless the operator configured one, and no global CSP is set, the sandbox was the only isolation boundary left, and it was granting precisely the permission that dissolved it.
Proof of concept On a terminal server the victim can also reach, serve an HTML page containing a script that reads window.parent.localStorage.token and sends it to a host the attacker controls. The victim opens the file navigator, switches to the port list and clicks that port. The preview opens, the script executes at the application origin, and the token is exfiltrated.
Credits Reported by @manus-use (researcher zx / Jace).
Summary The chat-completions endpoint reads a folder id out of the request body and saves the newly created chat into that folder without checking that the caller is allowed to write there. Any authenticated user who knows a folder's id can put a chat of their own into another user's folder, including a shared folder where they hold read-only access and a folder they have no access to at all. The chat then shows up in that folder for everyone who can read it, under a title and with content the attacker controls. The dedicated chat-creation and chat-move endpoints already enforced this check, the chat-completions path did not.
Preconditions - An authenticated account of any role. No elevated permission, no admin involvement. - Folders enabled, which is the default (ENABLEFOLDERS=true, USERPERMISSIONSFEATURESFOLDERS=true). - The attacker needs the target folder's id. A member of a shared folder gets it from the shared-folder listing. Sharing a folder with named users is available to ordinary users by default; only wildcard public sharing is gated. A folder that was never shared has an id the attacker cannot obtain through any endpoint available to them, so those folders are not practically reachable. - Releases before 0.10.0 contain the same missing check but have no read path that lists a folder's chats across owners, so the injected row was never visible to anyone.
Impact The integrity of folder contents. An attacker who cannot write to a folder can place chats into it, and every member with read access, the owner included, sees the entry with the attacker's display name and a title the attacker can change to arbitrary text at any time. Members can also open the injected chat and read its messages. In a workspace where a shared folder is treated as trusted, that is a usable surface for planting misleading or phishing content under another team's nose.
Nothing is disclosed to the attacker and nothing existing is altered. Writing into a folder grants no read access to it, so the attacker still cannot list or open the folder's other chats, and no data belonging to other users can be modified or deleted through this path. Availability is unaffected.
Fix Fixed in 0.11.1 by https://github.com/open-webui/open-webui/pull/28366. Chat creation, chat moves and the chat-completions creation path now share a single folder write-access check, so a folder id the caller cannot write to is rejected everywhere a chat's folder can be set. Upgrading to 0.11.1 resolves it completely, with no configuration change required.
Root cause Affected component: the chat-completions handler in backend/openwebui/main.py, reached through POST /api/chat/completions and POST /api/v1/chat/completions. Affected setup: every deployment on 0.10.0 through 0.11.0 with folders enabled.
The completions handler gained its own chat-creation branch, which copied the client-supplied folder id into the chat it saved. The ownership and shared-write check lived as duplicated inline code inside the two chat routers rather than in one shared helper, so a third place that learned to set a folder id inherited none of it. Separately, folder listings had been changed to return a folder's chats across all owners so that shared folders work at all, which turned a write that used to be invisible to everyone into one the whole folder can see.
Proof of concept Reproduced on a running instance at 0.11.0 with three accounts: one administrator and two ordinary users, one acting as victim and one as attacker.
1. The victim creates a folder. The attacker is given no access to it: reading the folder returns 404 and listing its chats returns 403. 2. The attacker sends POST /api/v1/chats/new with the victim's folder id. Rejected with 404, which is the pre-existing guard working. 3. The attacker sends POST /api/chat/completions with parentid: null, no chat id, and the victim's folder id in the body. Accepted. 4. The victim lists their own folder and sees a chat owned by "Attacker". The victim can open it and read its messages. The attacker then renames their own chat and the new title appears verbatim in the victim's folder listing.
Repeating step 3 with the attacker granted read-only access to a shared folder also succeeds on 0.11.0. On 0.11.1 both variants are rejected with 404 and the folder stays empty, while a member holding write access can still create chats there as intended.
The three accounts and the folder were created through the normal API. Nothing was written directly to the database.
Credits whyiug, for reporting the missing folder write-access check on the chat-completions creation path.
Summary External knowledge connections are created and owned by administrators, and are shared by every external knowledge base bound to them. Deleting an external knowledge base also removed that connection from the instance configuration, with no check on the caller's role and no check for other knowledge bases still using it. Any authenticated user holding a write grant on a single external knowledge base could therefore wipe a shared connection that other knowledge bases depend on, and the dedicated administrator route for deleting a connection explicitly refuses that same operation while the connection is still in use.
Preconditions The deployment uses external knowledge bases, backed by the qdrant, milvus or pgvector external providers. Instances with only local knowledge bases are not affected.
An administrator created at least one external connection and at least one external knowledge base bound to it. Both of those actions are admin-only.
The attacker is an ordinary authenticated user holding a write grant on one of those external knowledge bases. A read grant is not enough, and an unrelated user cannot reach the route at all.
The damage scales with sharing. It is worst when one connection backs several knowledge bases, because deleting a single knowledge base destroys the connection for all of them.
Impact An ordinary user removes instance-wide configuration that only administrators can create or manage. Every other external knowledge base bound to the deleted connection keeps its stored connection id and stops working: retrieval against it fails with "External knowledge connection not found", so those knowledge bases return nothing in chat until an administrator recreates the connection by hand. The stored credential goes with it, and connection API responses strip credentials, so an administrator who did not keep the key elsewhere cannot restore the connection without obtaining it again.
Nothing is disclosed to the attacker and the external vector store itself is untouched. What is lost is Open WebUI's own connection configuration, and the availability of the knowledge bases that depend on it.
Fix Fixed in 0.11.1 by https://github.com/open-webui/open-webui/pull/28113. Deleting an external knowledge base now clears its connection only when the caller is an administrator and the knowledge base being deleted is the last one referencing that connection, matching the dedicated connection delete route. Upgrading resolves the issue with no configuration change required.
Root cause Affected component: the knowledge base delete handler in backend/openwebui/routers/knowledge.py, reached through DELETE /api/v1/knowledge/{id}/delete.
Affected setup: every release from 0.10.0, where external knowledge connections were introduced, up to and including 0.11.0.
The handler authorized the caller against the knowledge base, which a write grant satisfies, and then performed a second, unrelated operation against global configuration in the same request. That second step carried no authorization of its own. It neither required the administrator role that creating a connection requires, nor counted the other knowledge bases still bound to the connection. The dedicated route DELETE /api/v1/knowledge/external/connections/{id} already performed both checks, so an outcome an administrator was blocked from was reachable by a non-administrator through the knowledge base route.
Proof of concept Reproduced against a local instance on 0.11.0 with default settings, and against 0.11.1 for comparison.
An administrator created one external connection and two external knowledge bases bound to it, then granted a plain user write access on the first knowledge base. As that user:
DELETE /api/v1/knowledge/{KBA}/delete Authorization: Bearer <user token>
On 0.11.0 the request returns 200 true. The administrator connection list then returns zero connections, the second knowledge base still carries the deleted connection id, and retrieving from it raises "External knowledge connection not found".
On 0.11.1 the same request deletes the knowledge base, the connection list still returns the connection, and the second knowledge base is unaffected. An administrator deleting the last remaining knowledge base on that connection still clears it, so the intended cleanup is preserved.
Credits @Bellingham-max, for reporting the missing authorization on the shared connection teardown.
Summary
Open WebUI fetches user-supplied URLs on the server for RAG URL ingestion and web search, and screens the resolved addresses so internal destinations cannot be reached. That screen decided whether a destination was external by asking Python's standard library whether the address is globally routable. Several addresses reserved for internal use answer yes to that question, including 168.63.129.16, the Azure platform channel every Azure virtual machine can reach. Any authenticated user could therefore make the server issue requests to those addresses and read the responses back through the API.
Preconditions
- The affected paths are the server-side URL fetches: RAG URL ingestion and web search. Both require an authenticated, verified account. No administrator role and no special workspace permission are needed. - ENABLELOCALWEBFETCH must be at its default of false. Setting it to true disables the address screen by design, and internal destinations are reachable on purpose. - WEBFETCHFILTERLIST is empty by default in affected versions, so no operator-supplied block entry covered these addresses unless one was added by hand. - For the Azure platform channel specifically, the Open WebUI host must run on Azure (virtual machine, AKS, Container Apps or equivalent). That address is reachable from every Azure virtual machine regardless of network security group rules. On a host that is not on Azure the address routes nowhere and nothing is reachable through it.
Impact
An authenticated user could direct the server to issue GET requests at addresses reserved for internal use and receive the response body back in the API response, which also lands in the RAG context. On Azure that includes the platform channel, an endpoint the operator never intended to expose to application users. The same gap applied to the IPv4-translated range and to deprecated IPv6 site-local space, which on a host that routes them would reach internal services the same way.
What was observed is the reachability and the read-back. No specific credential or secret was retrieved from the Azure platform channel during this investigation, and this advisory does not claim one. Deployments not hosted on Azure are unaffected for the platform-channel address, and deployments running with local web fetch enabled were never protected by this screen in any version.
Fix
Fixed in https://github.com/open-webui/open-webui/pull/27823. Address screening no longer rests on the library's notion of a globally routable address alone: a default block list of reserved and internal-purpose ranges is applied to every resolved address, at URL validation and again at connection time on both HTTP transports, so it also covers redirect hops and DNS rebinding. Operators can extend the list through WEBFETCHFILTERLIST, which is merged with the defaults and cannot remove them.
Root cause
- backend/openwebui/retrieval/web/utils.py: the shared address screen used by every server-side fetch. - POST /api/v1/retrieval/process/web: RAG URL ingestion, returns the fetched body to the caller. - POST /api/v1/retrieval/process/web/search: web search, fetches each result.
The code ships in every build, so no optional component or feature flag limits which installations carry it.
The screen answered one question, whether an address is globally routable, and treated that answer as a proxy for whether the destination is external. Those are two different questions. The library classifies by IANA special-purpose registry membership, and the Azure platform channel is allocated out of ordinary public IPv4 space, so the library correctly reports it as global while in practice it is an internal endpoint of the host's own platform. Earlier hardening (GHSA-8x5v-cpv7-8jjp) extended the screen to unwrap IPv6 addresses that carry an IPv4 address inside them. That closed the encoded-address bypasses and left the underlying test unchanged, so an address that is public by registry and internal by convention still passed.
Proof of concept
As any verified user:
POST /api/v1/retrieval/process/web {"url": "http://168.63.129.16/?comp=versions"}
On an affected version the address passes validation, the request is issued, and the response body is returned in the content field of the API response.
The screening code of each released version was exercised directly:
- 0.11.0: 168.63.129.16, ::ffff:0:169.254.169.254 and fec0::1 all pass validation. - 0.11.1: all three are rejected, along with every alternative spelling of the same address (IPv4-mapped, IPv4-compatible, IPv4-translated, 6to4 and both NAT64 prefixes) and the obfuscated decimal, octal and hexadecimal forms. Ordinary public addresses continue to pass.
No request was issued to a live Azure platform channel. The read-back property was confirmed from the ingestion endpoint, which returns the fetched body to the caller in its response.
Credits
@NaorYaa reported that the Azure platform channel remained reachable after the previous hardening, and demonstrated the same gap for the IPv4-translated and IPv6 site-local ranges.
Summary
The OIDC back-channel logout endpoint is unauthenticated by design, because the identity provider calls it without a browser session. Before checking whether the submitted logout token was genuine, the handler fetched the provider's discovery document and its signing keys over the network, and repeated both fetches on every request because nothing was cached. The signing-key fetch also ran as a blocking call inside the async event loop. A small number of requests carrying a worthless token was therefore enough to make the whole instance stop answering.
Preconditions
- ENABLEOAUTHBACKCHANNELLOGOUT=true. The default is False, so a stock deployment is not affected. This setting is recommended in the Open WebUI hardening documentation, which is why the issue is treated as in scope. - At least one OIDC provider configured (OAUTHCLIENTID, OAUTHCLIENTSECRET, OPENIDPROVIDERURL). - No account, credential, session or secret identifier is required. The attacker needs network access to the instance and the configured issuer string, which is published in the provider's own discovery document.
Impact
Against 0.11.0, with the identity provider answering in 150 ms, 60 concurrent requests carrying a token whose signature was four characters long stalled the async event loop for 8.3 seconds, measured against an idle baseline of 10.8 ms. For the length of that stall the process answers nothing: no chat requests, no API calls, no health check. Open WebUI runs a single worker by default, so the effect is instance-wide rather than per-connection, and no rate limit sits in front of the endpoint.
The same traffic is also amplified outward: 20 sequential requests produced 20 discovery fetches and 40 key-set fetches at the identity provider, so an attacker can drive load onto the provider through the instance.
No data is read, modified or exposed, and the forged token is still rejected. The cost is paid before the rejection.
Fix
Fixed in 0.11.1. The handler now resolves both the discovery document and the signing keys through the already-configured OAuth client, so each is fetched once per provider and reused afterwards, and both fetches are asynchronous rather than blocking the event loop. A token carrying no kid header is rejected before any key lookup happens. Upgrading is sufficient, and no configuration change is required.
Root cause
Affected component: the OIDC back-channel logout handler in backend/openwebui/utils/oauth.py, reached through POST /oauth/backchannel-logout. Affected setups: releases 0.9.0 through 0.11.0 with back-channel logout enabled and an OIDC provider configured.
The handler treated its network work as cheap preparation rather than as work worth protecting. For every request it opened a new HTTP session per configured provider to re-read the discovery document, then constructed a fresh JWKS client, whose own cache consequently started empty each time, and asked that client for the signing key through a synchronous call issued directly on the event loop. All of this ran before the token signature was verified, so an attacker unable to produce a valid token still cost the server two network round trips and a blocked loop per request. Both fetches used the library default timeout of five minutes.
Proof of concept
Reproduced by running the unmodified handler from the 0.11.0 and 0.11.1 backends against a loopback identity provider that counted every inbound request and could answer with a configured delay. The submitted token carried a valid issuer and audience, a kid naming a key the provider does not hold, and AAAA as its signature.
20 sequential requests, provider answering immediately:
| Version | Discovery fetches | Key-set fetches | Response | | --- | --- | --- | --- | | 0.11.0 | 20 | 40 | 400 | | 0.11.1 | 1 | 1 | 400 |
60 concurrent requests, provider answering in 150 ms:
| Version | Wall time | Discovery fetches | Key-set fetches | Worst event-loop stall | | --- | --- | --- | --- | --- | | 0.11.0 | 18.6 s | 60 | 120 | 8255 ms | | 0.11.1 | under 0.01 s | 0 | 0 | none measurable |
Idle event-loop stall was 10.8 ms in both cases. On 0.11.1 the first request an instance receives warms both caches, and every request after that reaches the signature check without any outbound network call.
Credits
@galanko, for reporting the issue and identifying both the missing caching and the blocking call.
Any authenticated user can suppress calendar alerts instance-wide via a non-numeric alert value
Summary
Calendar events carry a free-form meta object that is stored exactly as submitted, with no validation of the values inside it. The scheduler reads the per-event alert offset out of that object in a single pass that covers every user's upcoming events, and compares it numerically without checking that it is a number. Any verified user could store a text value there, which made the comparison raise and abort the whole pass, so no calendar reminder fired for anyone on the instance while that event stayed inside the lookahead window.
Preconditions
- Calendar is enabled (ENABLECALENDAR / calendar.enable, default True). - The attacker is a verified user (role user or admin) holding the calendar feature permission, which is granted to all users by default (USERPERMISSIONSFEATURESCALENDAR, default True). - The event's start time falls inside the scheduler's one hour lookahead window, so the shared alert pass selects it.
No admin access, no shared calendar, no recurrence rule and no open registration are required, an invited account is enough. Deployments running with the calendar disabled, or with the calendar feature permission removed from regular users, are not affected.
Impact
Availability loss on one feature, affecting every user on the instance. While a single event carrying a non-numeric alert value sat in the upcoming window, the shared alert pass raised before emitting anything, so no user received calendar alerts and no event was recorded as alerted. The same events were selected again on the next poll, so the suppression lasted as long as the event stayed in the window, roughly one hour per event, and could be sustained by storing a new one. Every user on a default deployment holds the permission needed to do this.
Chat, timers, automations and the HTTP API kept working throughout, and authenticated calendar reads continued to succeed. There is no crash, no code execution, and no access to other users' data. Alerts resumed on their own once the event left the window or was deleted.
Fix
Fixed in https://github.com/open-webui/open-webui/pull/28790, released in 0.11.1. The scheduler now treats any non-numeric alert value as unset and falls back to the default alert offset, so a stored text value can no longer interrupt the pass. Upgrading is sufficient and no configuration change is required. Events already holding a bad value simply revert to the default alert timing.
Root cause
Affected component: the upcoming-event lookup in the calendar event model, which the scheduler's alert pass calls every poll. Affected setup: every build carrying the calendar feature, which was introduced in 0.9.0.
Event meta is an untyped dictionary and was written to the database exactly as submitted, so nothing on the write path guaranteed that the alert offset was a number. The alert pass then read that value back and compared it numerically on the assumption that the write path had already constrained it, and the loop had no per-event error handling. Because a single background pass serves the whole instance rather than one user at a time, one unusable value from one user aborted the pass for everyone.
Proof of concept
Reported with a scripted reproduction against 0.11.0 (ghcr.io/open-webui/open-webui:latest) with the calendar enabled and two ordinary user accounts. The first account created an upcoming event with the alert offset stored as the text "5", the second created a normal upcoming event with a numeric offset. Across several scheduler polls the second user's event was never marked as alerted and no alert was delivered, while the server logged a type error from the alert pass on each poll. Health checks and authenticated calendar reads returned normally during the fault. After the first account's event was deleted, the second user's event was marked as alerted on the next poll and alerting resumed.
Credits
Binbin Xu of Tencent YUNDING LAB CodeBuddy Security.
Summary Any authenticated user can move one of their own folders under itself, leaving a loop in their folder tree. The re-parent endpoint performed no check that the new parent was not the folder itself or one of its own subfolders, and the folder tree walks did not track which folders they had already visited. A single request against a folder in a loop therefore never finishes.
Preconditions Folders must be enabled (ENABLEFOLDERS, default true) and the acting user's role must hold the folders feature permission (USERPERMISSIONSFEATURESFOLDERS, default true). Both are on in a default install. Any authenticated account is sufficient: no administrator, no second user, no shared folder and no victim interaction. Deployments that disable folders, or withhold the folders feature permission from non-admin roles, are not affected.
Impact A single request that never returns. It keeps running after the client disconnects, holds a CPU core and grows its in-memory folder list at roughly 0.6 GB per hour until the process is restarted. The folder stays in the looping state in the database, so any later request touching that folder starts the loop again.
The instance stays responsive while this happens. Each step of the walk waits on a database query, so other users continue to be served normally; measured on a default install, two concurrent instances of this request left unrelated users unaffected, and roughly 900 concurrent requests were needed before anyone else saw a slowdown. Nothing is denied to any other party at the time of the attack, and an unattended instance degrades over hours rather than immediately. No user data is exposed and no data belonging to another user is modified.
Fix Fixed in 0.11.1 by https://github.com/open-webui/open-webui/pull/28748. Moving a folder into itself or into one of its own subfolders is now rejected with a 400, every folder tree walk skips ids it has already visited, and a folder whose parent chain loops is returned to the root the next time the folder list is requested. An instance that already holds folders in this state recovers on upgrade with no operator action.
Root cause Affected components: the folder re-parent handler POST /api/v1/folders/{id}/update/parent, the subtree walk in the folder model that expands a folder into its descendants, and the two endpoints that call it, DELETE /api/v1/folders/{id} and POST /api/v1/folders/{id}/read. Affected setup: the subtree walk was introduced in 0.10.0, so no earlier release carries the code.
The folder hierarchy is stored as a plain parent reference per folder, with the acyclic property assumed rather than enforced. The write path never validated that assumption, and the read paths were written as if it always held, so nothing on either side would notice or stop a loop.
Proof of concept Reproduced against a default 0.11.0 install. As an ordinary authenticated user:
1. Create a folder and note its id. 2. POST /api/v1/folders/{id}/update/parent with parentid set to that same id. The request is accepted. 3. DELETE /api/v1/folders/{id} (or POST /api/v1/folders/{id}/read). The request never returns, and the worker continues running after the client disconnects.
No data was seeded directly into the database. Every step ran through the public API.
Credits @luida-ikura, who reported the folder parent cycle and the non-terminating subtree walk it reaches.
Summary
A user who is demoted from admin by an identity provider keeps admin-level read and write access to every user's notes, over any Socket.IO connection that was already open when the demotion happened. Open WebUI caches the user's role on the socket at connection time, and the two SSO role-sync paths, the reverse-proxy trusted role header and OAuth role mapping, changed the role in the database without tearing that cached session down. Only the admin user-management endpoints invalidated sessions, so a demotion driven by the identity provider left the old privileges live on the socket.
Preconditions
A role-sync SSO mode has to be enabled. Either trusted-header authentication with WEBUIAUTHTRUSTEDROLEHEADER set, which is unset by default, or OAuth with role mapping enabled, which is off by default. Deployments that change roles only through the admin panel are not affected, because that path already invalidated the user's sockets.
The account has to be an admin at the moment it opens a Socket.IO connection, has to keep that connection open across the demotion, and has to be demoted through the SSO path rather than through the admin panel. Signing out, reloading the page, or any network interruption ends the exposure, because the reconnect reads a fresh role from the database.
Impact
Until the socket closes, the demoted account can open and edit any user's note through the collaborative-notes socket handlers, which grant admins access to every note. That is read and write access to other users' private notes, held by someone the operator has already removed from the admin role.
The rest of the product is not affected. Every HTTP endpoint reads the role fresh from the database on each request, so the demoted account has no admin access to the REST API, no user management, no settings and no model or connection configuration. The exposure ends the moment the connection drops, and the account controls that only by keeping the browser tab open.
The realistic case is off-boarding or a least-privilege downgrade performed in the identity provider, which is a normal administrative action rather than a misconfiguration.
Fix
Fixed in 0.11.1 by commit ce3c175e260709f359d7e6cbb3132f0572098b95. Role changes and user deletions now publish an internal event, and a session handler tears down that user's Socket.IO connections whenever the event fires, so every path that can change a role invalidates cached sessions instead of only the admin endpoints. Upgrading is sufficient, no configuration change is needed.
Root cause
Affected components:
- backend/openwebui/routers/auths.py, the trusted-header role sync in the sign-in handler. - backend/openwebui/utils/oauth.py, the OAuth role sync run on login. - backend/openwebui/socket/main.py, which caches the user record on the socket and authorizes the collaborative-notes handlers against that cached copy.
Session invalidation on role change was added as an explicit call, made at the call sites that changed a role rather than inside the role-change itself. The admin user-management endpoints made that call, the two SSO sync paths did not, and nothing else refreshed the role cached on an open socket. The socket keep-alive rewrites the cached record to reset the idle timer and preserves the stale role while doing so, so a connection that stays active is never reaped. Because the notes handlers grant full access to any note when the cached role reads admin, the gap turned into read and write access over all notes for as long as the connection lived.
Proof of concept
Deploy Open WebUI with trusted-header authentication and WEBUIAUTHTRUSTEDROLEHEADER set, or with OAuth role mapping enabled. Sign in as an admin and leave the browser open on a page holding a Socket.IO connection. Demote the account through the identity provider, meaning the next sign-in carries the role header set to user, or the account's group is changed at the OAuth provider. The database role is now user.
Over the connection that is still open, join and edit another user's note by its id. Both are granted, because the role cached on that socket still reads admin. Performing the same demotion through the admin panel instead drops the connection and revokes the access, which is the behaviour the SSO paths were missing.
This was established by source inspection against 0.11.0 rather than by an executed exploit.
Credits
Reported by @mtholmquist.
Summary
When more than one external tool server is reachable in the same request, a tool call to a server configured for bearer authentication can arrive carrying the calling user's Open WebUI session cookies alongside that server's own key. The cookie jar is built per connection, but the callable that performs the request reads it late instead of per connection, so every tool callable built in the same pass sends the cookies belonging to whichever connection was processed last. An administrator who configures a server with its own API key has not chosen to send that server anything else, and the operator of that server receives a live session credential for the user who triggered the call.
Preconditions
At least two external tool servers must be attached to the same request, and at least one of them must be set to session or system OAuth authentication, since no cookie jar is assembled otherwise. The connection using that authentication mode must be the one processed last, which follows the order of the tool servers attached to the request rather than anything the receiving party controls.
A deployment with no tool servers, with only one tool server, or where no attached server uses session or system OAuth authentication, is not affected. Tool servers are not configured by default.
Impact
The operator of a tool server that was configured with only its own API key receives the session token of every user whose tool call reaches it. That token authenticates as the user against the whole application, so the receiving party can act as that user for the lifetime of the token, which is a full account takeover of anyone whose request lands on that server. Where the affected user is an administrator, the receiving party gains administrative access.
The receiving party is the operator of a server the administrator deliberately registered, so this is a disclosure of user credentials to a partially trusted third party rather than to an arbitrary attacker. It nonetheless crosses a boundary the administrator set, because selecting bearer authentication for a connection states that the connection is authenticated by its own key alone. The leak does not depend on the tool server behaving maliciously to obtain the token, only on it receiving and retaining ordinary request data.
Fix
Fixed in 0.11.1 by https://github.com/open-webui/open-webui/pull/28630. The tool callable now takes its own connection's cookie jar as a parameter, in the same way it already took that connection's headers, so each request carries only what its own connection was configured to send. Upgrading resolves the issue with no configuration change.
Root cause
Affected component: the tool loading routine in backend/openwebui/utils/tools.py, which builds one callable per tool across all attached connections; the header and cookie builder in the same module, which populates a cookie jar only for session and system OAuth connections; and the tool server request function, which places the supplied jar on the outgoing request.
Headers were passed into the callable factory as an argument and were therefore fixed per connection, while the cookie jar was left as a variable in the enclosing scope and was read only when the tool was finally invoked. By then both loops had completed and the variable held the last value assigned to it. The two values are produced together by the same builder and were plainly intended to travel together, so this is an oversight in how one of them was captured rather than a forwarding decision. The equivalent code path for terminal tool servers in the same module binds both values per connection and does not carry the defect.
Proof of concept
Two tool servers were registered: one using bearer authentication with its own key, and one using session authentication, with the session connection processed last. A tool call was driven to an operation belonging to the bearer server, and that server recorded the incoming request.
The bearer server received its own key in the authorization header, together with the calling user's token and oauthsessionid cookies. The received token matched the calling user's session token.
Control: with the session connection removed and only the bearer server registered, the same tool call arrived with no cookies at all, confirming that the leak depends on a session connection being present and processed last.
Reproduced against a running instance built from the affected snapshot, with the receiving tool servers replaced by recording servers.
Credits
Classic298, who reported the issue and demonstrated the cross-connection cookie leak against a running instance.
Summary
On SQLite deployments, the lookup that maps an external identity to a local account does a substring match instead of an exact match. A subject value containing SQL wildcard characters therefore matches accounts the value was never issued for, and the sign-in binds to whichever account the database returns first, which can be an administrator. The same defect affects SCIM external-ID resolution. PostgreSQL deployments are not affected, because they take a separate and correct code path.
Preconditions
The database is SQLite. This is the default backend. PostgreSQL deployments are not affected at all. OAuth or OIDC sign-in is configured, or SCIM provisioning is enabled. Both are off by default. For an attacker to steer the match deliberately, they must control the value of the claim Open WebUI uses as the subject. That value is normally assigned by the identity provider and is not attacker-controlled: the shipped GitHub and Feishu configurations use provider-assigned numeric identifiers, and a standard OIDC sub is provider-assigned. The deliberate case therefore requires an operator to have pointed OAUTHSUBCLAIM at a claim the end user can set at the identity provider, such as a username or email claim, or an identity provider that lets a user choose their own subject value. No attacker and no misconfiguration are needed for the accidental case. A legitimate subject value that happens to contain an underscore matches other accounts as well, and which account is returned depends on database row order. For the SCIM path, the caller must already hold the SCIM bearer token, which is a privileged credential.
Impact
A sign-in can be bound to an account other than the one the identity provider authenticated. Where the operator has made the subject claim user-settable, an attacker who registers at that provider can choose a value that matches an existing account and receive a session for it, including an administrator account, which is a full compromise of the instance. Where the subject claim is provider-assigned, the deliberate attack is not available, and what remains is a correctness failure in which an ordinary subject value containing an underscore can resolve to the wrong account and hand one user another user's session non-deterministically.
The defect is in the identity match itself, so it is not mitigated by any downstream permission check: by the time a session is issued the wrong account has already been selected. It does not allow account creation, and it does not affect password sign-in, PostgreSQL deployments, or any deployment with OAuth, OIDC and SCIM all disabled.
Fix
Fixed in 0.11.1 by https://github.com/open-webui/open-webui/pull/28624. The two identity lookups now compare the nested JSON value directly through SQLAlchemy's JSON subscript operator, which emits an exact match on both supported databases, instead of going through the column-level contains() operator that degraded to a substring comparison on SQLite. The hand-written per-dialect branching is removed, since the operator already handles both backends.
Upgrading fully resolves it and no configuration change is required. Existing stored identities are unaffected, as the stored format does not change.
Root cause
backend/openwebui/models/users.py — getuserbyoauthsub, resolves an OAuth or OIDC identity to a local account. backend/openwebui/models/users.py — getuserbyscimexternalid, the same pattern for SCIM. Reached from the OAuth callback handler and the OAuth token-exchange handler, and from the SCIM user routes. Affects builds running on SQLite, which is the default database.
The oauth and scim columns are declared with SQLAlchemy's generic JSON type. That type does not implement a containment comparator, so a contains() call against it falls back to the generic string operator and compiles to a LIKE with the operand wrapped in % on both sides. The intent was a JSON containment test; what was emitted was a substring test against the serialized JSON, in which % and carry their usual LIKE meaning. The PostgreSQL branch was written separately against JSONB with an equality comparison and is correct, which is why the defect is confined to the default backend and why it survived review: the two branches look symmetrical and only one of them does what it appears to do.
Proof of concept
Against a SQLite instance with an OIDC provider configured, two accounts exist: an administrator whose stored subject is adminsub9999, and an ordinary user whose stored subject is bobsub1234. Both rows were seeded directly rather than created through a live provider sign-in; the lookup under test was then called as the application calls it.
Resolving the subject value % returns the administrator account. Resolving adminsub% likewise returns the administrator account. Resolving the correct full values returns the correct accounts, and resolving an unknown value returns nothing, so the failure is visible only when the supplied value contains a wildcard character. A subsequent check with an ordinary subject value containing an underscore showed it matching more than one account, with the returned account determined by row order.
Credits
Reported by @Classic298.
Summary Chat histories are stored as an unvalidated JSON object. The walk that reconstructs a chat's message chain detected repeats using each message's own id field while moving through the history by map key, so a message that simply omitted id was never recorded as visited. A history whose messages referenced each other in a parent cycle therefore made the walk run forever. Any account with the default user role could store such a chat and trigger the walk, blocking the server for everyone.
Preconditions One account with the default user role. No administrator rights, no additional permissions, no configuration change and no non-default setting: creating a chat is available to every user out of the box. The attack runs entirely against the attacker's own chat, so no knowledge of any other user's data is needed. Instances where every account is trusted are affected in the sense that the fault is reachable, but require a user acting deliberately.
Impact The walk is synchronous and runs on the asyncio event loop, so while it spins, every request from every user is blocked, including unauthenticated /health and administrator endpoints. External health checks and orchestrator liveness probes fail alongside the UI. The list it appends to grows without bound, so a memory-capped deployment ends in an out-of-memory kill rather than a hang. The work is not cancelled when the client disconnects, so one fire-and-forget request is enough and the attacker can disconnect immediately. The malformed chat stays in the database, so restarting the process does not clear the condition: the next request that walks that chat hangs the new process, and recovery requires deleting the stored chat. No data is disclosed, altered or deleted.
Fix Fixed in 0.11.1 by https://github.com/open-webui/open-webui/commit/5c79ccc9e5c9efc2bc024d8f0b9757652ece929a. The walk now records the map key it is currently positioned at instead of the message's self-reported id, so it terminates after at most one step per stored message whatever the message contents are. Upgrading fully resolves the issue, including for chats stored while the deployment was on an affected version, which become harmless once the walk terminates.
Root cause Affected component: the message-chain reconstruction helper in backend/openwebui/utils/misc.py, reached from every path that rebuilds a chat's history, including chat completion, per-chat statistics, context compaction, subagents and timers. Affected setup: all builds from 0.5.0 up to and including 0.11.0.
The loop's visited set was keyed on the message body's id field while the loop itself advanced by looking the parent up as a key in the history map, so the two used different notions of identity. That id field is part of the stored chat object and is fully attacker-controlled, and the guard explicitly skipped recording it when absent, which left the exit condition unreachable for any message that omitted it. The write path does not validate the structure of a chat's history, so a history containing id-less messages in a parent cycle was persisted exactly as submitted.
Proof of concept As a default-role user on a running instance, store one chat of two messages that reference each other as parents, with the id field omitted:
json {"chat":{"title":"poc","history":{"currentId":"A","messages":{ "A":{"parentId":"B","role":"user","content":"a","childrenIds":[]}, "B":{"parentId":"A","role":"assistant","content":"b","childrenIds":[]}}}}}
POST /api/v1/chats/new stores it verbatim. A single subsequent GET /api/v1/chats/stats/usage as the same user then never returns. Sampled during the hang, the worker consumed one full CPU core and reached 1.86 GB resident within 60 seconds, still growing. Unauthenticated GET /health and administrator API calls both time out for as long as the process lives, and continue to do so after the attacker's connection closes. Restarting the server restores service until the first request that walks the stored chat, which hangs the new process the same way.
On 0.11.1 the same payload returns promptly, /health stays available throughout, and a well-formed chat still resolves its full history.
Credits @YashvantHange, who reported the missing-id cycle in the message-chain walk, demonstrated the resulting server-wide outage end to end against a live instance, and showed that it survives both attacker disconnect and a process restart.
Summary
Open WebUI protects server-side web fetches with two controls: the operator's list of excluded hosts, and a check that refuses private and internal addresses. Neither control was applied to the destination of an HTTP redirect. On a deployment where redirect following is enabled, any authenticated user who can cause the server to fetch a URL could submit a page that redirects, and the server would fetch the redirect destination without either control being applied to it. The server therefore connects to hosts the operator excluded, and to internal addresses including loopback, private networks and cloud metadata endpoints.
Preconditions
AIOHTTPCLIENTALLOWREDIRECTS must be set to true. Its default is false, and on the default the affected fetch paths do not follow redirects at all, so a deployment that has not changed this setting is not affected.
The attacker needs an authenticated account of any role, with access to any feature that causes the server to fetch a URL. Web search, ingesting a URL into a collection, the built-in page fetch tool and image URLs in chat all reach it. No internal hostnames need to be known, because the loopback and cloud metadata addresses are fixed and identical on every deployment.
Neither ENABLELOCALWEBFETCH at its default of false nor any set of entries in WEBFETCHFILTERLIST prevents this.
Impact
An authenticated user can make the server issue requests to hosts the operator deliberately excluded, and to addresses on the internal network including loopback, private ranges and the cloud metadata endpoints of the major providers.
What reaches the attacker depends on which HTTP client the fetch path uses, and is stated here as verified rather than assumed. On the paths built on requests, the fetched body is returned to the caller, so the response of an excluded host is readable: the built-in page fetch tool hands it to the model, and the URL ingestion endpoint returns it in its response. Those paths kept a working private-address check at connection time, so what they reach is excluded public hosts and internal names, not private IP addresses. On the paths built on aiohttp, which are the ones that reach private addresses written as IP literals, fetched content reaches the web search response, a retrievable collection, or model input as base64 through chat image URLs. A path on that client returning an attacker-chosen response verbatim to the requester was not demonstrated.
The operator has no configuration that prevents this. 169.254.169.254 ships in the default excluded list, so an operator reviewing their configuration would reasonably conclude the metadata endpoint is out of reach. The documentation for the redirect setting further recommends the excluded-host list as a compensating control when enabling redirects, and that is precisely the control the redirect path skipped.
Fix
Fixed in 0.11.1 by https://github.com/open-webui/open-webui/pull/27823. The excluded-host list is now evaluated once per request on both HTTP clients, where the real destination is known and a reused connection cannot skip it, so it applies to every redirect hop. The private-address check stays at connection time and is now hooked where aiohttp resolves a host, which is also reached for hosts written as IP literals.
One case is not resolved by the upgrade alone. Where the server reaches the internet through a forward proxy, the private-address check sees the proxy's address rather than the final destination, so it does not constrain where the proxy is asked to connect. The excluded-host list is still applied to the destination on that path. Operators who route outbound fetches through a proxy should enforce destination restrictions on the proxy itself.
Root cause
Affected components:
- the web retrieval fetch paths, on both the aiohttp and requests clients - the built-in page fetch tool - the URL ingestion endpoint
Affected setup: builds that carry the redirect-following setting, which was introduced in 0.9.5. Earlier builds have no such setting.
The two controls sat at different layers, and each sat where only the originally submitted URL passes through. The excluded-host list was consulted inside URL validation, which runs once against what the user submitted, so a redirect destination never reached it on either client. The private-address check was placed inside the aiohttp resolver, and aiohttp answers a host written as an IP address itself without consulting a resolver, so for exactly those hosts the check never ran. Redirect following was added later as an option, and neither control was revisited at that point, so turning it on moved the real destination out of reach of both.
Proof of concept
Both vectors were reproduced against the shipped code of 0.11.0 and confirmed closed against 0.11.1, with AIOHTTPCLIENTALLOWREDIRECTS=true in both runs. A local HTTP server serves a redirect and a body, DNS is stubbed so the test hostnames resolve to loopback, and the guards themselves are unmodified.
Excluded-host list, redirect destination 203.0.113.1 excluded, submitted URL not excluded:
0.11.0 no check runs against the redirect destination, the server opens a connection to it 0.11.1 blocked at the redirect hop before any connection is attempted
Private-address check, host written as an IP literal, with ENABLELOCALWEBFETCH at its default of false:
0.11.0 fetch returned 'LOOPBACK-CONTENT-REACHED', no check ran 0.11.1 blocked, non-global address 127.0.0.1
Instrumenting 0.11.1 on a redirect that is allowed shows both controls running against the redirect destination as well as the submitted URL, and an ordinary public fetch is unaffected.
Credits
- @arpitjain099, original report: the excluded-host list is not re-applied to redirect destinations. - @Classic298: the private-address check is bypassed for redirect destinations written as IP literals, through aiohttp's resolver shortcut.
Rejected reason: Open WebU's investigation showed that this describes the behavior of plain HTTP rather than a defect in the product. TLS termination is the operator's deployment decision, as it is for any backend that speaks HTTP, and not a security issue. https://docs.openwebui.com/security/vendor-dispositions/cve-2026-0767
Rejected reason: Open WebU's investigation further investigation showed that this is intended functionality of the Plugins extension system, in which users granted the relevant permission author Python that the server executes by design, and not a security issue. https://docs.openwebui.com/security/vendor-dispositions/cve-2026-0766
Rejected reason: Open WebU's investigation further investigation showed that this is intended functionality of the Plugins extension system, in which users granted the relevant permission author Python that the server executes by design, and not a security issue. https://docs.openwebui.com/security/vendor-dispositions/cve-2026-0765
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
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 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
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.
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
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
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 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.