From 9a3eea64489df6dd2f48d4734a700de08dee0f13 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:07:52 +0200 Subject: [PATCH] fix: bind prompt history/version ops to the authorized prompt (#25056) The history diff, delete, and version-restore routes authorize the URL prompt_id but then act on a caller-supplied history/version id without checking it belongs to that prompt (IDOR). Filter by prompt_id in compute_diff and delete_history_entry, and reject a cross-prompt version_id in update_prompt_version. Co-authored-by: Claude Opus 4.7 (1M context) --- backend/open_webui/models/prompt_history.py | 14 +++++++++++--- backend/open_webui/models/prompts.py | 3 ++- backend/open_webui/routers/prompts.py | 4 ++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py index a73032c4bb..bb27657032 100644 --- a/backend/open_webui/models/prompt_history.py +++ b/backend/open_webui/models/prompt_history.py @@ -151,13 +151,19 @@ class PromptHistoryTable: self, from_id: str, to_id: str, + prompt_id: str, db: Optional[AsyncSession] = None, ) -> Optional[dict]: """Compute diff between two history entries.""" async with get_async_db_context(db) as db: - result_from = await db.execute(select(PromptHistory).filter(PromptHistory.id == from_id)) + # Bind both entries to the authorized prompt; an unbound id reads another prompt's snapshot. + result_from = await db.execute( + select(PromptHistory).filter(PromptHistory.id == from_id, PromptHistory.prompt_id == prompt_id) + ) from_entry = result_from.scalars().first() - result_to = await db.execute(select(PromptHistory).filter(PromptHistory.id == to_id)) + result_to = await db.execute( + select(PromptHistory).filter(PromptHistory.id == to_id, PromptHistory.prompt_id == prompt_id) + ) to_entry = result_to.scalars().first() if not from_entry or not to_entry: @@ -203,11 +209,13 @@ class PromptHistoryTable: async def delete_history_entry( self, history_id: str, + prompt_id: str, db: Optional[AsyncSession] = None, ) -> bool: """Delete a history entry and reparent its children to grandparent.""" async with get_async_db_context(db) as db: - result = await db.execute(select(PromptHistory).filter_by(id=history_id)) + # Bind to the authorized prompt; an unbound id deletes another prompt's history. + result = await db.execute(select(PromptHistory).filter_by(id=history_id, prompt_id=prompt_id)) entry = result.scalars().first() if not entry: return False diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 54deeda3d4..6155c5f95b 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -580,7 +580,8 @@ class PromptsTable: history_entry = await PromptHistories.get_history_entry_by_id(version_id, db=session) - if not history_entry: + # Reject a version_id from another prompt; restoring it would copy a foreign snapshot in. + if not history_entry or history_entry.prompt_id != prompt_id: return None # Restore prompt content from the snapshot diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 6d428d250e..94f18e499f 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -653,7 +653,7 @@ async def delete_prompt_history_entry( detail='Cannot delete the active production version', ) - success = await PromptHistories.delete_history_entry(history_id, db=db) + success = await PromptHistories.delete_history_entry(history_id, prompt.id, db=db) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -697,7 +697,7 @@ async def get_prompt_diff( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - diff = await PromptHistories.compute_diff(from_id, to_id, db=db) + diff = await PromptHistories.compute_diff(from_id, to_id, prompt.id, db=db) if not diff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND,