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

View file

@ -1,5 +1,5 @@
import { Providers } from '@librechat/agents';
import { logger } from '@librechat/data-schemas';
import { logger, decryptV2 } from '@librechat/data-schemas';
import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages';
import {
@ -8,6 +8,18 @@ import {
extractDiscoveredToolsFromHistory,
} from './run';
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
decryptV2: jest.fn(async (value: string) =>
value === '0123456789abcdef0123456789abcdef:736b2d6167656e74'
? encodeURIComponent('sk-agent')
: value,
),
};
});
type LangfuseRunAgent = Parameters<typeof resolveEffectiveLangfuseConfig>[0];
type LangfuseAppConfig = NonNullable<Parameters<typeof resolveEffectiveLangfuseConfig>[1]>;
@ -188,14 +200,14 @@ describe('resolveEffectiveLangfuseConfig', () => {
delete process.env.LANGFUSE_TEST_MISSING_KEY;
});
it('returns undefined when no tenant or agent config is supplied', () => {
it('returns undefined when no tenant or agent config is supplied', async () => {
expect(
resolveEffectiveLangfuseConfig(createLangfuseAgent(), createLangfuseAppConfig()),
await resolveEffectiveLangfuseConfig(createLangfuseAgent(), createLangfuseAppConfig()),
).toBeUndefined();
});
it('uses tenant defaults and resolves env var refs', () => {
const result = resolveEffectiveLangfuseConfig(
it('uses tenant defaults and resolves env var refs', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent(),
createLangfuseAppConfig({
enabled: true,
@ -213,8 +225,8 @@ describe('resolveEffectiveLangfuseConfig', () => {
});
});
it('overlays non-empty agent fields on tenant defaults', () => {
const result = resolveEffectiveLangfuseConfig(
it('overlays non-empty agent fields on tenant defaults', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
enabled: true,
publicKey: 'pk-agent',
@ -235,8 +247,8 @@ describe('resolveEffectiveLangfuseConfig', () => {
});
});
it('enables tracing with valid keys and no base URL', () => {
const result = resolveEffectiveLangfuseConfig(
it('enables tracing with valid keys and no base URL', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent(),
createLangfuseAppConfig({
enabled: true,
@ -252,8 +264,31 @@ describe('resolveEffectiveLangfuseConfig', () => {
});
});
it('lets an agent explicitly disable tracing over enabled tenant defaults', () => {
const result = resolveEffectiveLangfuseConfig(
it('decrypts encrypted agent secret keys before resolving the effective config', async () => {
const encryptedSecret = '0123456789abcdef0123456789abcdef:736b2d6167656e74';
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
enabled: true,
publicKey: 'pk-agent',
secretKey: encryptedSecret,
}),
createLangfuseAppConfig({
enabled: true,
publicKey: 'pk-tenant',
secretKey: 'sk-tenant',
}),
);
expect(decryptV2).toHaveBeenCalledWith(encryptedSecret);
expect(result).toEqual({
enabled: true,
publicKey: 'pk-agent',
secretKey: 'sk-agent',
});
});
it('lets an agent explicitly disable tracing over enabled tenant defaults', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
enabled: false,
}),
@ -268,8 +303,8 @@ describe('resolveEffectiveLangfuseConfig', () => {
expect(result).toEqual({ enabled: false });
});
it('disables tracing and warns when credentials are unresolved', () => {
const result = resolveEffectiveLangfuseConfig(
it('disables tracing and warns when credentials are unresolved', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent(),
createLangfuseAppConfig({
enabled: true,

View file

@ -1,4 +1,4 @@
import { logger } from '@librechat/data-schemas';
import { logger, decryptV2 } from '@librechat/data-schemas';
import { Run, Providers, Constants } from '@librechat/agents';
import {
KnownEndpoints,
@ -278,6 +278,7 @@ function isNonEmptyString(value: unknown): value is string {
}
const UNRESOLVED_ENV_VAR_PLACEHOLDER = /\$\{[^}]+\}/;
const ENCRYPTED_V2_VALUE = /^[a-f0-9]{32}:[a-f0-9]+$/i;
function hasUnresolvedPlaceholder(value: string): boolean {
return UNRESOLVED_ENV_VAR_PLACEHOLDER.test(value);
@ -298,7 +299,11 @@ type EffectiveLangfuseConfig =
enabled: false;
};
function resolveLangfuseValue(agentValue?: string, tenantValue?: string): string | undefined {
async function resolveLangfuseValue(
agentValue?: string,
tenantValue?: string,
options: { encrypted?: boolean; agentId?: string } = {},
): Promise<string | undefined> {
const rawValue = isNonEmptyString(agentValue)
? agentValue
: isNonEmptyString(tenantValue)
@ -308,7 +313,21 @@ function resolveLangfuseValue(agentValue?: string, tenantValue?: string): string
return undefined;
}
const resolved = extractEnvVariable(rawValue);
let value = rawValue;
if (options.encrypted === true && ENCRYPTED_V2_VALUE.test(value)) {
try {
value = decodeURIComponent(await decryptV2(value));
} catch (error) {
logger.warn(
`[createRun] Langfuse tracing disabled for agent ${
options.agentId ?? 'unknown'
}; failed to decrypt secretKey: ${error instanceof Error ? error.message : String(error)}`,
);
return undefined;
}
}
const resolved = extractEnvVariable(value);
if (!isNonEmptyString(resolved) || hasUnresolvedPlaceholder(resolved)) {
return undefined;
}
@ -316,10 +335,10 @@ function resolveLangfuseValue(agentValue?: string, tenantValue?: string): string
return resolved;
}
export function resolveEffectiveLangfuseConfig(
export async function resolveEffectiveLangfuseConfig(
agent: RunAgent,
appConfig?: AppConfig,
): EffectiveLangfuseConfig | undefined {
): Promise<EffectiveLangfuseConfig | undefined> {
const tenantLangfuse = appConfig?.langfuse;
const agentLangfuse = agent.langfuse;
@ -332,9 +351,12 @@ export function resolveEffectiveLangfuseConfig(
return { enabled: false };
}
const publicKey = resolveLangfuseValue(agentLangfuse?.publicKey, tenantLangfuse?.publicKey);
const secretKey = resolveLangfuseValue(agentLangfuse?.secretKey, tenantLangfuse?.secretKey);
const baseUrl = resolveLangfuseValue(agentLangfuse?.baseUrl, tenantLangfuse?.baseUrl);
const publicKey = await resolveLangfuseValue(agentLangfuse?.publicKey, tenantLangfuse?.publicKey);
const secretKey = await resolveLangfuseValue(agentLangfuse?.secretKey, tenantLangfuse?.secretKey, {
encrypted: true,
agentId: agent.id,
});
const baseUrl = await resolveLangfuseValue(agentLangfuse?.baseUrl, tenantLangfuse?.baseUrl);
if (!publicKey || !secretKey) {
const missingFields = [
@ -697,14 +719,14 @@ function anyAgentHasCodeEnv(agents: RunAgent[]): boolean {
* explicit child agents loaded in `agent.subagentAgentConfigs`. Returns an empty
* array when subagents are disabled or no spawn targets are available.
*/
function buildSubagentConfigs(
async function buildSubagentConfigs(
agent: RunAgent,
agentInput: AgentInputs,
toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs,
toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => Promise<AgentInputs>,
state: SubagentBuildState,
ancestors: Set<string> = new Set(),
depth = 0,
): SubagentConfig[] {
): Promise<SubagentConfig[]> {
if (!agent.subagents?.enabled) {
return [];
}
@ -754,7 +776,7 @@ function buildSubagentConfigs(
* skips both the field stamping and the registry mutation at the
* source so children truly start fresh.
*/
const childInputs = toInput(child, { isSubagent: true });
const childInputs = await toInput(child, { isSubagent: true });
/**
* Recursively resolve the child's own spawn targets so multi-level
* delegation (A B C) works. Without this, a child whose own
@ -763,7 +785,7 @@ function buildSubagentConfigs(
* `subagentConfigs`, and that only runs for the outer agents in
* `agents[]`. Cycle-safe via `nextAncestors`.
*/
const grandchildConfigs = buildSubagentConfigs(
const grandchildConfigs = await buildSubagentConfigs(
child,
childInputs,
toInput,
@ -858,7 +880,10 @@ export async function createRun({
? extractDiscoveredToolsFromHistory(messages)
: new Set<string>();
const buildAgentInput = (agent: RunAgent, opts: { isSubagent?: boolean } = {}): AgentInputs => {
const buildAgentInput = async (
agent: RunAgent,
opts: { isSubagent?: boolean } = {},
): Promise<AgentInputs> => {
const isSubagent = opts.isSubagent === true;
const provider =
(providerEndpointMap[
@ -981,7 +1006,7 @@ export async function createRun({
);
const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint);
const langfuse = resolveEffectiveLangfuseConfig(agent, appConfig);
const langfuse = await resolveEffectiveLangfuseConfig(agent, appConfig);
const agentInput: AgentInputs & { langfuse?: LangfuseConfig } = {
provider,
reasoningKey,
@ -1015,8 +1040,8 @@ export async function createRun({
rootAgentIds: agents.map((agent) => agent.id),
};
for (const agent of agents) {
const agentInput = buildAgentInput(agent);
const subagentConfigs = buildSubagentConfigs(
const agentInput = await buildAgentInput(agent);
const subagentConfigs = await buildSubagentConfigs(
agent,
agentInput,
buildAgentInput,