mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-20 17:13:44 +00:00
🧾 fix: Harden Historical File Authorization (#13918)
* fix: Harden historical file authorization * chore: Sort file authorization imports * fix: Preserve authorized historical artifact refs * chore: Format historical artifact hardening
This commit is contained in:
parent
606292c5c5
commit
1eb460eb03
11 changed files with 755 additions and 74 deletions
|
|
@ -6,6 +6,7 @@ const {
|
|||
checkBalance,
|
||||
getBalanceConfig,
|
||||
buildMessageFiles,
|
||||
sanitizeFileForTransmit,
|
||||
extractFileContext,
|
||||
getReferencedQuotes,
|
||||
encodeAndFormatAudios,
|
||||
|
|
@ -15,6 +16,7 @@ const {
|
|||
const {
|
||||
Constants,
|
||||
FileSources,
|
||||
Tools,
|
||||
ContentTypes,
|
||||
excludedKeys,
|
||||
EModelEndpoint,
|
||||
|
|
@ -31,6 +33,115 @@ const { logViolation } = require('~/cache');
|
|||
const TextStream = require('./TextStream');
|
||||
const db = require('~/models');
|
||||
|
||||
const collectHistoricalFileRefs = (message) => {
|
||||
const refs = [];
|
||||
if (Array.isArray(message.files)) {
|
||||
refs.push(...message.files);
|
||||
}
|
||||
if (Array.isArray(message.attachments)) {
|
||||
refs.push(...message.attachments);
|
||||
}
|
||||
return refs;
|
||||
};
|
||||
|
||||
const collectHistoricalFileIds = (messages) => {
|
||||
const fileIds = new Set();
|
||||
for (const message of messages) {
|
||||
for (const ref of collectHistoricalFileRefs(message)) {
|
||||
if (ref?.file_id) {
|
||||
fileIds.add(ref.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(fileIds);
|
||||
};
|
||||
|
||||
const buildOwnerFileFilter = (fileIds, user) => {
|
||||
if (!user?.id || fileIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
file_id: { $in: fileIds },
|
||||
user: user.id,
|
||||
};
|
||||
if (user.tenantId) {
|
||||
filter.tenantId = user.tenantId;
|
||||
}
|
||||
return filter;
|
||||
};
|
||||
|
||||
const TOOL_ATTACHMENT_KEYS = [
|
||||
Tools.file_search,
|
||||
Tools.web_search,
|
||||
Tools.ui_resources,
|
||||
Tools.memory,
|
||||
];
|
||||
const DISPLAY_ATTACHMENT_FIELDS = [
|
||||
'filename',
|
||||
'filepath',
|
||||
'expiresAt',
|
||||
'conversationId',
|
||||
'messageId',
|
||||
'toolCallId',
|
||||
'name',
|
||||
];
|
||||
const PER_MESSAGE_FILE_ATTACHMENT_FIELDS = ['messageId', 'toolCallId'];
|
||||
|
||||
const pickFields = (source, fields) => {
|
||||
const picked = {};
|
||||
for (const field of fields) {
|
||||
if (source?.[field] !== undefined) {
|
||||
picked[field] = source[field];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
|
||||
const sanitizeDisplayOnlyAttachment = (ref) => {
|
||||
if (!ref || ref.file_id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const attachment = pickFields(ref, DISPLAY_ATTACHMENT_FIELDS);
|
||||
if (TOOL_ATTACHMENT_KEYS.includes(ref.type)) {
|
||||
attachment.type = ref.type;
|
||||
}
|
||||
for (const key of TOOL_ATTACHMENT_KEYS) {
|
||||
if (ref[key] !== undefined) {
|
||||
attachment[key] = ref[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(attachment).length > 0 ? attachment : undefined;
|
||||
};
|
||||
|
||||
const rehydrateMessageFileRefs = (refs, filesById, { preserveDisplayOnly = false } = {}) => {
|
||||
if (!Array.isArray(refs)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const files = [];
|
||||
for (const ref of refs) {
|
||||
const file = filesById.get(ref?.file_id);
|
||||
if (file) {
|
||||
files.push({
|
||||
...sanitizeFileForTransmit(file),
|
||||
...pickFields(ref, PER_MESSAGE_FILE_ATTACHMENT_FIELDS),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preserveDisplayOnly) {
|
||||
const displayOnlyAttachment = sanitizeDisplayOnlyAttachment(ref);
|
||||
if (displayOnlyAttachment) {
|
||||
files.push(displayOnlyAttachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files.length > 0 ? files : undefined;
|
||||
};
|
||||
|
||||
class BaseClient {
|
||||
constructor(apiKey, options = {}) {
|
||||
this.apiKey = apiKey;
|
||||
|
|
@ -1324,12 +1435,26 @@ class BaseClient {
|
|||
return _messages;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const contextSeen = new Set();
|
||||
const attachmentsProcessed =
|
||||
this.options.attachments && !(this.options.attachments instanceof Promise);
|
||||
if (attachmentsProcessed) {
|
||||
for (const attachment of this.options.attachments) {
|
||||
seen.add(attachment.file_id);
|
||||
if (attachment?.file_id) {
|
||||
contextSeen.add(attachment.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const historicalFileIds = collectHistoricalFileIds(_messages);
|
||||
const fileFilter = buildOwnerFileFilter(historicalFileIds, this.options.req?.user);
|
||||
const authorizedFilesById = new Map();
|
||||
if (fileFilter) {
|
||||
const files = (await db.getFiles(fileFilter, {}, {})) ?? [];
|
||||
for (const file of files) {
|
||||
if (file?.file_id) {
|
||||
authorizedFilesById.set(file.file_id, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1343,38 +1468,57 @@ class BaseClient {
|
|||
this.message_file_map = {};
|
||||
}
|
||||
|
||||
const fileIds = [];
|
||||
for (const file of message.files) {
|
||||
if (seen.has(file.file_id)) {
|
||||
continue;
|
||||
delete message.fileContext;
|
||||
|
||||
const contextFiles = [];
|
||||
if (Array.isArray(message.files)) {
|
||||
for (const file of message.files) {
|
||||
if (!file?.file_id || contextSeen.has(file.file_id)) {
|
||||
continue;
|
||||
}
|
||||
const authorizedFile = authorizedFilesById.get(file.file_id);
|
||||
if (authorizedFile) {
|
||||
contextFiles.push(authorizedFile);
|
||||
contextSeen.add(file.file_id);
|
||||
}
|
||||
}
|
||||
fileIds.push(file.file_id);
|
||||
seen.add(file.file_id);
|
||||
}
|
||||
|
||||
if (fileIds.length === 0) {
|
||||
const rehydratedFiles = rehydrateMessageFileRefs(message.files, authorizedFilesById);
|
||||
if (rehydratedFiles) {
|
||||
message.files = rehydratedFiles;
|
||||
} else {
|
||||
delete message.files;
|
||||
}
|
||||
|
||||
const rehydratedAttachments = rehydrateMessageFileRefs(
|
||||
message.attachments,
|
||||
authorizedFilesById,
|
||||
{
|
||||
preserveDisplayOnly: true,
|
||||
},
|
||||
);
|
||||
if (rehydratedAttachments) {
|
||||
message.attachments = rehydratedAttachments;
|
||||
} else {
|
||||
delete message.attachments;
|
||||
}
|
||||
|
||||
if (contextFiles.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const files = await db.getFiles(
|
||||
{
|
||||
file_id: { $in: fileIds },
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
await this.addFileContextToMessage(message, contextFiles);
|
||||
await this.processAttachments(message, contextFiles);
|
||||
|
||||
await this.addFileContextToMessage(message, files);
|
||||
await this.processAttachments(message, files);
|
||||
|
||||
this.message_file_map[message.messageId] = files;
|
||||
this.message_file_map[message.messageId] = contextFiles;
|
||||
return message;
|
||||
};
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const message of _messages) {
|
||||
if (!message.files) {
|
||||
if (!message.files && !message.attachments) {
|
||||
promises.push(message);
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ jest.mock('~/models', () => ({
|
|||
updateFileUsage: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getConvo, getMessages, saveConvo, saveMessage } = require('~/models');
|
||||
const { getConvo, getFiles, getMessages, saveConvo, saveMessage } = require('~/models');
|
||||
|
||||
jest.mock('@librechat/agents', () => {
|
||||
const actual = jest.requireActual('@librechat/agents');
|
||||
|
|
@ -1317,6 +1317,242 @@ describe('BaseClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('addPreviousAttachments authorization', () => {
|
||||
const ownerFile = {
|
||||
file_id: 'owner-file',
|
||||
filename: 'owner.txt',
|
||||
filepath: '/uploads/owner.txt',
|
||||
source: 'local',
|
||||
type: 'text/plain',
|
||||
bytes: 100,
|
||||
object: 'file',
|
||||
user: 'user-1',
|
||||
embedded: false,
|
||||
usage: 0,
|
||||
text: 'authorized owner text',
|
||||
_id: 'owner-mongo-id',
|
||||
metadata: {
|
||||
codeEnvRef: {
|
||||
kind: 'user',
|
||||
id: 'user-1',
|
||||
storage_session_id: 'owner-session',
|
||||
file_id: 'owner-code-file',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getFiles.mockReset();
|
||||
TestClient.options.resendFiles = true;
|
||||
TestClient.options.attachments = undefined;
|
||||
TestClient.options.req = {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
};
|
||||
TestClient.addFileContextToMessage = jest.fn(async (message, files) => {
|
||||
const text = files
|
||||
.map((file) => file.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (text) {
|
||||
message.fileContext = text;
|
||||
}
|
||||
});
|
||||
TestClient.processAttachments = jest.fn(async (_message, files) => files);
|
||||
TestClient.checkVisionRequest = jest.fn();
|
||||
});
|
||||
|
||||
test('rehydrates historical file refs from owner-scoped DB rows only', async () => {
|
||||
getFiles.mockResolvedValueOnce([ownerFile]);
|
||||
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
text: 'Use the attachment',
|
||||
files: [
|
||||
{
|
||||
file_id: 'owner-file',
|
||||
filename: 'attacker-controlled-owner-name.txt',
|
||||
filepath: '/forged/owner.txt',
|
||||
text: 'forged owner text',
|
||||
},
|
||||
{
|
||||
file_id: 'victim-file',
|
||||
filename: 'victim.txt',
|
||||
filepath: '/victim/private.txt',
|
||||
text: 'victim private text',
|
||||
},
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
file_id: 'victim-file',
|
||||
filename: 'victim-output.csv',
|
||||
text: 'victim output text',
|
||||
},
|
||||
],
|
||||
fileContext: 'stale victim private text',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getFiles).toHaveBeenCalledWith(
|
||||
{
|
||||
file_id: { $in: ['owner-file', 'victim-file'] },
|
||||
user: 'user-1',
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(message, [ownerFile]);
|
||||
expect(TestClient.processAttachments).toHaveBeenCalledWith(message, [ownerFile]);
|
||||
expect(message.fileContext).toBe('authorized owner text');
|
||||
expect(message.files).toEqual([
|
||||
expect.objectContaining({
|
||||
file_id: 'owner-file',
|
||||
filename: 'owner.txt',
|
||||
filepath: '/uploads/owner.txt',
|
||||
source: 'local',
|
||||
metadata: ownerFile.metadata,
|
||||
}),
|
||||
]);
|
||||
expect(message.files[0].text).toBeUndefined();
|
||||
expect(message.files[0]._id).toBeUndefined();
|
||||
expect(message.attachments).toBeUndefined();
|
||||
expect(JSON.stringify(message)).not.toContain('victim');
|
||||
expect(JSON.stringify(message)).not.toContain('forged owner text');
|
||||
});
|
||||
|
||||
test('strips historical file context when no authenticated owner scope is available', async () => {
|
||||
TestClient.options.req = {};
|
||||
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-2',
|
||||
files: [{ file_id: 'victim-file', filename: 'victim.txt' }],
|
||||
fileContext: 'stale victim private text',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
expect(message.files).toBeUndefined();
|
||||
expect(message.fileContext).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preserves repeated owner-authorized historical file refs after the first context use', async () => {
|
||||
getFiles.mockResolvedValueOnce([ownerFile]);
|
||||
|
||||
const [firstMessage, secondMessage] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-repeat-1',
|
||||
files: [{ file_id: 'owner-file', filename: 'first-forged.txt' }],
|
||||
},
|
||||
{
|
||||
messageId: 'msg-repeat-2',
|
||||
files: [{ file_id: 'owner-file', filename: 'second-forged.txt' }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getFiles).toHaveBeenCalledTimes(1);
|
||||
expect(getFiles).toHaveBeenCalledWith(
|
||||
{
|
||||
file_id: { $in: ['owner-file'] },
|
||||
user: 'user-1',
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(TestClient.addFileContextToMessage).toHaveBeenCalledTimes(1);
|
||||
expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(firstMessage, [ownerFile]);
|
||||
expect(secondMessage.fileContext).toBeUndefined();
|
||||
expect(firstMessage.files).toEqual([
|
||||
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
|
||||
]);
|
||||
expect(secondMessage.files).toEqual([
|
||||
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
|
||||
]);
|
||||
expect(JSON.stringify(secondMessage)).not.toContain('second-forged');
|
||||
});
|
||||
|
||||
test('preserves download-only historical attachments without trusting file fields', async () => {
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-download-only',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'report.csv',
|
||||
filepath: '/api/files/code/download/session/file',
|
||||
expiresAt: 123456,
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-message',
|
||||
toolCallId: 'tool-call-1',
|
||||
text: 'untrusted text should not survive',
|
||||
source: 'forged-source',
|
||||
metadata: { codeEnvRef: { id: 'victim' } },
|
||||
},
|
||||
],
|
||||
fileContext: 'stale context',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
expect(message.fileContext).toBeUndefined();
|
||||
expect(message.attachments).toEqual([
|
||||
{
|
||||
filename: 'report.csv',
|
||||
filepath: '/api/files/code/download/session/file',
|
||||
expiresAt: 123456,
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-message',
|
||||
toolCallId: 'tool-call-1',
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(message)).not.toContain('untrusted text');
|
||||
expect(JSON.stringify(message)).not.toContain('forged-source');
|
||||
expect(JSON.stringify(message)).not.toContain('victim');
|
||||
});
|
||||
|
||||
test('merges safe per-message metadata onto authorized DB-backed attachments', async () => {
|
||||
getFiles.mockResolvedValueOnce([ownerFile]);
|
||||
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-artifact',
|
||||
attachments: [
|
||||
{
|
||||
file_id: 'owner-file',
|
||||
filename: 'forged-artifact.csv',
|
||||
filepath: '/forged/artifact.csv',
|
||||
source: 'forged-source',
|
||||
metadata: { codeEnvRef: { id: 'victim' } },
|
||||
text: 'forged artifact text',
|
||||
messageId: 'assistant-message',
|
||||
toolCallId: 'tool-call-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(message.attachments).toEqual([
|
||||
expect.objectContaining({
|
||||
file_id: 'owner-file',
|
||||
filename: 'owner.txt',
|
||||
filepath: '/uploads/owner.txt',
|
||||
source: 'local',
|
||||
metadata: ownerFile.metadata,
|
||||
messageId: 'assistant-message',
|
||||
toolCallId: 'tool-call-2',
|
||||
}),
|
||||
]);
|
||||
expect(message.attachments[0].text).toBeUndefined();
|
||||
expect(message.attachments[0]._id).toBeUndefined();
|
||||
expect(JSON.stringify(message)).not.toContain('forged-artifact');
|
||||
expect(JSON.stringify(message)).not.toContain('forged artifact text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage quote references', () => {
|
||||
// The blockquote merge itself lives in AgentClient.buildMessages / prependQuotes
|
||||
// (covered by packages/api specs). BaseClient's job is to attach the normalized
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue