fix: harden Langfuse agent config handling

This commit is contained in:
Danny Avila 2026-05-12 00:10:20 -04:00
parent 0c2fdc92cd
commit bc636c9a2e
18 changed files with 628 additions and 149 deletions

View file

@ -1,12 +1,14 @@
const { z } = require('zod');
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger, encryptV2 } = require('@librechat/data-schemas');
const { logger } = require('@librechat/data-schemas');
const {
refreshS3Url,
agentCreateSchema,
agentUpdateSchema,
redactLangfuseSecret,
refreshListAvatars,
normalizeLangfuseConfig,
collectEdgeAgentIds,
mergeAgentOcrConversion,
MAX_AVATAR_REFRESH_AGENTS,
@ -60,88 +62,6 @@ const getSafeModelParameters = (modelParameters) => {
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
};
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 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;
}
const existingConfig = toPlainObject(existing) ?? {};
const normalized = {};
if (typeof incoming.enabled === 'boolean') {
normalized.enabled = incoming.enabled;
}
for (const key of ['publicKey', 'baseUrl']) {
if (isNonEmptyString(incoming[key])) {
normalized[key] = incoming[key].trim();
}
}
if (isNonEmptyString(incoming.secretKey)) {
normalized.secretKey = await normalizeLangfuseSecret(incoming.secretKey, {
preserveEncrypted: options.preserveIncomingEncrypted === true,
});
} else if (isNonEmptyString(existingConfig.secretKey)) {
normalized.secretKey = await normalizeLangfuseSecret(existingConfig.secretKey, {
preserveEncrypted: true,
});
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
const redactLangfuseSecret = (agent) => {
const payload = toPlainObject(agent);
if (!payload || typeof payload !== 'object') {
return payload;
}
const redactSingleAgent = (value) => {
if (!value || typeof value !== 'object') {
return value;
}
if (value.langfuse && typeof value.langfuse === 'object' && value.langfuse.secretKey) {
return {
...value,
langfuse: {
...toPlainObject(value.langfuse),
secretKey: '',
},
};
}
return value;
};
const redactedPayload = redactSingleAgent(payload);
if (Array.isArray(redactedPayload.versions)) {
redactedPayload.versions = redactedPayload.versions.map((version) =>
redactSingleAgent(toPlainObject(version)),
);
}
return redactedPayload;
};
/**
* Looks up each referenced agent id in Mongo, splits them into three
* buckets the caller needs for validation: ids that don't exist at all,

View file

@ -10,7 +10,11 @@ const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
const { agentSchema, fileSchema, encryptV2, decryptV2 } = require('@librechat/data-schemas');
const { FileSources, PermissionBits } = require('librechat-data-provider');
const {
FileSources,
PermissionBits,
LANGFUSE_SECRET_CLEAR_VALUE,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
// Only mock the dependencies that are not database-related
@ -877,6 +881,44 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-updated');
});
test('should clear existing Langfuse secret when update sends the clear sentinel', async () => {
const encryptedOriginal = await encryptStoredSecret('sk-original');
await Agent.updateOne(
{ id: existingAgentId },
{
langfuse: {
enabled: true,
publicKey: 'pk-original',
secretKey: encryptedOriginal,
baseUrl: 'https://cloud.langfuse.com',
},
},
);
mockReq.params.id = existingAgentId;
mockReq.body = {
langfuse: {
enabled: true,
publicKey: 'pk-original',
secretKey: LANGFUSE_SECRET_CLEAR_VALUE,
baseUrl: 'https://cloud.langfuse.com',
},
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.langfuse.secretKey).toBeUndefined();
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
expect(agentInDb.langfuse).toEqual({
enabled: true,
publicKey: 'pk-original',
baseUrl: 'https://cloud.langfuse.com',
});
});
test('uploadAgentAvatarHandler should redact Langfuse secret in response', async () => {
await Agent.updateOne(
{ id: existingAgentId },