Skip to content

Commit 0aca87c

Browse files
committed
fix: prevent details leak from edited assistant content
1 parent 359590c commit 0aca87c

2 files changed

Lines changed: 39 additions & 9 deletions

File tree

backend/open_webui/models/chats.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,44 @@ def _sanitize_chat_row(self, chat_item):
299299

300300
return changed
301301

302+
def _normalize_assistant_content_from_output(self, chat: dict, prior_chat: dict) -> dict:
303+
"""Rewrite assistant 'content' from 'output' when 'output' has changed.
304+
305+
Skips messages where the caller also changed 'content', or where the prior
306+
'content' differs from 'serialize_output(prior_output)'. Preserves filter
307+
outlet rewrites and direct user content edits across saves.
308+
"""
309+
from open_webui.utils.middleware import serialize_output
310+
311+
history = (chat or {}).get('history') or {}
312+
messages = history.get('messages')
313+
if not isinstance(messages, dict):
314+
return chat
315+
316+
prior_messages = ((prior_chat or {}).get('history') or {}).get('messages') or {}
317+
318+
for message_id, message in messages.items():
319+
if not (isinstance(message, dict) and message.get('role') == 'assistant'):
320+
continue
321+
new_output = message.get('output')
322+
if not isinstance(new_output, list):
323+
continue
324+
prior = prior_messages.get(message_id) or {}
325+
old_output = prior.get('output')
326+
if new_output == old_output:
327+
continue
328+
prior_content = prior.get('content')
329+
if message.get('content') != prior_content:
330+
continue
331+
try:
332+
canonical = serialize_output(old_output) if isinstance(old_output, list) else None
333+
except Exception:
334+
canonical = None
335+
if prior_content != canonical:
336+
continue
337+
message['content'] = serialize_output(new_output)
338+
return chat
339+
302340
async def insert_new_chat(
303341
self, id: str, user_id: str, form_data: ChatForm, db: AsyncSession | None = None
304342
) -> ChatModel | None:
@@ -394,6 +432,7 @@ async def update_chat_by_id(self, id: str, chat: dict, db: AsyncSession | None =
394432
try:
395433
async with get_async_db_context(db) as db:
396434
chat_item = await db.get(Chat, id)
435+
chat = self._normalize_assistant_content_from_output(chat, chat_item.chat)
397436
chat_item.chat = self._clean_null_bytes(chat)
398437
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
399438

backend/open_webui/routers/chats.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
from open_webui.socket.main import get_event_emitter
3333
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
3434
from open_webui.utils.auth import get_admin_user, get_verified_user
35-
from open_webui.utils.middleware import serialize_output
3635
from open_webui.utils.misc import get_message_list
3736
from pydantic import BaseModel
3837
from sqlalchemy.ext.asyncio import AsyncSession
@@ -978,14 +977,6 @@ async def update_chat_by_id(
978977
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
979978
if chat:
980979
updated_chat = {**chat.chat, **form_data.chat}
981-
982-
# Re-derive content from output for assistant messages so that
983-
# frontend edits to output items are always reflected in content.
984-
# serialize_output() is the single source of truth for this conversion.
985-
for msg in updated_chat.get('history', {}).get('messages', {}).values():
986-
if msg.get('role') == 'assistant' and msg.get('output'):
987-
msg['content'] = serialize_output(msg['output'])
988-
989980
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
990981
return ChatResponse(**chat.model_dump())
991982
else:

0 commit comments

Comments
 (0)