🧾 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:
Danny Avila 2026-06-23 15:49:57 -04:00 • committed by GitHub
parent 606292c5c5
commit 1eb460eb03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 755 additions and 74 deletions

View file

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

View file

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