mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
fix: encrypt Langfuse agent secrets
This commit is contained in:
parent
dfc297ce2d
commit
0c2fdc92cd
4 changed files with 240 additions and 54 deletions
|
|
@ -1,7 +1,7 @@
|
|||
const { z } = require('zod');
|
||||
const fs = require('fs').promises;
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logger, encryptV2 } = require('@librechat/data-schemas');
|
||||
const {
|
||||
refreshS3Url,
|
||||
agentCreateSchema,
|
||||
|
|
@ -64,8 +64,22 @@ const toPlainObject = (value) =>
|
|||
value && typeof value.toObject === 'function' ? value.toObject() : value;
|
||||
|
||||
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
||||
const ENCRYPTED_V2_VALUE = /^[a-f0-9]{32}:[a-f0-9]+$/i;
|
||||
|
||||
const normalizeLangfuseConfig = (incoming, existing) => {
|
||||
const encryptSensitiveValue = async (value) => encryptV2(encodeURIComponent(value));
|
||||
|
||||
const normalizeLangfuseSecret = async (value, options = {}) => {
|
||||
if (!isNonEmptyString(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (options.preserveEncrypted === true && ENCRYPTED_V2_VALUE.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return await encryptSensitiveValue(trimmed);
|
||||
};
|
||||
|
||||
const normalizeLangfuseConfig = async (incoming, existing, options = {}) => {
|
||||
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
|
||||
return incoming;
|
||||
}
|
||||
|
|
@ -84,9 +98,13 @@ const normalizeLangfuseConfig = (incoming, existing) => {
|
|||
}
|
||||
|
||||
if (isNonEmptyString(incoming.secretKey)) {
|
||||
normalized.secretKey = incoming.secretKey.trim();
|
||||
normalized.secretKey = await normalizeLangfuseSecret(incoming.secretKey, {
|
||||
preserveEncrypted: options.preserveIncomingEncrypted === true,
|
||||
});
|
||||
} else if (isNonEmptyString(existingConfig.secretKey)) {
|
||||
normalized.secretKey = existingConfig.secretKey;
|
||||
normalized.secretKey = await normalizeLangfuseSecret(existingConfig.secretKey, {
|
||||
preserveEncrypted: true,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
|
|
@ -349,7 +367,7 @@ const createAgentHandler = async (req, res) => {
|
|||
}
|
||||
|
||||
if (agentData.langfuse) {
|
||||
agentData.langfuse = normalizeLangfuseConfig(agentData.langfuse);
|
||||
agentData.langfuse = await normalizeLangfuseConfig(agentData.langfuse);
|
||||
if (!agentData.langfuse) {
|
||||
delete agentData.langfuse;
|
||||
}
|
||||
|
|
@ -628,7 +646,10 @@ const updateAgentHandler = async (req, res) => {
|
|||
}
|
||||
|
||||
if (updateData.langfuse) {
|
||||
updateData.langfuse = normalizeLangfuseConfig(updateData.langfuse, existingAgent.langfuse);
|
||||
updateData.langfuse = await normalizeLangfuseConfig(
|
||||
updateData.langfuse,
|
||||
existingAgent.langfuse,
|
||||
);
|
||||
if (!updateData.langfuse) {
|
||||
delete updateData.langfuse;
|
||||
}
|
||||
|
|
@ -1120,7 +1141,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||
logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr);
|
||||
}
|
||||
|
||||
res.status(201).json(updatedAgent);
|
||||
res.status(201).json(redactLangfuseSecret(updatedAgent));
|
||||
} catch (error) {
|
||||
const message = 'An error occurred while updating the Agent Avatar';
|
||||
logger.error(
|
||||
|
|
@ -1204,6 +1225,15 @@ const revertAgentVersionHandler = async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (updatedAgent.langfuse) {
|
||||
const normalizedLangfuse = await normalizeLangfuseConfig(updatedAgent.langfuse, undefined, {
|
||||
preserveIncomingEncrypted: true,
|
||||
});
|
||||
if (normalizedLangfuse) {
|
||||
revertUpdates.langfuse = normalizedLangfuse;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(revertUpdates).length > 0) {
|
||||
updatedAgent = await db.updateAgent({ id }, revertUpdates, { updatingUserId: req.user.id });
|
||||
}
|
||||
|
|
@ -1216,7 +1246,7 @@ const revertAgentVersionHandler = async (req, res) => {
|
|||
delete updatedAgent.author;
|
||||
}
|
||||
|
||||
return res.json(updatedAgent);
|
||||
return res.json(redactLangfuseSecret(updatedAgent));
|
||||
} catch (error) {
|
||||
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
process.env.CREDS_KEY =
|
||||
process.env.CREDS_KEY?.length === 64
|
||||
? process.env.CREDS_KEY
|
||||
: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
process.env.CREDS_IV =
|
||||
process.env.CREDS_IV?.length === 32 ? process.env.CREDS_IV : '0123456789abcdef0123456789abcdef';
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const fs = require('fs').promises;
|
||||
const { nanoid } = require('nanoid');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { agentSchema, fileSchema } = require('@librechat/data-schemas');
|
||||
const { agentSchema, fileSchema, encryptV2, decryptV2 } = require('@librechat/data-schemas');
|
||||
const { FileSources, PermissionBits } = require('librechat-data-provider');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
|
|
@ -74,6 +82,7 @@ const {
|
|||
createAgent: createAgentHandler,
|
||||
getAgent: getAgentHandler,
|
||||
duplicateAgent: duplicateAgentHandler,
|
||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||
revertAgentVersion: revertAgentVersionHandler,
|
||||
updateAgent: updateAgentHandler,
|
||||
getListAgents: getListAgentsHandler,
|
||||
|
|
@ -86,12 +95,17 @@ const {
|
|||
} = require('~/server/services/PermissionService');
|
||||
|
||||
const { refreshS3Url } = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
|
||||
/**
|
||||
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
||||
*/
|
||||
let Agent;
|
||||
|
||||
const encryptStoredSecret = async (value) => encryptV2(encodeURIComponent(value));
|
||||
const decryptStoredSecret = async (value) => decodeURIComponent(await decryptV2(value));
|
||||
|
||||
describe('Agent Controllers - Mass Assignment Protection', () => {
|
||||
let mongoServer;
|
||||
let mockReq;
|
||||
|
|
@ -203,12 +217,12 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
});
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
|
||||
expect(agentInDb.langfuse).toEqual({
|
||||
enabled: true,
|
||||
publicKey: 'pk-test',
|
||||
secretKey: 'sk-test',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
});
|
||||
expect(agentInDb.langfuse.enabled).toBe(true);
|
||||
expect(agentInDb.langfuse.publicKey).toBe('pk-test');
|
||||
expect(agentInDb.langfuse.secretKey).not.toBe('sk-test');
|
||||
expect(agentInDb.langfuse.secretKey).toContain(':');
|
||||
expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-test');
|
||||
expect(agentInDb.langfuse.baseUrl).toBe('https://cloud.langfuse.com');
|
||||
});
|
||||
|
||||
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
|
||||
|
|
@ -785,13 +799,14 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
});
|
||||
|
||||
test('should preserve existing Langfuse secret when update sends an empty secret', async () => {
|
||||
const encryptedOriginal = await encryptStoredSecret('sk-original');
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-original',
|
||||
secretKey: 'sk-original',
|
||||
secretKey: encryptedOriginal,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
},
|
||||
|
|
@ -819,22 +834,22 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
});
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
|
||||
expect(agentInDb.langfuse).toEqual({
|
||||
enabled: true,
|
||||
publicKey: 'pk-updated',
|
||||
secretKey: 'sk-original',
|
||||
baseUrl: 'https://us.cloud.langfuse.com',
|
||||
});
|
||||
expect(agentInDb.langfuse.enabled).toBe(true);
|
||||
expect(agentInDb.langfuse.publicKey).toBe('pk-updated');
|
||||
expect(agentInDb.langfuse.secretKey).toBe(encryptedOriginal);
|
||||
expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-original');
|
||||
expect(agentInDb.langfuse.baseUrl).toBe('https://us.cloud.langfuse.com');
|
||||
});
|
||||
|
||||
test('should update Langfuse secret explicitly while redacting it in update response', async () => {
|
||||
const encryptedOriginal = await encryptStoredSecret('sk-original');
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-original',
|
||||
secretKey: 'sk-original',
|
||||
secretKey: encryptedOriginal,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
},
|
||||
|
|
@ -857,7 +872,50 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(updatedAgent.langfuse.secretKey).toBe('');
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
|
||||
expect(agentInDb.langfuse.secretKey).toBe('sk-updated');
|
||||
expect(agentInDb.langfuse.secretKey).not.toBe('sk-updated');
|
||||
expect(agentInDb.langfuse.secretKey).not.toBe(encryptedOriginal);
|
||||
expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-updated');
|
||||
});
|
||||
|
||||
test('uploadAgentAvatarHandler should redact Langfuse secret in response', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-avatar',
|
||||
secretKey: 'sk-avatar',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const readFileSpy = jest.spyOn(fs, 'readFile').mockResolvedValue(Buffer.from('avatar'));
|
||||
const unlinkSpy = jest.spyOn(fs, 'unlink').mockResolvedValue();
|
||||
getStrategyFunctions.mockReturnValue({
|
||||
processAvatar: jest.fn().mockResolvedValue('avatars/new-avatar.png'),
|
||||
});
|
||||
resizeAvatar.mockResolvedValue(Buffer.from('resized-avatar'));
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.user.tenantId = 'tenant-1';
|
||||
mockReq.params.agent_id = existingAgentId;
|
||||
mockReq.config = {};
|
||||
mockReq.file = {
|
||||
path: '/tmp/avatar.png',
|
||||
};
|
||||
|
||||
await uploadAgentAvatarHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.langfuse.secretKey).toBe('');
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
|
||||
expect(agentInDb.langfuse.secretKey).toBe('sk-avatar');
|
||||
|
||||
readFileSpy.mockRestore();
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should return 404 for non-existent agent', async () => {
|
||||
|
|
@ -1159,6 +1217,44 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
const agentInDb = await Agent.findOne({ id: agent.id }).lean();
|
||||
expect(agentInDb.tool_resources.file_search.file_ids).toEqual([ownedFileId]);
|
||||
});
|
||||
|
||||
test('revertAgentVersionHandler should redact Langfuse secret in response', async () => {
|
||||
const agentAuthorId = new mongoose.Types.ObjectId();
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Current Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: agentAuthorId,
|
||||
versions: [
|
||||
{
|
||||
name: 'Historical Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-version',
|
||||
secretKey: 'sk-version',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockReq.user.id = agentAuthorId.toString();
|
||||
mockReq.params.id = agent.id;
|
||||
mockReq.body = { version_index: 0 };
|
||||
|
||||
await revertAgentVersionHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.langfuse.secretKey).toBe('');
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: agent.id }).lean();
|
||||
expect(agentInDb.langfuse.secretKey).not.toBe('sk-version');
|
||||
expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-version');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mass Assignment Attack Scenarios', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue