mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🧩 fix: Normalize MCP UI Resource Rendering (#14868)
* fix: normalize MCP UI resource rendering * fix: filter unsupported MCP UI resources * fix: preserve MCP UI marker examples * fix: handle MCP UI resource edge cases * fix: harden MCP UI marker sanitization * fix: scope MCP UI marker sanitization * fix: parse MCP UI marker contexts * fix: align MCP UI sanitizer parsing * fix: match MCP UI renderer syntax * fix: align blockquote marker spans * fix: decode MCP UI text node sources * fix: sanitize nested subagent markers * fix: bound MCP UI sanitizer traversal * fix: keep MCP UI marker mapping linear * style: sort security patch imports * fix: harden nested MCP UI sanitization * fix: mirror citation cleanup for MCP UI markers * fix: clean decoded citation markers * fix: clean assembled citation markers * fix: align MCP marker sanitization with rendering * fix: match persisted MCP marker render paths * fix: preserve highlighted citation boundaries * fix: align MCP markers across content renderers * fix: preserve citation renderer boundaries * fix: match legacy thinking trim semantics
This commit is contained in:
parent
eb3b353712
commit
1d789c41a5
20 changed files with 1758 additions and 272 deletions
|
|
@ -1,11 +1,78 @@
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
const { logger, getTenantId } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider');
|
||||
const mongoose = require('mongoose');
|
||||
const {
|
||||
logger,
|
||||
getTenantId,
|
||||
sanitizeUIResourceContent,
|
||||
stripMessageUIResourceMarkers,
|
||||
} = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, Constants, Tools, openAISettings } = require('librechat-data-provider');
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
const { createImportBatchBuilder } = require('./importBatchBuilder');
|
||||
const { resolveImportDefaultModel } = require('./defaults');
|
||||
const { cloneMessagesWithTimestamps } = require('./fork');
|
||||
|
||||
const castImportedBoolean = mongoose.Schema.Types.Boolean.cast();
|
||||
const castImportedString = mongoose.Schema.Types.String.cast();
|
||||
|
||||
function isImportedAssistantMessage(isCreatedByUser) {
|
||||
if (isCreatedByUser === null) {
|
||||
return false;
|
||||
}
|
||||
if (isCreatedByUser === undefined) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return castImportedBoolean(isCreatedByUser) !== true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isImportedAssistantContent(isCreatedByUser) {
|
||||
try {
|
||||
return castImportedBoolean(isCreatedByUser) !== true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function castPersistedImportedText(text) {
|
||||
try {
|
||||
return castImportedString(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImportedArray(value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/** Removes executable legacy MCP-UI payloads from untrusted conversation imports. */
|
||||
function sanitizeImportedMessage(message) {
|
||||
const sanitizeTextMarkers = isImportedAssistantMessage(message.isCreatedByUser);
|
||||
const sanitizeContentMarkers = isImportedAssistantContent(message.isCreatedByUser);
|
||||
const text = castPersistedImportedText(message.text);
|
||||
const content = normalizeImportedArray(message.content);
|
||||
const attachments = normalizeImportedArray(message.attachments);
|
||||
return {
|
||||
...message,
|
||||
...(text !== message.text && { text }),
|
||||
...(sanitizeTextMarkers &&
|
||||
typeof text === 'string' && { text: stripMessageUIResourceMarkers(text, false) }),
|
||||
...(content && {
|
||||
content: sanitizeUIResourceContent(content, sanitizeContentMarkers),
|
||||
}),
|
||||
...(attachments && {
|
||||
attachments: attachments.filter((attachment) => attachment?.type !== Tools.ui_resources),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate importer function based on the provided JSON data.
|
||||
*
|
||||
|
|
@ -283,10 +350,13 @@ async function importLibreChatConvo(
|
|||
return flatMessages;
|
||||
};
|
||||
|
||||
const flatMessages = flattenMessages(messagesToImport);
|
||||
const flatMessages = flattenMessages(messagesToImport).map(sanitizeImportedMessage);
|
||||
cloneMessagesWithTimestamps(flatMessages, importBatchBuilder);
|
||||
} else if (messagesToImport) {
|
||||
cloneMessagesWithTimestamps(messagesToImport, importBatchBuilder);
|
||||
cloneMessagesWithTimestamps(
|
||||
messagesToImport.map(sanitizeImportedMessage),
|
||||
importBatchBuilder,
|
||||
);
|
||||
for (const message of messagesToImport) {
|
||||
if (!firstMessageDate && message.createdAt) {
|
||||
firstMessageDate = new Date(message.createdAt);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ const path = require('path');
|
|||
const {
|
||||
EModelEndpoint,
|
||||
Constants,
|
||||
ContentTypes,
|
||||
Tools,
|
||||
RetentionMode,
|
||||
openAISettings,
|
||||
anthropicSettings,
|
||||
|
|
@ -877,6 +879,158 @@ describe('importLibreChatConvo', () => {
|
|||
expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['linear', false, false],
|
||||
['recursive', true, false],
|
||||
['numeric-author', false, 0],
|
||||
['omitted-author', false, undefined],
|
||||
])('strips MCP-UI attachments from %s imports', async (_format, recursive, authorFlag) => {
|
||||
const message = {
|
||||
messageId: 'message-1',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: { _id: '\\ui{malicious}' },
|
||||
isCreatedByUser: authorFlag,
|
||||
content: [
|
||||
{ type: ContentTypes.TEXT, text: 'Before \\ui{malicious} after' },
|
||||
{ type: ContentTypes.TEXT, text: '`\\ui{literal}`' },
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text: {
|
||||
value: 'Object \\ui{malicious} value',
|
||||
annotations: [{ type: 'citation' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
subagent_content: [{ type: ContentTypes.TEXT, text: 'Nested \\ui{malicious} content' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
[Tools.ui_resources]: [
|
||||
{
|
||||
resourceId: 'malicious',
|
||||
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
|
||||
text: "root.innerHTML='<img src=x onerror=alert(window.origin)>'",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: Tools.web_search, [Tools.web_search]: { results: [] } },
|
||||
],
|
||||
};
|
||||
const jsonData = {
|
||||
conversationId: 'malicious-import',
|
||||
title: 'Malicious import',
|
||||
recursive,
|
||||
...(recursive ? { messagesTree: [message] } : { messages: [message] }),
|
||||
};
|
||||
const importBatchBuilder = new ImportBatchBuilder('user-123');
|
||||
|
||||
const importer = getImporter(jsonData);
|
||||
await importer(jsonData, 'user-123', () => importBatchBuilder);
|
||||
|
||||
expect(importBatchBuilder.messages[0].attachments).toEqual([
|
||||
{ type: Tools.web_search, [Tools.web_search]: { results: [] } },
|
||||
]);
|
||||
expect(importBatchBuilder.messages[0].text).toBe('');
|
||||
expect(importBatchBuilder.messages[0].content).toEqual([
|
||||
{ type: ContentTypes.TEXT, text: 'Before after' },
|
||||
{ type: ContentTypes.TEXT, text: '`\\ui{literal}`' },
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text: { value: 'Object value', annotations: [{ type: 'citation' }] },
|
||||
},
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
subagent_content: [{ type: ContentTypes.TEXT, text: 'Nested content' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('sanitizes singleton content and attachment fields before Mongoose array casting', async () => {
|
||||
const message = {
|
||||
messageId: 'message-1',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: '\\ui{malicious}',
|
||||
isCreatedByUser: false,
|
||||
error: true,
|
||||
content: { type: ContentTypes.TEXT, text: 'Before \\ui{malicious} after' },
|
||||
attachments: { type: Tools.ui_resources, [Tools.ui_resources]: [] },
|
||||
};
|
||||
const jsonData = {
|
||||
conversationId: 'singleton-import',
|
||||
title: 'Singleton fields',
|
||||
recursive: false,
|
||||
messages: [message],
|
||||
};
|
||||
const importBatchBuilder = new ImportBatchBuilder('user-123');
|
||||
|
||||
const importer = getImporter(jsonData);
|
||||
await importer(jsonData, 'user-123', () => importBatchBuilder);
|
||||
|
||||
expect(importBatchBuilder.messages[0].content).toEqual([
|
||||
{ type: ContentTypes.TEXT, text: 'Before after' },
|
||||
]);
|
||||
expect(importBatchBuilder.messages[0].attachments).toEqual([]);
|
||||
expect(importBatchBuilder.messages[0].text).toBe('');
|
||||
});
|
||||
|
||||
it.each([true, null])(
|
||||
'matches text and content renderers for author flag %s while stripping attachments',
|
||||
async (authorFlag) => {
|
||||
const message = {
|
||||
messageId: 'message-1',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Example: \\ui{literal}',
|
||||
isCreatedByUser: authorFlag,
|
||||
content: [
|
||||
{ type: ContentTypes.TEXT, text: 'Part: \\ui{literal}' },
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
name: Constants.SUBAGENT,
|
||||
output: 'Legacy \\ui{nested} output',
|
||||
subagent_content: [{ type: ContentTypes.TEXT, text: 'Nested \\ui{nested} text' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
attachments: [{ type: Tools.ui_resources, [Tools.ui_resources]: [] }],
|
||||
};
|
||||
const jsonData = {
|
||||
conversationId: 'user-marker-import',
|
||||
title: 'User marker import',
|
||||
recursive: false,
|
||||
messages: [message],
|
||||
};
|
||||
const importBatchBuilder = new ImportBatchBuilder('user-123');
|
||||
|
||||
const importer = getImporter(jsonData);
|
||||
await importer(jsonData, 'user-123', () => importBatchBuilder);
|
||||
|
||||
expect(importBatchBuilder.messages[0].text).toBe('Example: \\ui{literal}');
|
||||
expect(importBatchBuilder.messages[0].content).toEqual([
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text: authorFlag === true ? 'Part: \\ui{literal}' : 'Part: ',
|
||||
},
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
name: Constants.SUBAGENT,
|
||||
output: 'Legacy output',
|
||||
subagent_content: [{ type: ContentTypes.TEXT, text: 'Nested text' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(importBatchBuilder.messages[0].attachments).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('should import linear, non-recursive thread correctly with correct endpoint', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({
|
||||
[EModelEndpoint.azureOpenAI]: {},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue