🧩 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:
Danny Avila 2026-08-15 12:49:59 -04:00 committed by GitHub
parent eb3b353712
commit 1d789c41a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1758 additions and 272 deletions

View file

@ -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);

View file

@ -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]: {},