Skip to content

fix: enforce ownership on Knowledge by-id read routes (#1056, SECURITY) - #1183

Merged
sakibsadmanshajib merged 2 commits into
mainfrom
fix/knowledge-byid-authz
Aug 25, 2026
Merged

sakibsadmanshajib merged 2 commits into
mainfrom
fix/knowledge-byid-authz

Conversation

@sakibsadmanshajib

Copy link
Copy Markdown
Owner

SECURITY fix: Knowledge by-id routes read across tenants (residual half of #947)

Closes #1056.

Do not merge before the other parity-sec fixes; reviewers please prioritize.

Defect

PR #960 gated the knowledge LISTING routes (GET /, GET /search) on BYPASS_ADMIN_ACCESS_CONTROL, which docker-compose.yml sets to false. Three by-id read routes in the chat backend never consulted it and short-circuited on user.role == 'admin':

Route Old behaviour
GET /api/v1/knowledge/{id} admin read any collection
GET /api/v1/knowledge/{id}/files admin list files; include_content=true returned extracted document text
GET /api/v1/knowledge/{id}/export bare get_admin_user, no ownership check at all: full zip of any tenant's collection given only its id

A fourth route, GET /{id}/files/pending, carries the identical guard text and got the same fix in the same edit.

On this shared chat instance every tenant OWNER is an Open WebUI instance admin (issue #748 family), so a collection id, which is not secret (shared links, support tickets, logs), was enough to read another tenant's knowledge.

Mechanism

The chat image builds only the frontend from vendor/open-webui; the backend comes from the pinned upstream image, so an edit to vendored backend Python is inert at runtime unless applied through owui-patches. This PR follows the established pattern (apply_tenant_role_patch.py and siblings): new deploy/docker/owui-patches/apply_knowledge_authz_patch.py rewrites all four gates at image build time with exact-count anchors plus an ast.parse check, failing the build loudly if upstream source shifts.

Enforcement semantics

All four routes now use exactly the listing routes' predicate: access iff owner, or AccessGrants.has_access(..., permission='read'), or admin when BYPASS_ADMIN_ACCESS_CONTROL is set. With the compose value false, instance admins stop reading across tenants; flipping the flag restores upstream behaviour for deployments that want it.

Verification

  1. Patch run against the vendored source: four markers, idempotent re-run exits 0.
  2. Image build green: patch step logged "enforced ownership", the RUN line's independent assertions passed (grep -c '# hive (#1056)' -eq 4, ast.parse).
  3. Built image inspected directly: 4 markers present in /app/backend/open_webui/routers/knowledge.py, export route now Depends(get_verified_user).
  4. New CI self-check scripts/test_owui_knowledge_authz.py (wired into make test-scripts) passes locally, including its negative control that keeps the test able to go red.

…ge (#1056)

The knowledge listing routes honour BYPASS_ADMIN_ACCESS_CONTROL (PR #960),
but GET /{id}, GET /{id}/files, GET /{id}/files/pending and GET /{id}/export
short-circuited on user.role == 'admin' and never consulted it. On this
shared chat instance every tenant OWNER is an Open WebUI instance admin, so
a collection id alone was enough to read another tenant's collection,
including document content via include_content=true and a full zip download
via the export route.

New owui-patches/apply_knowledge_authz_patch.py rewrites all four gates in
the built backend to the listing routes' predicate: owner, AccessGrants read
grant, or admin when BYPASS_ADMIN_ACCESS_CONTROL is set. The export route
drops its bare get_admin_user dependency for get_verified_user. Every edit
asserts its own effect (exact-count anchors plus ast.parse) and fails the
build otherwise; the Dockerfile RUN line independently counts the four
in-image markers and re-parses the file.
…#1056)

scripts/test_owui_knowledge_authz.py runs the real patch against the vendored
knowledge.py in CI (make test-scripts) and asserts the four gates, the export
dependency swap, and that the unpatched source still carries the defect as a
negative control. The compose comment no longer describes knowledge by-id
reads as discovery-only-closed, and the Caddyfile comment now points at the
backend patch instead of a tracked-separately TODO; both stay accurate to
what actually enforces what.
@sakibsadmanshajib sakibsadmanshajib added the security Security-relevant issue label Aug 25, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 5 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bd16ff9-e0ca-46c7-8ccf-6db40b505710

📥 Commits

Reviewing files that changed from the base of the PR and between a9b6985 and e48e0ba.

📒 Files selected for processing (6)
  • Makefile
  • deploy/docker/Caddyfile.owui
  • deploy/docker/Dockerfile.open-webui
  • deploy/docker/docker-compose.yml
  • deploy/docker/owui-patches/apply_knowledge_authz_patch.py
  • scripts/test_owui_knowledge_authz.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sakibsadmanshajib
sakibsadmanshajib merged commit 952cc37 into main Aug 25, 2026
35 of 46 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the fix/knowledge-byid-authz branch August 25, 2026 21:13
sakibsadmanshajib added a commit that referenced this pull request Aug 25, 2026
…1186) (#1190)

Closes #1186.

## Defect class

The vendored Open WebUI backend short-circuits on bare `user.role ==
'admin'` without consulting the tenant visibility flags this fork
already sets (`BYPASS_ADMIN_ACCESS_CONTROL`, false in compose;
chats.py's own `ENABLE_ADMIN_CHAT_ACCESS`, also false). On a shared chat
instance every tenant OWNER is an instance admin (issue #748 family), so
role alone granted cross tenant read and write access. PR #960 gated the
knowledge listings and PR #1183 fixed the knowledge by-id reads; this PR
applies the same predicate everywhere else.

## HIGH slice (commit 1)

`retrieval/utils.py filter_accessible_collections` returned every
requested collection for any admin-role caller, so `POST
/api/v1/retrieval/query/collection` with a known KB id returned another
tenant's RAG document chunks on the documented RAG path (worse than
#1056: content exposure through the shared choke point that also feeds
/query/doc and web search collection allowlisting).

New `deploy/docker/owui-patches/apply_retrieval_authz_patch.py` rewrites
it to the #960 predicate: owner, AccessGrants read grant, or admin when
BYPASS_ADMIN_ACCESS_CONTROL is set.

## Family sweep (commit 2): 74 sites patched, 10 routers

| File | Sites | Verdict |
|---|---|---|
| knowledge.py | 12 | 9 write gates (/{id}/update, /access/update,
/file/add, /file/update, /file/remove, DELETE /{id}/delete, /reset,
/files/batch/add, `_verify_knowledge_write_access` covering dirs/create,
dirs/update, dirs/delete, file/move) + 3 adjacent same-class gates found
in sweep: per-file read check in file/add (:1409), delete_file object
deletion bypass in file/remove (:1614), batch per-file read-check skip
(:2024) |
| files.py | 10 | 9 content/read/download/update one-line gates + the
upload path knowledge-write gate (:189) |
| evaluations.py | 4 | GET/update/delete feedback by id: admin branch
fetched or mutated any user's feedback |
| folders.py | 7 | GET by id read gate, update write gate, access-grants
rewrite gate, is_admin for folder chat listing (cross-tenant chat
reads), subfolder delete gate, root-shared-folder delete gate, import
added |
| calendar.py | 2 | single choke point `_check_calendar_access` covers
every CRUD route; :405/:446 owner checks become unreachable cross-tenant
once it is gated |
| chats.py | 7 | delete by id admin branch, message edit/delete/event
gates x3, export-stats gate, plus shared-chat access-grant read/update
branches x2, gated on ENABLE_ADMIN_CHAT_ACCESS (its own upstream flag,
matching siblings at :1056/:1174/:1592); gated admins fall through to
ownership-scoped else branches, verified ownership-enforcing |
| prompts.py | 11 | 6 write tails + GET-by-id head + 4 if-not heads |
| notes.py | 6 | 5 deny-shaped gates + the write_access response
indicator so the UI stops offering cross-tenant edit affordances |
| tools.py | 10 | GET-by-id head + 9 write tails |
| models.py | 5 | `_verify_knowledge_file_access` skip, toggle gate,
update/delete tails x2, import overwrite compound gate |

False positives verified and NOT patched: knowledge.py reindex (:331)
stays admin-global by design; feature-permission gates of the shape
`role != admin AND not has_permission(...)` (notes/tools/folders/chats
community-sharing/calendar automations) guard features, not other
tenants' resources; files.py :828 requires the file OWNER to be an admin
rather than bypassing anything.

Mechanism: backend Python edits are inert in the chat image unless
applied through owui-patches, so nothing here touches vendored source
directly; both scripts follow #1183 exactly: fail-loud exact-count
anchors, ast.parse gate, idempotent re-run exits 0. The consolidated
Dockerfile drift guard asserts the exact marker count in every patched
file (#1056 markers included) so a vendor bump cannot silently un-fix
any hole (same silent-drift class as #1162). The compose comment
claiming Knowledge was fully closed is softened to match reality.

Verification:
1. Both patches run twice against copies of the vendored tree, second
run reports already applied.
2. `python3 scripts/test_owui_knowledge_authz.py`: 42/42 PASS including
per-file negative controls that keep the test able to go red on vendor
drift.
3. Image build green from the branch tree (`hive-open-webui:secfamily`,
log /tmp/secfamily-build.log): patch RUN lines report "flag-gated 74
unflagged admin bypasses across 10 routers", the consolidated drift
guard passed, direct inspection of the built image shows every expected
marker count (retrieval 2; knowledge 12 plus four #1056 markers; files
10; evaluations 4; folders 7; calendar 2; chats 7; prompts 11; notes 6;
tools 10; models 5) and ast.parse passes over retrieval/utils.py and
every router.
sakibsadmanshajib added a commit that referenced this pull request Sep 2, 2026
#1358) (#1707)

Closes #1358.

## The false claim, and where it broke

`ProjectDetail.svelte:308` tells the user "No files yet. Files here are
given to every conversation in this project." Nothing delivered them.

The trace, end to end:

| Step | State on `main` |
|---|---|
| File uploaded into a project | Lands in the Open WebUI knowledge
collection that IS the project, embedded into its own vector collection.
Works. |
| Conversation created in the project | `createBoundChat`
(`vendor/open-webui/src/lib/hive/projects/projects.ts:327`) writes `{
chat: { hiveProject: projectId } }` and nothing else. |
| Conversation assembles a request | `sendMessageSocket`
(`vendor/open-webui/src/lib/components/chat/Chat.svelte:2799`) builds
`files` from `chatFiles` plus this turn's own attachments. It has never
read `hiveProject`; before this change the key appeared in no file
outside `src/lib/hive/projects/`. |
| Model sees the documents | No. `files` reaches the request without a
single project document in it. |

So the break point is `Chat.svelte:2828`, the `files` assembly inside
`sendMessageSocket`. The value the marker was supposed to carry had no
reader.

## Why the obvious fix does not work

The issue proposes wiring the project onto `chat.files` at
`createBoundChat` time. That fails, and the reason is the interesting
half of this defect. Immediately above the assembly, `sendMessageSocket`
prunes:

```js
const chatMessageFiles = _messages.filter((message) => message.files).flatMap((message) => message.files);

// Filter chatFiles to only include files that are in the chatMessageFiles
chatFiles = chatFiles.filter((item) => {
	const fileExists = chatMessageFiles.some((messageFile) => messageFile.id === item.id);
	return fileExists;
});
```

An attachment written onto the chat blob at creation is referenced by no
message, so it is deleted on the first send, and then `saveChatHandler`
persists `files: chatFiles` and deletes it from the stored chat as well.
The wiring would exist and the value would still never arrive, which is
the shape this repository keeps hitting.

That is also why a manual attach works today: the plus menu puts the
collection on the user message, so a message references it and the prune
keeps it.

## The fix

Resolve the binding at request assembly, after the prune, in the one
function every entry point reaches.

* `Chat.svelte` reads `hiveProject` off the chat blob at load
(`hiveProjectId`), clears it when a new chat starts, and calls
`withProjectFiles(files, hiveProjectId)` at the end of the `files`
assembly in `sendMessageSocket`. `submitPrompt`, regeneration and
continue all route through that one function, so no sibling entry point
is left broken.
* `withProjectFiles` in `projects.ts` appends `{ type: 'collection', id:
projectId }` when the chat is bound and the project is not already on
the turn, and returns the identical array otherwise.

A reference, not a snapshot. The collection is resolved to its current
file set on every request, so a file uploaded into the project tomorrow
reaches a conversation created today, which is what the sentence on the
project page actually claims. A list of file ids captured at bind time
would not do that.

## The other half: the conversation the project page creates

The project page's **New chat** used to create the conversation itself.
That was wrong twice over, and a review caught the second one.

It created a blob carrying the marker alone, so `loadChat` reached
`convertMessagesToHistory(undefined)` and threw before the page
rendered. And a conversation that already exists makes its first request
carry a `chat_id`, so the backend's `is_new_chat` is false
(`main.py:1096`) and the title and tag tasks are dropped for it
permanently: every conversation in a project would have read "New Chat"
forever, and the project's list would have been a column of identical
rows.

So the page now creates nothing. It navigates to `/?project=<id>`,
`initNewChat` picks the binding off the query string the way it already
picks up `models` and `youtube`, and the ordinary composer path supplies
the blob, the models, the title and the tags. Two helper functions this
branch had added, `createBoundChat` and `seedChatModels`, are deleted
rather than kept in step with the code they were re-implementing.

One measured correction to that shape, because the obvious place to
write the binding does not work here. `initChatHandler`'s
`createNewChat` never runs on the send path on this fork: a send
produces no `POST /api/v1/chats/new` at all, and the stored blob carries
no `params`, which that payload would have supplied. The chat row is
created server side from the completion request. That is why the branch
handling the returned id already persists chat level params "that the
backend doesn't receive in the chat completion request", and the binding
is written one line below it, for the same reason.

The tradeoff, stated rather than buried: the conversation appears in the
project once it has a first message rather than immediately. An
abandoned pre created chat was a permanent empty row, so this is the
better behaviour.

## Retrieval scope: unchanged, stated explicitly

This touches document retrieval, so the scope question is answered
directly rather than by omission.

`{ type: 'collection', id }` is byte for byte the item
`MessageInput/InputMenu/Knowledge.svelte:179` already produces when a
user attaches the same project by hand. It lands in the existing
`item.get('type') == 'collection'` branch of `get_sources_from_items`
(`backend/open_webui/retrieval/utils.py:1468`), which resolves the
collection only after checking `user.role == 'admin' or
knowledge_base.user_id == user.id or AccessGrants.has_access(...)`, and
whose collection names then pass through
`filter_accessible_collections`, the choke point PR #1183 and the #1186
patch hardened.

So: no new retrieval path, no new permission, no widening. A user who
can open a project bound conversation is a user who could already attach
that project from the composer and get the same passages. In particular
this does not touch `/v1/rag/*`, so it neither helps nor worsens #1643,
the tenant readable RAG store; that issue keeps its full scope.

## Known gap, named rather than left quiet

Cowork turns never reach the injection. `submitHandler` returns after
`submitCoworkRun`, so a run started inside a project receives none of
its documents. That is issue #1312 and task 8 of the Projects
unification spec, both of which already own it, and closing it here
would mean a `project_id` on `POST /v1/agent/tasks` with an ownership
check in Go. It is recorded in the code comment at the injection site so
it cannot go quiet, along with the one cost this adds: a non empty
`files` array makes the backend run `generate_queries`, so a project
bound turn spends one extra task model completion even while the project
is empty. Retrieval over an empty collection returns no sources and the
injection is guarded, so no answer is degraded.

## Visual proof

Posted on this pull request, four frames from a chat image built from
this branch on a fresh instance, plus the log in
`docs/proof/projects-attach-delivers-1358-2026-09-02/`.

Re-captured after review with title generation left at its default, on.
The first capture had disabled it, which made it structurally incapable
of showing the defect above: a proof configured with the relevant
feature off proves that feature's absence, not its correctness.

The load bearing line is not the screenshot, it is the outgoing request
body read off the wire in each case:

```
control, before the project existed:   files=null
in the project, same model:            files=[{"type":"collection","id":"<the project>"}]
```

The document's only distinguishing content is a string that exists
nowhere else. In the project the conversation renders "Retrieved 1
source", answers with it, and cites `rack-register.txt`. In the control
the model searched, found nothing and said so.

And the two things the review said this had to show, both from the same
run:

```
generated conversation title: What is the rack asset tag of ...
title is still the placeholder: false
stored chat: hiveProject=f6aab089-94d1-4134-89d8-6533089c3e42
project page conversation rows: ["rack-register.txt Remove","What is the rack asset tag of the Hive demo box? Unlink"]
```

What the capture still cannot show is the denial path, because it runs
with `WEBUI_AUTH=false` and therefore has one identity. That claim rests
on the independent backend review, not on these frames.

## Tests

`vendor/open-webui/src/lib/hive/projects/projects.test.ts`, seven new
cases, written before the fix and observed red for the right reasons
(`withProjectFiles` absent, and `Chat.svelte` containing no read of the
marker and no call at the assembly site):

* the bound chat case starts from the state the prune actually leaves,
an empty `files` array, which is the case a naive blob attachment fails;
* the turn's own attachments survive alongside the project;
* an unbound chat gets the identical array back;
* attaching the project by hand does not duplicate it;
* the attached item carries `type` and `id` only, pinning reference
semantics rather than a snapshot;
* guards on `Chat.svelte`'s source for the URL pickup, the write of the
binding once the backend returns the new id, the load off an existing
blob, and both reset sites;
* an ordering guard, added in review, so an edit that moves the
attachment above the `chatFiles` prune fails rather than silently
restoring the exact bug this description spends half its length on;
* two guards on `ProjectDetail.svelte`, so the second chat creation path
cannot come back.

`make test-owui-frontend`: 380 passed, 25 files. The same sources run
again in place during the chat image build (`npm run test:frontend`), so
this is a build time gate, not only a CI one.

## Relationship to #1595

The Projects unification spec (`spec-2026-08-31-projects-unification`,
Obsidian vault) moves Projects onto Hive's own `/v1/rag/*` store, and
its task 6 fixes #1358 as a side effect of the new attachment control.
Tasks 4, 5 and 7 of that spec, the proxy routes, the repointed
`projects.ts` and the `hive_project` retrieval branch, are not built:
nothing on `main` reads `rag_projects` from the chat surface, and no
pull request is open against them.

This change is deliberately scoped to the store Projects is bound to
today. When task 5 repoints `projects.ts`, `withProjectFiles` is the one
line that changes, from `type: 'collection'` to `type: 'hive_project'`,
and the call site in `Chat.svelte` and every test above stay as they
are. It does not block, duplicate or pre-empt that work; it stops the
interface lying in the meantime.

Spec acceptance criteria 1, 5 and 6 remain #1595's, and remain unmet:
they are about the new store, the `ENABLE_RAG` default and cross member
project isolation on `/v1/rag/*`, none of which this touches.

## Buglog entry

```json
{"date":"2026-09-02","issue":1358,"title":"Projects promised files to every conversation and delivered none","error_message":"A conversation created inside a Project answered as if the project's uploaded documents did not exist, while ProjectDetail.svelte stated 'Files here are given to every conversation in this project'.","root_cause":"createBoundChat wrote only the hiveProject marker onto the chat blob and no code outside src/lib/hive/projects read it, so Chat.svelte's sendMessageSocket assembled the request files with no project document in them. The apparent fix, writing the attachment onto chat.files at bind time, also fails: sendMessageSocket prunes chatFiles to only those files some message in the branch references, so a chat level attachment is dropped on the first send and then written out of the persisted chat by the next save.","fix":"Resolve the binding at request assembly instead of persisting it. Chat.svelte reads hiveProject off the chat blob at load, clears it on a new chat, and calls withProjectFiles(files, hiveProjectId) after the prune and dedupe in sendMessageSocket, the one function submit, regeneration and continue all reach. withProjectFiles appends { type: 'collection', id } , the same item the plus menu produces, so retrieval scope and access control are unchanged.","tags":["owui-fork","projects","rag","claimed-state-no-reader","chat-svelte","frontend"]}
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security Security-relevant issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Knowledge by-id and files routes still admin-short-circuit across tenants (#947 residual)

1 participant