fix: encrypt Langfuse agent secrets

This commit is contained in:
Danny Avila 2026-05-11 22:10:03 -04:00
parent dfc297ce2d
commit 0c2fdc92cd
4 changed files with 240 additions and 54 deletions

View file

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

View file

@ -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', () => {