Where
-Infinity
0
Severity
6.5
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

Summary Open WebUI's OAuth token exchange endpoint issues a session for a provider access token without running the OAuth role management that the normal OAuth login callback runs. A user whose provider roles the login callback would refuse, or would demote, could still obtain a working session at their existing role through this endpoint.

Preconditions - ENABLEOAUTHTOKENEXCHANGE=True. It is disabled by default, so a default deployment is not affected. - ENABLEOAUTHROLEMANAGEMENT=True together with OAUTHALLOWEDROLES or OAUTHADMINROLES. Role management is off by default, and deployments not using it are not affected. - A valid, unexpired access token on the configured provider. - An Open WebUI account already linked to that provider subject, or an account with a matching email when OAUTHMERGEACCOUNTSBYEMAIL is enabled. This endpoint never creates accounts, so a token for a subject with no existing account is rejected.

Impact An admin who relies on OAuth role management expects a user to lose access, or lose admin, as soon as the identity provider stops reporting the required role. The login callback does enforce this on the next sign-in. Token exchange kept issuing sessions and never re-evaluated the role, so the user retained working access as their existing account at its existing role, including an admin role the provider had already revoked. The endpoint cannot create an account and cannot raise anyone's role, so this grants continued access rather than new or elevated access.

Fix d799e81ed, released in 0.11.1, runs the same role evaluation on the provider's response that the login callback runs. The exchange is denied with 403 when the reported roles match no allowed or admin role, and the account's role is updated to match the provider otherwise. Upgrading restores the check with no further action.

Root cause The affected component is the OAuth token exchange endpoint in backend/openwebui/routers/auths.py, present in builds from 0.8.0 onward.

The endpoint was added as a second entry point into the same session-issuing path the OAuth login callback uses, but it re-implemented only the identity lookup and not the policy checks surrounding it. Role evaluation lived inside the callback's own body rather than in shared code, so the second caller inherited none of it.

Credits @Classic298

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

Summary Open WebUI's OAuth token exchange endpoint issues a session for a provider access token without applying the email domain allowlist that the normal OAuth login callback enforces. An account whose email domain the login callback would refuse could still obtain a working session through this endpoint.

Preconditions - ENABLEOAUTHTOKENEXCHANGE=True. It is disabled by default, so a default deployment is not affected. - OAUTHALLOWEDDOMAINS set to something other than . Deployments without a domain allowlist are not affected. - A valid, unexpired access token on the configured provider. - An Open WebUI account already linked to that provider subject, or an account with a matching email when OAUTHMERGEACCOUNTSBYEMAIL is enabled. This endpoint never creates accounts, so a token for a subject with no existing account is rejected.

Impact An admin who narrows the domain allowlist expects users outside it to lose access at their next sign-in. The login callback does deny them. Token exchange kept issuing sessions, so a user whose domain was removed retained working access as their existing account at its existing role. The endpoint cannot create an account and cannot raise anyone's role, so this grants continued access rather than new or elevated access.

Fix fb5ef978b, released in 0.9.0, adds the same domain allowlist check to the token exchange endpoint that the login callback runs, and denies the exchange with 403 when the email domain is not allowed. Upgrading restores the check with no further action.

Root cause The affected component is the OAuth token exchange endpoint in backend/openwebui/routers/auths.py, present in builds from 0.8.0 onward.

The endpoint was added as a second entry point into the same session-issuing path the OAuth login callback uses, but it re-implemented only the identity lookup and not the policy checks surrounding it. The domain allowlist check lived inside the callback's own body rather than in shared code, so the second caller inherited none of it.

Credits @Classic298

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

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.

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

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.

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

Summary Chat histories are stored as an unvalidated JSON object. After a message is deleted, the code that picks the chat's new current message walked down the childrenIds links without recording where it had already been. Any account with the default user role could store a chat whose messages list each other as children, then delete a message from it, and the walk would run forever. That walk runs on the server's request loop, so it blocks every other user's requests until the process is killed.

Preconditions One account with the default user role. No administrator rights, no additional permissions, no configuration change and no non-default setting: creating and deleting chats 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. Versions before 0.10.0 are unaffected because neither the message-deletion endpoint nor the affected code existed.

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. This is a pure CPU pin with no list growth, so a worker consumes one full core with flat memory and has to be killed rather than being reclaimed by an out-of-memory kill. The work is not cancelled when the client disconnects, so a single fire-and-forget request is enough and the attacker can disconnect immediately. The malformed chat stays in the database, so the condition re-arms on the next deletion attempt against that chat. No data is disclosed, altered or deleted.

Fix Fixed in 0.11.1 by https://github.com/open-webui/open-webui/commit/b933292d63d12be3fd1416fe55519ddc7aa336bc. The walk now records the ids it has already passed through, 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 chat-history message deletion helper in backend/openwebui/models/chats.py, reached from DELETE /api/v1/chats/{id}/messages/{messageid}. Affected setup: all builds from 0.10.0 up to and including 0.11.0.

Resolving the chat's new current message after a deletion means descending to the deepest remaining child, and that descent had no notion of where it had already been, so two messages naming each other as children sent it back and forth indefinitely. The write path does not validate the structure of a chat's history, and the child-link rebuild that runs on other write paths does not apply when a chat is created, so a history whose childrenIds form a cycle was persisted exactly as submitted.

Proof of concept As a default-role user on a running instance, store one chat of roughly 300 bytes whose messages reference each other as children:

json {"chat":{"title":"poc","history":{"currentId":"C","messages":{ "A":{"id":"A","parentId":null,"role":"user","content":"a","childrenIds":["B","C"],"timestamp":1}, "B":{"id":"B","parentId":"A","role":"assistant","content":"b","childrenIds":["A"],"timestamp":2}, "C":{"id":"C","parentId":"A","role":"user","content":"c","childrenIds":[],"timestamp":3}}}}}

POST /api/v1/chats/new stores it verbatim, with the child links unchanged. A single DELETE /api/v1/chats/{id}/messages/C as the same user then never returns:

unauthenticated GET /health -> HTTP 000 after 10.007s DELETE -> HTTP 000 after 20.007s

Sampled during the hang, the worker had consumed 42.9 seconds of CPU on one fully occupied core with flat resident memory, and unauthenticated GET /health timed out for as long as the process lived, including after the attacker's connection closed.

On 0.11.1 the same payload returns HTTP 200 in 0.011s, /health stays available throughout, and deleting a middle message from a well-formed four-message chain still resolves the current message correctly.

Credits @Classic298, who found the unguarded descent through the chat's child links, demonstrated the resulting server-wide outage end to end against a live instance, and supplied the fix.

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

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.

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

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.

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

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.

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

Summary With the Playwright web loader enabled, Open WebUI checks the address behind a user-submitted URL before allowing the request, then handed the request to the browser to perform. The browser resolved the hostname a second time, on its own, and that answer was never checked. An attacker who controls the authoritative DNS for a hostname they submit can answer the first lookup with a public address and the second with an internal one, so the browser connects to an address the check exists to block. The connection-layer pinning that protects the other fetch paths could not apply here, because the request ran inside the browser rather than through our own HTTP clients.

Preconditions - WEBLOADERENGINE=playwright. This is not the default, and deployments on the default web loader are unaffected. - A reachable Playwright browser, either local or via PLAYWRIGHTWSURL. - Any authenticated user who can submit a URL for ingestion or trigger a web search. No administrator role is required. - Control of the authoritative DNS for a hostname the attacker submits, serving a short TTL and alternating answers. No timing race is needed, because the two lookups are separate queries the attacker answers differently. - Internal services the browser process can actually reach. A deployment whose browser has no route to internal addresses loses nothing here.

Impact An authenticated user can make the server read HTTP responses from addresses only it can reach: cloud instance metadata, loopback-bound admin APIs, and internal services on the same network. The response body is fulfilled back into the page, and the loader returns that page as the document, so the content lands in the web-search or ingestion result the user receives. On a cloud host with IMDSv1 reachable, that is enough to take instance IAM credentials.

Because the intercepted request forwards the original method and headers, a page the attacker controls can also drive requests that need a header or a non-GET method, which covers the header-gated metadata endpoints. This is a read primitive; no modification of Open WebUI data and no availability impact was demonstrated. Deployments on the default web loader are not affected at all.

Fix Fixed in 0.11.1 by commit 27402ff21 (#28634). The interceptor no longer asks the browser to perform the request. It issues the request from the SSRF-safe HTTP client instead, which resolves the hostname once and connects to that same validated address, re-validates every redirect hop, and then fulfils the browser with the response it received. Upgrading to 0.11.1 fully resolves this, and no configuration change is required.

Root cause Affected component: SafePlaywrightURLLoader in backend/openwebui/retrieval/web/utils.py, in both the sync and async request interceptors. Affected setup: only builds running the Playwright loader engine.

The address check and the connection were performed by two different resolvers in two different processes. Validation resolved the hostname in Python and inspected the result, then the request was handed to the browser, which resolved the name again and connected to whatever it got. Playwright's request-fetch API offers no way to pin a connection to an already-validated address, so the fix that had been applied to the other fetch paths, resolving once and connecting to that same address, could not reach this one. The check was therefore an opinion about an earlier lookup rather than a constraint on the connection that followed.

Proof of concept Reproduced against the real implementation: the validation and interception code was taken unmodified, with only the module-level configuration constants stubbed at their documented defaults, and driven against a real Chromium instance inside an unprivileged network namespace with an authoritative DNS server answering the first lookup public and the second internal. Instance metadata and a loopback service were both reached, with the response body returned through the page. A second run showed an attacker-controlled page driving the header-gated metadata sequence to completion. The path from the HTTP endpoint to the loader was traced in source rather than driven end to end.

Credits - @baeseungwon1010 — identified that the address check and the browser's own resolution are two separate lookups, so the check cannot constrain where the browser connects.

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

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

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

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

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

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

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

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

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

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

Summary A user granted write access to a shared chat folder could permanently delete chats and messages belonging to the folder's owner. Deleting a folder cascades into the owner's chats and the entire subfolder subtree, and the deletion handler required only write access on subfolders instead of ownership. Root folders were restricted to the owner or an admin, subfolders were not.

Preconditions The Folders Sharing permission (user.permissions.sharing.folders) must be enabled; it is off by default. The victim must have shared a folder with the attacker at write access. features.folders and the chat.delete permission are enabled by default and are both required. Deployments that leave folder sharing disabled are not affected, and neither are single-user instances.

Impact Permanent, irreversible destruction of another user's chat history within and beneath a shared folder. With deletecontents=false the same request instead force-moved the owner's chats out of the folder, an unauthorized relocation rather than a deletion. The write grant on the shared root folder is inherited by every descendant, so the attacker could destroy subfolders that were never explicitly shared with them. Nothing outside the shared folder's subtree is reachable, and no data is disclosed that write access did not already expose.

Fix Fixed in 0.11.0 by https://github.com/open-webui/open-webui/pull/27003. Folder deletion is now restricted to the folder owner or an admin for root folders and subfolders alike, replacing the previous root/subfolder split with a single check. Upgrading fully resolves the issue; no configuration change is required. Owners and admins are unaffected, and a write-collaborator can still create, rename and add to shared folders and delete subfolders they own.

Root cause Affected component: backend/openwebui/routers/folders.py, the DELETE /api/v1/folders/{id} handler. Affected setup: any release from 0.10.0 onward that has folder sharing enabled.

The cascade that follows the authorization check is bound to the folder owner's id, not the caller's, so whoever passes the check deletes the owner's data. The check itself branched on whether the folder had a parent: root folders demanded ownership or admin, while subfolders accepted any write grant. Because write grants propagate down the folder tree, that branch handed every collaborator deletion rights over the owner's subtree, which is broader than what the sharing model grants write access.

Credits @legobattman, who reported the issue and its remediation.

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

Summary The built-in knowledge search tools let a chat participant choose the pattern used to grep knowledge files. Patterns containing regex metacharacters were compiled with Python's backtracking re engine and run against every line of every reachable file, with no time limit anywhere on that path. A single crafted pattern and a single short line of matching text pin one CPU core for as long as the attacker wants, and because the search runs synchronously inside the event loop, that worker serves nobody else while it spins.

Preconditions - Default configuration. The knowledge builtin tool group is enabled by default, and with ENABLEKBEXEC at its default of False the model is handed grepknowledgefiles, which is the affected path. - Any authenticated user, no elevated role and no workspace permission. - One file the attacker can read. USERPERMISSIONSCHATFILEUPLOAD defaults to true and a user always has read access to their own upload, so both halves of the input are attacker-supplied. - A model willing to call the tool with the attacker's literal pattern. This is the one non-deterministic step: it is reliable in practice by instructing the model in your own chat, but it is not guaranteed on a given turn. - Deployments running with UVICORNWORKERS at its default of 1 lose the whole instance; multi-worker deployments lose one worker per request.

Impact Availability, against every other user of the affected worker. Cost scales exponentially with the length of the matching text: measured on the vulnerable code, a 24 character subject takes 1.2s, 28 takes 19s and 30 takes 74s, and a 40 character subject extrapolates to roughly a day of CPU. The same subject against a literal pattern takes under a microsecond. There is no confidentiality or integrity effect, and no data is read or altered.

Fix Fixed in 0.11.0 by https://github.com/open-webui/open-webui/pull/27471. Pattern matching moved from re to the regex engine, which supports a per-search timeout, and every tool call now runs its searches under a single 2 second matching budget, after which the tool returns an error instead of continuing to match. Upgrading is sufficient; there is nothing an operator has to configure.

Root cause - backend/openwebui/tools/knowledgefs.py, buildmatcher: compiled the caller's pattern and returned an unbounded match function. - backend/openwebui/tools/builtin.py, grepknowledgefiles: the default-configuration caller, which ran that matcher over every line of every reachable file.

buildmatcher treated any pattern containing regex metacharacters as a regex, so no explicit flag was needed to reach the compiler. From there the only limits in place were on results, not on work: a cap on matches returned and a cap on files scanned, neither of which bounds the time a single line can consume. Backtracking cost is exponential in the length of the matched text rather than in the pattern, so capping pattern length or line length would not have bounded it either. The engine had no timeout available and none was imposed elsewhere.

Proof of concept Against a real instance as an ordinary user:

1. Upload a text file whose content is a single line of 30 x characters, and no y. 2. In a chat on a model with the knowledge tools available, instruct the model to call grepknowledgefiles with the pattern (x|x)y and that file's id. 3. The request never returns. The worker's CPU sits at 100% for the duration, and concurrent requests from other users on the same worker do not complete.

Growth measured directly against buildmatcher on the vulnerable code:

| subject length | time | | -------------- | ----- | | 16 | 4.7ms | | 20 | 73ms | | 24 | 1.21s | | 28 | 19.3s | | 30 | 73.9s |

Credits @Classic298, for the finding and the fix.

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

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

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

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

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

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

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

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

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

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

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

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

Summary

A workspace tool shared with a read grant returned its full Python source to the recipient. Any authenticated non-admin who could use a shared tool could also read its source, including any user on the instance when a tool was shared publicly. Source is meant to be a writer-only tier: the list response schema deliberately omits it and source export sits behind its own permission. The read endpoints delivered it anyway.

Preconditions

- Authentication enabled (WEBUIAUTH=true, default) and plugins enabled (ENABLEPLUGINS=true, default). - The attacker is an authenticated non-admin without the workspace.tools permission and without a write grant on the tool. - A tool is shared with a read grant to the attacker, to one of their groups, or to all users (user:).

Deployments that share no tools, or share them only with users who already hold write access, are not affected.

Impact

A non-admin obtains another user's server-side tool source. Tool source commonly embeds hard-coded API keys, credentials and internal service URLs, so the practical loss frequently extends past the code itself. The attack needs no special permission beyond an ordinary account that a tool was shared with, and no user interaction. Confidentiality only: it grants no ability to create, modify or execute tools, and no integrity or availability impact.

Fix

Fixed in 0.11.0 by commit c05de13b4 (#27005) together with 310ae9130. The per-id endpoint now drops the source for callers without write access, and the two list endpoints no longer load source at all. Function specs stay visible to read users, since the chat tool listing renders a tool's functions from them. Tool execution loads source server-side, so shared tools keep working. Upgrading to 0.11.0 fully resolves the issue with no configuration change.

Root cause

Affected components: GET /api/v1/tools/, GET /api/v1/tools/list and GET /api/v1/tools/id/{id} in backend/openwebui/routers/tools.py, and the response models in backend/openwebui/models/tools.py. Every build carrying the plugin routes is affected.

ToolResponse deliberately omits the source and the specs, but its subclass ToolUserResponse permits extra fields, and each handler built its response by spreading a full dump of the tool model. The omitted fields were re-admitted as extras and serialised back to the caller, so the schema meant to enforce the writer-only tier enforced nothing at all. The listing path carried a second, independent defect: the flag that was supposed to keep source out of listings never changed the query it guarded.

Proof of concept

Against a default instance on 0.10.2, with an admin account and a second account of role user:

1. As the admin, create a tool whose source contains a marker secret and share it read-only with everyone:

POST /api/v1/tools/create {"id": "poctool", "name": "PoC Tool", "content": "APIKEY = \"TOOLSRCSECRET\"\nclass Tools:\n def hello(self) -> str: return 'hi'", "meta": {"description": "poc"}, "accessgrants": [{"principaltype": "user", "principalid": "", "permission": "read"}]}

2. As the non-admin, call any of the three read endpoints:

GET /api/v1/tools/list -> 200, item "poctool": writeaccess=false, content="APIKEY = \"TOOLSRCSECRET\" ..."

The same source is returned by GET /api/v1/tools/ and GET /api/v1/tools/id/poctool. On 0.11.0 the identical run returns the item with no source for the non-admin, while the owner still receives it.

Credits

- bogdancherniy11-sudo — reported the disclosure across the three tool read endpoints.

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

Summary The terminal WebSocket route authenticates its own first-message JWT instead of going through the HTTP dependency chain, and never applies the role check that getverifieduser enforces on every HTTP terminal route. An account whose role is pending, meaning registered but not approved, or approved and later deactivated back to pending, can therefore open an interactive terminal session that the HTTP terminal endpoints would refuse. The missing control is the verified-user role gate, not the terminal access grants, which are evaluated correctly.

Preconditions At least one terminal server must be configured, which is off by default, and its access grants must cover the account: either public read (principalid: "") or a group the account still belongs to. The attacker needs a valid, unexpired JWT for a pending account and the terminal server id. Both are obtainable by an account that registered while approvals are pending, or by one that held access and was deactivated, since deactivation sets the role to pending without revoking the token, which lasts four weeks by default. Deployments with no terminal server configured, or whose terminal grants are admin-only, are not affected.

Impact A deployment loses the account-approval boundary for terminal access. An unapproved or deactivated account gets interactive shell access, file browsing and terminal-backed tooling in the terminal environment, for as long as its token remains valid. Because the HTTP terminal routes correctly reject the same account, the two planes disagree, so an administrator who deactivates a user sees access revoked over HTTP while the WebSocket keeps working. The terminal access grants themselves are not bypassed: an account with no grant is still refused, so this only widens access to terminals already shared broadly or with a group the account remains in.

Fix Fixed in v0.11.0 by https://github.com/open-webui/open-webui/pull/27537. Token decoding, revocation checking, user lookup and the role check are consolidated into a single getverifieduserbytoken helper, and both the terminal WebSocket route and the Socket.IO handshake now go through it. The role set lives in one constant shared with the HTTP gate so the two cannot drift apart again. Upgrading fully resolves the issue; no configuration change is required.

Root cause Affected component: backend/openwebui/routers/terminals.py, the resolveauthenticatedconnection helper backing the /{serverid}/api/terminals/{sessionid} WebSocket route. Affected setup: every build from v0.8.8 onward, since the route was introduced there.

WebSocket handshakes cannot use FastAPI dependencies, so this route reimplemented authentication inline. The reimplementation reproduced the parts that are visible in the token, that it decodes and that the user row exists, and silently dropped the part that lives in the database row, the role check. Authorization then ran on two independent code paths with no shared definition of what an authenticated user is, and only one of them was updated when the role gate was introduced.

Credits Reported by @rexpository.

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

Summary In every affected release, automation recurrence parsing anchors minutely and hourly rules at a fixed date of 2000-01-01 and then walks forward one interval at a time to find the next run. A single FREQ=MINUTELY rule therefore enumerates roughly a quarter-century of occurrences, synchronously, on the event loop that also serves the scheduler, HTTP and WebSocket traffic. Nothing bounds the walk, and nothing moves it off the loop.

Preconditions Any user who can create an automation. USERPERMISSIONSFEATURESAUTOMATIONS defaults to false, so on a default deployment only an admin can reach the create path; it becomes reachable by ordinary users on any deployment that has granted the automations feature, which is the normal way to make the feature usable. UVICORNWORKERS defaults to 1, so there is no second worker to absorb the stall. The rule needs no unusual syntax: FREQ=MINUTELY with no DTSTART, or with a DTSTART set well in the past, is enough.

Impact Availability, against every other user of the instance. One evaluation of RRULE:FREQ=MINUTELY takes 18.9 s of blocking CPU; adding a ten-value BYSECOND list multiplies the walk and takes 64.2 s. FREQ=HOURLY costs 0.34 s and is not materially exploitable on its own. The cost does not stop at creation: once the automation is stored, the scheduler recomputes the next run for every claimed row on each poll, so the same walk repeats on a default 10 s interval and the instance stays wedged rather than recovering. Instances that have not enabled the automations feature for non-admin users are exposed only to an admin doing this.

Fix Fixed in 0.11.0. Sub-daily rules are now anchored to the current clock instead of the year-2000 date, so the walk starts at the next occurrence rather than a quarter-century behind it. A caller-supplied DTSTART is honoured only when the number of occurrences it implies stays under a fixed bound, and is otherwise replaced by the clock-aligned anchor. The same rules that cost 18.9 s and 64.2 s now cost under a millisecond. Upgrading fully resolves the issue, no configuration change is required.

Root cause Affected component: backend/openwebui/utils/automations.py, parserule, reached from the automation create, update and toggle handlers in backend/openwebui/routers/automations.py and from the scheduler's claim path in backend/openwebui/models/automations.py. Affected setup: every build from 0.9.0 onward, since that is when the automations feature shipped.

The fixed anchor existed to make sub-daily intervals snap to clock boundaries, so that "every 5 minutes" lands on :00, :05, :10 rather than drifting from whenever the automation happened to be created. Snapping only needs a reference point of the right phase, but the implementation used a literal far-past date as that reference and left the recurrence library to walk forward from it. The distance between the anchor and the present is therefore attacker-influenced work that grows with real time, and it was never treated as a cost that needed a bound or a thread.

Proof of concept Measured cost of a single next-run computation against the shipped 0.10.2 parser:

| rule | cost | | --- | --- | | RRULE:FREQ=MINUTELY | 18,880 ms | | RRULE:FREQ=MINUTELY;BYSECOND=0,1,2,3,4,5,6,7,8,9 | 64,236 ms | | DTSTART:20000101T000000 + RRULE:FREQ=MINUTELY | 26,237 ms | | RRULE:FREQ=HOURLY | 339 ms |

Measured at function level deliberately. Persisting such an automation has the scheduler repeat the walk on every poll and wedge the instance indefinitely, which is the actual impact but makes a live end-to-end run destructive to the test instance.

Credits Reported by @Classic298.

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

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.

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

Summary A user with write access to one knowledge base could delete directories, and drop file embeddings, belonging to knowledge bases they do not control. The sync cleanup endpoint verified write access on the knowledge base named in the URL and then acted on the directory and file ids supplied in the request body without checking that those objects belonged to that knowledge base.

Preconditions Default configuration, no flags involved. The attacker needs write access to at least one knowledge base, which comes from owning one, from a write access grant, or from the admin role; workspace.knowledge is off by default, so an ordinary user cannot simply create one. They also need the victim's directory or file id, which are UUIDs and are not enumerable, so in practice the attacker is someone who can already see the target knowledge base, typically a read-only collaborator on a shared one. Deployments where no knowledge base is shared beyond its owner are not reachable.

Impact The attacker deletes a target directory and, because the deletion runs without moving files to the parent, the knowledgefile associations for every file in that subtree are removed as well, so those documents silently drop out of the victim's knowledge base and out of its retrieval results. Separately, the per-file vector cleanup dropped the standalone file-<id> collection for any file id, breaking chat-with-file for that document. The stored files and their database rows survive, since that path was gated on file ownership, and an owner can restore the state by re-adding and reprocessing. Nothing about the target knowledge base's contents is disclosed to the attacker.

Fix Fixed in https://github.com/open-webui/open-webui/pull/26722. Both request-body loops are now scoped to the knowledge base in the URL: a directory is resolved and skipped unless its knowledgeid matches, and the per-file vector cleanup runs only for files that are members of that knowledge base. Upgrading fully resolves the issue.

Root cause Affected component: backend/openwebui/routers/knowledge.py, handler syncknowledgecleanup, endpoint POST /api/v1/knowledge/{id}/sync/cleanup. Affected setup: all builds from 0.9.6 onward, no optional dependency involved.

The handler treated the write-access check on the URL knowledge base as authorization for everything it went on to do, but the objects it acted on came from the request body and were addressed by primary key alone. Directory deletion at the model layer deletes by directory id and has no notion of a parent knowledge base, so the only thing that could have bound the two together was a membership check in the handler, and there was none. The explicit directory-delete endpoint in the same router already carried that check, which is what makes this a gap in one handler rather than a missing model-layer control.

Credits Reported by @whyiug.

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

Summary Open WebUI lets a client define a model inline on a chat request instead of selecting a saved workspace model. The knowledge attached to such an inline model was used as-is, without checking that the caller can read what it points at. Any authenticated user who knows another user's file id could therefore have the builtin knowledge tools return that file's indexed content back to them.

Preconditions Authenticated user of any role, no admin rights needed. The request must carry a session id and use native function calling, which is the default (only functioncalling: legacy opts out), and the model's builtintools capability must not be disabled (default enabled). The attacker must already know the target file's id; ids are UUIDs and are not enumerable through this path. No admin setting needs to be turned on: enabling Direct Connections is not required for the backend to accept an inline model. Knowledge bases attached this way were never affected, their own access grants were enforced on every path.

Impact An authenticated user could read the indexed chunks of another user's or group's file, a cross-user confidentiality loss limited to files whose ids the attacker already holds. It is read-only: nothing is modified or deleted, knowledge-base permissions are unaffected, and saved workspace models were already validated at creation time.

Fix Fixed in 0.11.0 by commit 305880f2e. An inline model's attached knowledge is filtered against the caller's real read access before it is used, dropping any file, knowledge base or note the caller cannot read. Upgrading fully resolves it, no configuration change is required.

Root cause Affected component: the chat completion, chat completed and chat action endpoints, which accept an inline model definition, and the builtin knowledge tools that consume the model's attached knowledge. Affected setups: all builds from 0.8.8 through 0.10.2.

Saved workspace models have their attached file references validated against the author when the model is created, updated or imported, so a stored model can only carry files its creator can read. An inline model never passes through that path, yet everything downstream treated its attached knowledge with the same trust, including a helper that grants read access to a file purely because the running model claims it as attached knowledge. The missing control was an access check at the point where client-supplied model metadata enters the request.

Credits Reported by @whyiug.

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

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

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

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

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

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

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

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

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

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

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.

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

Summary An authenticated user whose features.imagegeneration permission has been revoked can still make the server generate images by sending the feature flag in a chat-completion request. The chat pipeline took the client-supplied features object at face value and never re-checked the permission that the direct image routes enforce, so the denial applied to the UI affordance but not to the server-side generation path.

Preconditions Image generation must be enabled and a provider configured by the administrator (ENABLEIMAGEGENERATION is off by default). The per-user permission defaults to granted, so only deployments where an administrator explicitly revoked it for some users are affected. On 0.10.0 and later the caller must also set params.functioncalling to legacy; on 0.9.x and earlier the legacy mode was the default, so no special parameter was needed. Deployments on native function calling are unaffected, since that path checks the permission before registering the image tools.

Impact A user the administrator has explicitly denied image generation can consume the operator's configured provider through the chat API, spending the operator's API credits and provider quota and writing generated files to the operator's storage. Where an image is present in the conversation and image editing is enabled, the same handler reaches the image-edit provider as well. No provider credentials are exposed, and no other user's data is reachable.

Fix Fixed in 897d69a (#26703). The legacy chat-features block now re-checks features.imagegeneration against the caller's permissions before invoking the image handler, matching the check the direct image routes and the native function-calling path already performed.

Root cause The chat-completions endpoint stored the request's features object into request metadata, and processchatpayload in the chat middleware dispatched to the image handler purely on the truthiness of that client-supplied flag. Permission enforcement lived on the two surfaces that were reached from the UI, the direct /images/generations and /images/edit routes and the native function-calling tool registration, and was simply absent on the legacy chat path. The flag was treated as a statement of user intent, which it is, rather than as an authorization decision, which the handler behind it made it.

Credits @DavidCarliez, for identifying that the chat pipeline honours the client-supplied image-generation feature flag without re-checking the permission.

1 / 2
Source: GitHub
First published (updated )

Contact

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