diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index af46b9cc79..94512930c7 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -3,7 +3,12 @@ const fs = require('fs').promises; const FormData = require('form-data'); const { Readable } = require('stream'); const { logger } = require('@librechat/data-schemas'); -const { genAzureEndpoint, logAxiosError, applyAxiosProxyConfig } = require('@librechat/api'); +const { + genAzureEndpoint, + logAxiosError, + applyAxiosProxyConfig, + resolveConfigSecret, +} = require('@librechat/api'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); const { getAppConfig } = require('~/server/services/Config'); @@ -195,7 +200,7 @@ class STTService { */ openAIProvider(sttSchema, audioReadStream, audioFile, language) { const url = sttSchema?.url || 'https://api.openai.com/v1/audio/transcriptions'; - const apiKey = extractEnvVariable(sttSchema.apiKey) || ''; + const apiKey = resolveConfigSecret(sttSchema.apiKey) || ''; const data = { file: audioReadStream, @@ -231,7 +236,7 @@ class STTService { azureOpenAIApiDeploymentName: extractEnvVariable(sttSchema?.deploymentName), })}/audio/transcriptions?api-version=${extractEnvVariable(sttSchema?.apiVersion)}`; - const apiKey = sttSchema.apiKey ? extractEnvVariable(sttSchema.apiKey) : ''; + const apiKey = sttSchema.apiKey ? resolveConfigSecret(sttSchema.apiKey) || '' : ''; if (audioBuffer.byteLength > 25 * 1024 * 1024) { throw new Error('The audio file size exceeds the limit of 25MB'); diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index 301bbe90f8..f8f47eda43 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -1,6 +1,11 @@ const axios = require('axios'); const { logger } = require('@librechat/data-schemas'); -const { genAzureEndpoint, logAxiosError, applyAxiosProxyConfig } = require('@librechat/api'); +const { + genAzureEndpoint, + logAxiosError, + applyAxiosProxyConfig, + resolveConfigSecret, +} = require('@librechat/api'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); const { getAppConfig } = require('~/server/services/Config'); @@ -120,9 +125,10 @@ class TTSService { backend: ttsSchema?.backend, }; + const apiKey = resolveConfigSecret(ttsSchema?.apiKey) || ''; const headers = { 'Content-Type': 'application/json', - Authorization: `Bearer ${extractEnvVariable(ttsSchema?.apiKey)}`, + ...(apiKey && { Authorization: `Bearer ${apiKey}` }), }; return [url, data, headers]; @@ -159,7 +165,7 @@ class TTSService { const headers = { 'Content-Type': 'application/json', - 'api-key': ttsSchema.apiKey ? extractEnvVariable(ttsSchema.apiKey) : '', + 'api-key': ttsSchema.apiKey ? resolveConfigSecret(ttsSchema.apiKey) || '' : '', }; return [url, data, headers]; @@ -195,9 +201,10 @@ class TTSService { pronunciation_dictionary_locators: ttsSchema?.pronunciation_dictionary_locators, }; + const apiKey = resolveConfigSecret(ttsSchema?.apiKey) || ''; const headers = { 'Content-Type': 'application/json', - 'xi-api-key': extractEnvVariable(ttsSchema?.apiKey), + ...(apiKey && { 'xi-api-key': apiKey }), Accept: 'audio/mpeg', }; @@ -230,15 +237,12 @@ class TTSService { backend: ttsSchema?.backend, }; + const apiKey = resolveConfigSecret(ttsSchema?.apiKey) || ''; const headers = { 'Content-Type': 'application/json', - Authorization: `Bearer ${extractEnvVariable(ttsSchema?.apiKey)}`, + ...(apiKey && { Authorization: `Bearer ${apiKey}` }), }; - if (extractEnvVariable(ttsSchema.apiKey) === '') { - delete headers.Authorization; - } - return [url, data, headers]; } @@ -492,4 +496,5 @@ module.exports = { textToSpeech, streamAudio, getProvider, + TTSService, }; diff --git a/api/server/services/Files/Audio/TTSService.spec.js b/api/server/services/Files/Audio/TTSService.spec.js new file mode 100644 index 0000000000..f3b506e3af --- /dev/null +++ b/api/server/services/Files/Audio/TTSService.spec.js @@ -0,0 +1,72 @@ +jest.mock('axios'); +jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), error: jest.fn() } })); +jest.mock('@librechat/api', () => ({ + genAzureEndpoint: jest.fn(), + logAxiosError: jest.fn(), + applyAxiosProxyConfig: jest.fn(), + resolveConfigSecret: jest.fn(), +})); +jest.mock('librechat-data-provider', () => ({ + extractEnvVariable: jest.fn((value) => value), + TTSProviders: { + OPENAI: 'openai', + AZURE_OPENAI: 'azureOpenAI', + ELEVENLABS: 'elevenlabs', + LOCALAI: 'localai', + }, +})); +jest.mock('./streamAudio', () => ({ + getRandomVoiceId: jest.fn(), + createChunkProcessor: jest.fn(), + splitTextIntoChunks: jest.fn(), +})); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + +const { resolveConfigSecret } = require('@librechat/api'); +const { TTSService } = require('./TTSService'); + +describe('TTSService provider header construction with an undecryptable apiKey', () => { + let service; + + beforeEach(() => { + service = new TTSService(); + resolveConfigSecret.mockReset(); + }); + + it('omits the Authorization header for openAIProvider instead of sending "Bearer undefined"', () => { + resolveConfigSecret.mockReturnValue(undefined); + const [, , headers] = service.openAIProvider( + { apiKey: 'v3:corrupted', voices: [] }, + 'hi', + 'alloy', + ); + expect(headers).not.toHaveProperty('Authorization'); + }); + + it('sets the Authorization header normally when the key resolves', () => { + resolveConfigSecret.mockReturnValue('sk-real-key'); + const [, , headers] = service.openAIProvider({ apiKey: 'v3:ok', voices: [] }, 'hi', 'alloy'); + expect(headers.Authorization).toBe('Bearer sk-real-key'); + }); + + it('omits the xi-api-key header for elevenLabsProvider instead of sending "undefined"', () => { + resolveConfigSecret.mockReturnValue(undefined); + const [, , headers] = service.elevenLabsProvider( + { apiKey: 'v3:corrupted', voices: ['ALL'] }, + 'hi', + 'voice1', + false, + ); + expect(headers).not.toHaveProperty('xi-api-key'); + }); + + it('omits the Authorization header for localAIProvider instead of sending "Bearer undefined"', () => { + resolveConfigSecret.mockReturnValue(undefined); + const [, , headers] = service.localAIProvider( + { apiKey: 'v3:corrupted', voices: [] }, + 'hi', + 'voice1', + ); + expect(headers).not.toHaveProperty('Authorization'); + }); +}); diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index afb874d4b6..babc42c0b9 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -93,7 +93,7 @@ describe('createAdminConfigHandlers', () => { langfuse: { publicKey: 'pk-lf-1', secretKey: 'v3:encrypted', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', }, }, }, @@ -110,7 +110,7 @@ describe('createAdminConfigHandlers', () => { }>; expect(configs[0].overrides.langfuse).toEqual({ publicKey: 'pk-lf-1', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', }); }); }); @@ -486,13 +486,13 @@ describe('createAdminConfigHandlers', () => { const savedOverrides = deps.upsertConfig.mock.calls[0][3]; expect(savedOverrides.langfuse.secretKey).toMatch(/^v3:/); expect(savedOverrides.langfuse.secretKey).not.toBe('sk-lf-secret'); - expect(savedOverrides.langfuse.displaySecretKey).toBe('sk-lf-...cret'); + expect(savedOverrides.langfuse.secretKeyPreview).toBe('sk-lf-...cret'); const responseConfig = res.body!.config as { overrides: { langfuse: Record }; }; expect(responseConfig.overrides.langfuse).toEqual({ publicKey: 'pk-lf-1', - displaySecretKey: savedOverrides.langfuse.displaySecretKey, + secretKeyPreview: savedOverrides.langfuse.secretKeyPreview, }); }); @@ -504,7 +504,7 @@ describe('createAdminConfigHandlers', () => { langfuse: { publicKey: 'pk-old', secretKey: 'v3:test:sk-old', - displaySecretKey: 'sk-old...-old', + secretKeyPreview: 'sk-old...-old', }, }, }; @@ -537,7 +537,7 @@ describe('createAdminConfigHandlers', () => { publicKey: 'pk-new', destination: 'eu', secretKey: 'v3:test:sk-old', - displaySecretKey: 'sk-old...-old', + secretKeyPreview: 'sk-old...-old', }); const responseConfig = res.body!.config as { overrides: { langfuse: Record }; @@ -545,7 +545,7 @@ describe('createAdminConfigHandlers', () => { expect(responseConfig.overrides.langfuse).toEqual({ publicKey: 'pk-new', destination: 'eu', - displaySecretKey: 'sk-old...-old', + secretKeyPreview: 'sk-old...-old', }); }); @@ -556,7 +556,7 @@ describe('createAdminConfigHandlers', () => { overrides: { langfuse: { secretKey: 'v3:test:sk-old', - displaySecretKey: 'sk-old...-old', + secretKeyPreview: 'sk-old...-old', }, }, }), @@ -586,7 +586,7 @@ describe('createAdminConfigHandlers', () => { expect(savedOverrides.langfuse).toEqual({ publicKey: 'pk-new', secretKey: '', - displaySecretKey: '', + secretKeyPreview: '', }); }); @@ -624,7 +624,7 @@ describe('createAdminConfigHandlers', () => { body: { overrides: { 'langfuse.secretKey': 'sk-lf-secret', - 'langfuse.displaySecretKey': 'spoofed', + 'langfuse.secretKeyPreview': 'spoofed', langfuse: { publicKey: 'pk-lf-1' }, }, }, @@ -636,7 +636,7 @@ describe('createAdminConfigHandlers', () => { expect(res.statusCode).toBe(201); const savedOverrides = deps.upsertConfig.mock.calls[0][3]; expect(savedOverrides).not.toHaveProperty('langfuse.secretKey'); - expect(savedOverrides).not.toHaveProperty('langfuse.displaySecretKey'); + expect(savedOverrides).not.toHaveProperty('langfuse.secretKeyPreview'); expect(savedOverrides.langfuse).toEqual({ publicKey: 'pk-lf-1' }); const responseConfig = res.body!.config as { overrides: { langfuse: Record }; @@ -837,7 +837,7 @@ describe('createAdminConfigHandlers', () => { expect(deps.unsetConfigField).toHaveBeenCalledWith( 'role', 'admin', - 'langfuse.displaySecretKey', + 'langfuse.secretKeyPreview', ); }); @@ -845,7 +845,7 @@ describe('createAdminConfigHandlers', () => { const { handlers, deps } = createHandlers(); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, - query: { fieldPath: 'langfuse.displaySecretKey' }, + query: { fieldPath: 'langfuse.secretKeyPreview' }, }); const res = mockRes(); @@ -947,7 +947,7 @@ describe('createAdminConfigHandlers', () => { 'role', 'admin', expect.anything(), - 'langfuse.displaySecretKey', + 'langfuse.secretKeyPreview', 10, ); }); @@ -956,7 +956,7 @@ describe('createAdminConfigHandlers', () => { const { handlers, deps } = createHandlers(); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, - body: { fieldPath: 'langfuse.displaySecretKey' }, + body: { fieldPath: 'langfuse.secretKeyPreview' }, }); const res = mockRes(); @@ -1054,7 +1054,7 @@ describe('createAdminConfigHandlers', () => { expect(patchedFields['interface.modelSelect']).toBe(false); }); - it('clears stale Langfuse display secret keys when clearing a secret', async () => { + it('clears stale Langfuse secret previews when clearing a secret', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, @@ -1069,7 +1069,7 @@ describe('createAdminConfigHandlers', () => { expect(res.statusCode).toBe(200); const patchedFields = deps.patchConfigFields.mock.calls[0][3]; expect(patchedFields['langfuse.secretKey']).toBe(''); - expect(patchedFields['langfuse.displaySecretKey']).toBe(''); + expect(patchedFields['langfuse.secretKeyPreview']).toBe(''); }); it('encrypts Langfuse secret keys inside object-valued patch entries', async () => { @@ -1096,7 +1096,7 @@ describe('createAdminConfigHandlers', () => { const patchedFields = deps.patchConfigFields.mock.calls[0][3]; expect(patchedFields.langfuse.secretKey).toMatch(/^v3:/); expect(patchedFields.langfuse.secretKey).not.toBe('sk-lf-secret'); - expect(patchedFields.langfuse.displaySecretKey).toBe('sk-lf-...cret'); + expect(patchedFields.langfuse.secretKeyPreview).toBe('sk-lf-...cret'); }); it('preserves existing encrypted Langfuse secrets on object-valued patch entries when omitted', async () => { @@ -1108,7 +1108,7 @@ describe('createAdminConfigHandlers', () => { langfuse: { publicKey: 'pk-old', secretKey: 'v3:test:sk-old', - displaySecretKey: 'sk-old...-old', + secretKeyPreview: 'sk-old...-old', }, }, }), @@ -1138,7 +1138,7 @@ describe('createAdminConfigHandlers', () => { publicKey: 'pk-new', destination: 'eu', secretKey: 'v3:test:sk-old', - displaySecretKey: 'sk-old...-old', + secretKeyPreview: 'sk-old...-old', }); expect(deps.findConfigByPrincipal).toHaveBeenCalled(); }); @@ -1151,7 +1151,7 @@ describe('createAdminConfigHandlers', () => { overrides: { langfuse: { secretKey: 'v3:test:sk-old', - displaySecretKey: 'sk-old...-old', + secretKeyPreview: 'sk-old...-old', }, }, }), @@ -1179,7 +1179,7 @@ describe('createAdminConfigHandlers', () => { expect(patchedFields.langfuse).toEqual({ publicKey: 'pk-new', secretKey: '', - displaySecretKey: '', + secretKeyPreview: '', }); }); @@ -1219,7 +1219,7 @@ describe('createAdminConfigHandlers', () => { expect(res.statusCode).toBe(200); const patchedFields = deps.patchConfigFields.mock.calls[0][3]; expect(patchedFields['langfuse.secretKey']).toBe(''); - expect(patchedFields['langfuse.displaySecretKey']).toBe(''); + expect(patchedFields['langfuse.secretKeyPreview']).toBe(''); }); it('rejects direct display secret key patch entries', async () => { @@ -1227,7 +1227,7 @@ describe('createAdminConfigHandlers', () => { const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, body: { - entries: [{ fieldPath: 'langfuse.displaySecretKey', value: 'spoofed' }], + entries: [{ fieldPath: 'langfuse.secretKeyPreview', value: 'spoofed' }], }, }); const res = mockRes(); @@ -1270,12 +1270,12 @@ describe('createAdminConfigHandlers', () => { expect(deps.patchConfigFields).not.toHaveBeenCalled(); }); - it('rejects patch entries below protected Langfuse displaySecretKey paths', async () => { + it('rejects patch entries below protected Langfuse secretKeyPreview paths', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, body: { - entries: [{ fieldPath: 'langfuse.displaySecretKey.hidden', value: 'spoofed' }], + entries: [{ fieldPath: 'langfuse.secretKeyPreview.hidden', value: 'spoofed' }], }, }); const res = mockRes(); @@ -2294,13 +2294,13 @@ describe('createAdminConfigHandlers', () => { langfuse: { publicKey: 'pk-lf-1', secretKey: 'sk-lf-secret', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', }, config: { langfuse: { publicKey: 'pk-lf-1', secretKey: 'sk-lf-raw-secret', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', }, }, }), @@ -2317,11 +2317,11 @@ describe('createAdminConfigHandlers', () => { }; expect(responseConfig.langfuse).toEqual({ publicKey: 'pk-lf-1', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', }); expect(responseConfig.config.langfuse).toEqual({ publicKey: 'pk-lf-1', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', }); }); diff --git a/packages/api/src/admin/config.spec.ts b/packages/api/src/admin/config.spec.ts index 3298cb5faa..cee1246c20 100644 --- a/packages/api/src/admin/config.spec.ts +++ b/packages/api/src/admin/config.spec.ts @@ -43,6 +43,13 @@ describe('isValidFieldPath', () => { expect(isValidFieldPath('prototypeChain')).toBe(true); expect(isValidFieldPath('a.myConstructor')).toBe(true); }); + + it('rejects MongoDB operator segments', () => { + expect(isValidFieldPath('webSearch.$[].serperApiKey')).toBe(false); + expect(isValidFieldPath('speech.tts.$.apiKey')).toBe(false); + expect(isValidFieldPath('a.$set')).toBe(false); + expect(isValidFieldPath('$')).toBe(false); + }); }); describe('getTopLevelSection', () => { diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index 8fad76708f..f5709b8b19 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -17,6 +17,7 @@ import { encryptConfigSecrets, getConfigSecretMutationPaths, getConfigSecretInputError, + getConfigSecretSections, isConfigSecretAncestorPath, isConfigSecretDescendantPath, preserveConfigSecrets, @@ -35,6 +36,7 @@ export function isValidFieldPath(path: string): boolean { !path.startsWith('.') && !path.endsWith('.') && !path.includes('..') && + !path.includes('$') && !UNSAFE_SEGMENTS.test(path) ); } @@ -319,7 +321,7 @@ function redactAppConfigForResponse(appConfig: AppConfig): AppConfig { return safeConfig; } -function isObjectValuedLangfusePatch(fieldPath: string, value: unknown): boolean { +function isObjectValuedSecretAncestorPatch(fieldPath: string, value: unknown): boolean { return ( isConfigSecretAncestorPath(fieldPath) && value != null && @@ -334,7 +336,7 @@ function preservePatchedConfigSecretFields( ): Record { const result = { ...fields }; for (const [fieldPath, value] of Object.entries(result)) { - if (isObjectValuedLangfusePatch(fieldPath, value)) { + if (isObjectValuedSecretAncestorPatch(fieldPath, value)) { result[fieldPath] = preserveConfigSecrets(value, existingOverrides, fieldPath); } } @@ -595,18 +597,22 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { ? { expectEmpty: false } : { expectEmpty: true, preservePriority: true }; - const langfuseInputError = getConfigSecretInputError( - 'langfuse', - (filteredOverrides as Record).langfuse, - ); - if (langfuseInputError) { - return res.status(400).json({ error: langfuseInputError }); + for (const section of getConfigSecretSections()) { + const secretInputError = getConfigSecretInputError( + section, + (filteredOverrides as Record)[section], + ); + if (secretInputError) { + return res.status(400).json({ error: secretInputError }); + } } const encryptedOverrides = encryptConfigSecrets(filteredOverrides); - const existingForSecrets = isObjectValuedLangfusePatch( - 'langfuse', - (filteredOverrides as Record).langfuse, + const existingForSecrets = getConfigSecretSections().some((section) => + isObjectValuedSecretAncestorPatch( + section, + (filteredOverrides as Record)[section], + ), ) ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) : null; @@ -754,11 +760,11 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { } const requestedPriority = hasBroadManage ? priority : undefined; - const hasObjectValuedLangfusePatch = Object.entries(fields).some(([fieldPath, value]) => - isObjectValuedLangfusePatch(fieldPath, value), + const hasObjectValuedSecretPatch = Object.entries(fields).some(([fieldPath, value]) => + isObjectValuedSecretAncestorPatch(fieldPath, value), ); const existing = - requestedPriority == null || hasObjectValuedLangfusePatch + requestedPriority == null || hasObjectValuedSecretPatch ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) : null; const encryptedFields = encryptConfigSecretFields(fields); diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index 52f0d68e71..1a572804e2 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -5,6 +5,7 @@ export { createAdminRolesHandlers } from './roles'; export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills'; export { createAdminUsersHandlers } from './users'; export { createAdminAuditLogHandlers } from './auditLog'; +export { resolveConfigSecret } from './secrets'; export type { AdminConfigDeps } from './config'; export type { AdminGrantsDeps, GrantPrincipalType } from './grants'; export type { AdminGroupsDeps } from './groups'; diff --git a/packages/api/src/admin/secrets.integration.spec.ts b/packages/api/src/admin/secrets.integration.spec.ts new file mode 100644 index 0000000000..7b4a1ec2c2 --- /dev/null +++ b/packages/api/src/admin/secrets.integration.spec.ts @@ -0,0 +1,598 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types/http'; + +process.env.CREDS_KEY = + process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +process.env.CREDS_IV = process.env.CREDS_IV ?? '0123456789abcdef0123456789abcdef'; + +type DataSchemas = typeof import('@librechat/data-schemas'); +type AdminConfigHandlers = ReturnType; + +let mongoServer: MongoMemoryServer; +let handlers: AdminConfigHandlers; +let decryptV3: DataSchemas['decryptV3']; +let getSecretPreview: typeof import('./secrets').getSecretPreview; + +interface SecretFieldCase { + /** Dot-path of the secret field */ + path: string; + /** Dot-path of the non-secret masked preview companion for `path` */ + previewPath: string; + /** Section object containing the secret plus a non-secret sibling */ + section: string; + object: Record; + /** Dot-path of a non-secret sibling used for unrelated writes */ + siblingPath: string; + siblingValue: unknown; +} + +const SECRET = 'sk-super-secret-literal'; + +const SECRET_FIELD_CASES: SecretFieldCase[] = [ + { + path: 'langfuse.secretKey', + previewPath: 'langfuse.secretKeyPreview', + section: 'langfuse', + object: { publicKey: 'pk-lf-1', secretKey: SECRET }, + siblingPath: 'langfuse.publicKey', + siblingValue: 'pk-lf-2', + }, + { + path: 'ocr.apiKey', + previewPath: 'ocr.apiKeyPreview', + section: 'ocr', + object: { apiKey: SECRET, mistralModel: 'mistral-ocr-latest' }, + siblingPath: 'ocr.mistralModel', + siblingValue: 'mistral-ocr-next', + }, + { + path: 'speech.tts.openai.apiKey', + previewPath: 'speech.tts.openai.apiKeyPreview', + section: 'speech', + object: { tts: { openai: { apiKey: SECRET, model: 'tts-1', voices: ['alloy'] } } }, + siblingPath: 'speech.tts.openai.model', + siblingValue: 'tts-2', + }, + { + path: 'speech.tts.azureOpenAI.apiKey', + previewPath: 'speech.tts.azureOpenAI.apiKeyPreview', + section: 'speech', + object: { tts: { azureOpenAI: { apiKey: SECRET, instanceName: 'inst' } } }, + siblingPath: 'speech.tts.azureOpenAI.instanceName', + siblingValue: 'inst-2', + }, + { + path: 'speech.tts.elevenlabs.apiKey', + previewPath: 'speech.tts.elevenlabs.apiKeyPreview', + section: 'speech', + object: { tts: { elevenlabs: { apiKey: SECRET, model: 'eleven_multilingual_v2' } } }, + siblingPath: 'speech.tts.elevenlabs.model', + siblingValue: 'eleven_turbo_v2', + }, + { + path: 'speech.tts.localai.apiKey', + previewPath: 'speech.tts.localai.apiKeyPreview', + section: 'speech', + object: { tts: { localai: { apiKey: SECRET, url: 'http://localai:8080' } } }, + siblingPath: 'speech.tts.localai.url', + siblingValue: 'http://localai:8081', + }, + { + path: 'speech.stt.openai.apiKey', + previewPath: 'speech.stt.openai.apiKeyPreview', + section: 'speech', + object: { stt: { openai: { apiKey: SECRET, model: 'whisper-1' } } }, + siblingPath: 'speech.stt.openai.model', + siblingValue: 'whisper-2', + }, + { + path: 'speech.stt.azureOpenAI.apiKey', + previewPath: 'speech.stt.azureOpenAI.apiKeyPreview', + section: 'speech', + object: { stt: { azureOpenAI: { apiKey: SECRET, instanceName: 'inst' } } }, + siblingPath: 'speech.stt.azureOpenAI.instanceName', + siblingValue: 'inst-2', + }, + { + path: 'webSearch.serperApiKey', + previewPath: 'webSearch.serperApiKeyPreview', + section: 'webSearch', + object: { serperApiKey: SECRET, searchProvider: 'serper' }, + siblingPath: 'webSearch.searchProvider', + siblingValue: 'serper', + }, + { + path: 'webSearch.searxngApiKey', + previewPath: 'webSearch.searxngApiKeyPreview', + section: 'webSearch', + object: { searxngApiKey: SECRET, searxngInstanceUrl: 'https://searx.example.com' }, + siblingPath: 'webSearch.searxngInstanceUrl', + siblingValue: 'https://searx2.example.com', + }, + { + path: 'webSearch.firecrawlApiKey', + previewPath: 'webSearch.firecrawlApiKeyPreview', + section: 'webSearch', + object: { firecrawlApiKey: SECRET, firecrawlApiUrl: 'https://api.firecrawl.dev' }, + siblingPath: 'webSearch.firecrawlApiUrl', + siblingValue: 'https://api2.firecrawl.dev', + }, + { + path: 'webSearch.tavilyApiKey', + previewPath: 'webSearch.tavilyApiKeyPreview', + section: 'webSearch', + object: { tavilyApiKey: SECRET, scraperTimeout: 7500 }, + siblingPath: 'webSearch.scraperTimeout', + siblingValue: 8000, + }, + { + path: 'webSearch.jinaApiKey', + previewPath: 'webSearch.jinaApiKeyPreview', + section: 'webSearch', + object: { jinaApiKey: SECRET, jinaApiUrl: 'https://r.jina.ai' }, + siblingPath: 'webSearch.jinaApiUrl', + siblingValue: 'https://r2.jina.ai', + }, + { + path: 'webSearch.cohereApiKey', + previewPath: 'webSearch.cohereApiKeyPreview', + section: 'webSearch', + object: { cohereApiKey: SECRET, rerankerType: 'cohere' }, + siblingPath: 'webSearch.rerankerType', + siblingValue: 'cohere', + }, + { + path: 'endpoints.assistants.apiKey', + previewPath: 'endpoints.assistants.apiKeyPreview', + section: 'endpoints', + object: { assistants: { apiKey: SECRET, disableBuilder: true } }, + siblingPath: 'endpoints.assistants.disableBuilder', + siblingValue: false, + }, + { + path: 'endpoints.azureAssistants.apiKey', + previewPath: 'endpoints.azureAssistants.apiKeyPreview', + section: 'endpoints', + object: { azureAssistants: { apiKey: SECRET, disableBuilder: true } }, + siblingPath: 'endpoints.azureAssistants.disableBuilder', + siblingValue: false, + }, +]; + +/** Fields whose values conventionally hold `${ENV_VAR}` placeholder references. */ +const PLACEHOLDER_CASES = [ + { path: 'ocr.apiKey', placeholder: '${OCR_API_KEY}' }, + { path: 'speech.tts.openai.apiKey', placeholder: '${TTS_API_KEY}' }, + { path: 'webSearch.serperApiKey', placeholder: '${SERPER_API_KEY}' }, + { path: 'endpoints.assistants.apiKey', placeholder: '${ASSISTANTS_API_KEY}' }, +]; + +function mockReq(overrides: Record = {}): ServerRequest { + return { + user: { id: 'u1', role: 'ADMIN', _id: { toString: () => 'u1' } }, + params: {}, + body: {}, + query: {}, + ...overrides, + } as Partial as ServerRequest; +} + +interface MockRes { + statusCode: number; + body: undefined | { config?: Record; error?: string; [key: string]: unknown }; + status: jest.Mock; + json: jest.Mock; +} + +function mockRes(): Response & MockRes { + const res: MockRes = { + statusCode: 200, + body: undefined, + status: jest.fn((code: number) => { + res.statusCode = code; + return res; + }), + json: jest.fn((data: MockRes['body']) => { + res.body = data; + return res; + }), + }; + return res as Partial as Response & MockRes; +} + +function getAtPath(root: unknown, path: string): unknown { + let cursor: unknown = root; + for (const segment of path.split('.')) { + if (cursor == null || typeof cursor !== 'object') { + return undefined; + } + cursor = (cursor as Record)[segment]; + } + return cursor; +} + +async function readRawOverrides(principalId: string): Promise> { + const doc = await mongoose.models.Config.findOne({ principalId }); + expect(doc).not.toBeNull(); + expect(doc!.$isNew).toBe(false); + return (doc!.toObject() as { overrides: Record }).overrides; +} + +let principalCounter = 0; +function nextPrincipalId(): string { + principalCounter += 1; + return `admin-${principalCounter}`; +} + +beforeAll(async () => { + jest.resetModules(); + const dataSchemas = await import('@librechat/data-schemas'); + ({ decryptV3 } = dataSchemas); + jest.spyOn(dataSchemas.logger, 'error').mockReturnValue(dataSchemas.logger); + jest.spyOn(dataSchemas.logger, 'warn').mockReturnValue(dataSchemas.logger); + jest.spyOn(dataSchemas.logger, 'info').mockReturnValue(dataSchemas.logger); + jest.spyOn(dataSchemas.logger, 'debug').mockReturnValue(dataSchemas.logger); + + const { createAdminConfigHandlers } = await import('./config'); + ({ getSecretPreview } = await import('./secrets')); + + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + dataSchemas.createModels(mongoose); + const methods = dataSchemas.createMethods(mongoose); + + handlers = createAdminConfigHandlers({ + listAllConfigs: methods.listAllConfigs, + findConfigByPrincipal: methods.findConfigByPrincipal, + upsertConfig: methods.upsertConfig, + patchConfigFields: methods.patchConfigFields, + tombstoneConfigField: methods.tombstoneConfigField, + unsetConfigField: methods.unsetConfigField, + deleteConfig: methods.deleteConfig, + toggleConfigActive: methods.toggleConfigActive, + hasConfigCapability: async () => true, + hasAnyConfigReadAccess: async () => true, + hasCapability: async () => true, + }); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +describe('config secret registry — real handlers against a real Config collection', () => { + describe.each(SECRET_FIELD_CASES)( + '$path', + ({ path, previewPath, section, object, siblingPath, siblingValue }) => { + it('encrypts dotted patch writes at rest, sets the masked preview companion, and redacts the secret from the response', async () => { + const principalId = nextPrincipalId(); + const res = mockRes(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: path, value: SECRET }] }, + }), + res, + ); + expect(res.statusCode).toBe(200); + expect(JSON.stringify(res.body)).not.toContain(SECRET); + + const overrides = await readRawOverrides(principalId); + const stored = getAtPath(overrides, path); + expect(typeof stored).toBe('string'); + expect(stored).toMatch(/^v3:/); + expect(decryptV3(stored as string)).toBe(SECRET); + expect(getAtPath(overrides, previewPath)).toBe(getSecretPreview(SECRET)); + + const responseOverrides = (res.body!.config as { overrides: Record }) + .overrides; + expect(getAtPath(responseOverrides, path)).toBeUndefined(); + expect(getAtPath(responseOverrides, previewPath)).toBe(getSecretPreview(SECRET)); + }); + + it('encrypts object-valued upsert writes at rest, sets the masked preview companion, and redacts reads', async () => { + const principalId = nextPrincipalId(); + const upsertRes = mockRes(); + await handlers.upsertConfigOverrides( + mockReq({ + params: { principalType: 'role', principalId }, + body: { overrides: { [section]: object } }, + }), + upsertRes, + ); + expect(upsertRes.statusCode).toBe(201); + expect(JSON.stringify(upsertRes.body)).not.toContain(SECRET); + + const overrides = await readRawOverrides(principalId); + expect(decryptV3(getAtPath(overrides, path) as string)).toBe(SECRET); + expect(getAtPath(overrides, previewPath)).toBe(getSecretPreview(SECRET)); + + const getRes = mockRes(); + await handlers.getConfig( + mockReq({ params: { principalType: 'role', principalId } }), + getRes, + ); + expect(getRes.statusCode).toBe(200); + expect(JSON.stringify(getRes.body)).not.toContain(SECRET); + expect(JSON.stringify(getRes.body)).not.toContain('v3:'); + const getOverrides = (getRes.body!.config as { overrides: Record }) + .overrides; + expect(getAtPath(getOverrides, path)).toBeUndefined(); + expect(getAtPath(getOverrides, previewPath)).toBe(getSecretPreview(SECRET)); + + const listRes = mockRes(); + await handlers.listConfigs(mockReq(), listRes); + expect(listRes.statusCode).toBe(200); + expect(JSON.stringify(listRes.body)).not.toContain(SECRET); + expect(JSON.stringify(listRes.body)).not.toContain('v3:'); + }); + + it('preserves the stored secret and its preview companion across an unrelated dotted patch', async () => { + const principalId = nextPrincipalId(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: path, value: SECRET }] }, + }), + mockRes(), + ); + const rawBefore = await readRawOverrides(principalId); + const before = getAtPath(rawBefore, path); + const displayBefore = getAtPath(rawBefore, previewPath); + expect(displayBefore).toBe(getSecretPreview(SECRET)); + + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: siblingPath, value: siblingValue }] }, + }), + mockRes(), + ); + + const overrides = await readRawOverrides(principalId); + expect(getAtPath(overrides, path)).toBe(before); + expect(decryptV3(getAtPath(overrides, path) as string)).toBe(SECRET); + expect(getAtPath(overrides, previewPath)).toBe(displayBefore); + expect(getAtPath(overrides, siblingPath)).toEqual(siblingValue); + }); + + it('round-trips a redacted read (including the visible preview companion) back through a full upsert without clobbering the secret', async () => { + const principalId = nextPrincipalId(); + await handlers.upsertConfigOverrides( + mockReq({ + params: { principalType: 'role', principalId }, + body: { overrides: { [section]: object } }, + }), + mockRes(), + ); + const rawBefore = await readRawOverrides(principalId); + const before = getAtPath(rawBefore, path); + const displayBefore = getAtPath(rawBefore, previewPath); + + const getRes = mockRes(); + await handlers.getConfig( + mockReq({ params: { principalType: 'role', principalId } }), + getRes, + ); + const redactedOverrides = (getRes.body!.config as { overrides: Record }) + .overrides; + expect(getAtPath(redactedOverrides, path)).toBeUndefined(); + expect(getAtPath(redactedOverrides, previewPath)).toBe(displayBefore); + + const clientEdited = JSON.parse(JSON.stringify(redactedOverrides)) as Record< + string, + unknown + >; + const upsertRes = mockRes(); + await handlers.upsertConfigOverrides( + mockReq({ + params: { principalType: 'role', principalId }, + body: { overrides: clientEdited }, + }), + upsertRes, + ); + expect(upsertRes.statusCode).toBe(200); + expect(JSON.stringify(upsertRes.body)).not.toContain(SECRET); + + const overrides = await readRawOverrides(principalId); + expect(getAtPath(overrides, path)).toBe(before); + expect(decryptV3(getAtPath(overrides, path) as string)).toBe(SECRET); + expect(getAtPath(overrides, previewPath)).toBe(displayBefore); + }); + + it('clears the secret and its preview companion when explicitly set to an empty value', async () => { + const principalId = nextPrincipalId(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: path, value: SECRET }] }, + }), + mockRes(), + ); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: path, value: '' }] }, + }), + mockRes(), + ); + const overrides = await readRawOverrides(principalId); + expect(getAtPath(overrides, path)).toBe(''); + expect(getAtPath(overrides, previewPath)).toBe(''); + }); + + it('rejects encrypted value submissions', async () => { + const res = mockRes(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId: nextPrincipalId() }, + body: { entries: [{ fieldPath: path, value: 'v3:attacker-controlled' }] }, + }), + res, + ); + expect(res.statusCode).toBe(400); + }); + + it('rejects a direct dotted-patch write to the preview companion path itself', async () => { + const principalId = nextPrincipalId(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: path, value: SECRET }] }, + }), + mockRes(), + ); + + const res = mockRes(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: previewPath, value: 'attacker-supplied-display' }] }, + }), + res, + ); + expect(res.statusCode).toBe(400); + + const overrides = await readRawOverrides(principalId); + expect(decryptV3(getAtPath(overrides, path) as string)).toBe(SECRET); + expect(getAtPath(overrides, previewPath)).toBe(getSecretPreview(SECRET)); + }); + + it('never persists a client-supplied display value as the real secret via an object-valued upsert', async () => { + const principalId = nextPrincipalId(); + const spoofedObject = JSON.parse(JSON.stringify(object)) as Record; + const objectPathSegments = path.slice(section.length + 1).split('.'); + let cursor = spoofedObject; + for (let i = 0; i < objectPathSegments.length - 1; i++) { + cursor = cursor[objectPathSegments[i]] as Record; + } + const secretKey = objectPathSegments[objectPathSegments.length - 1]; + const previewKey = previewPath.split('.').slice(-1)[0]; + delete cursor[secretKey]; + cursor[previewKey] = 'v3:attacker-supplied-looks-encrypted'; + + const res = mockRes(); + await handlers.upsertConfigOverrides( + mockReq({ + params: { principalType: 'role', principalId }, + body: { overrides: { [section]: spoofedObject } }, + }), + res, + ); + expect(res.statusCode).toBe(201); + + const overrides = await readRawOverrides(principalId); + expect(getAtPath(overrides, path)).toBeUndefined(); + expect(getAtPath(overrides, previewPath)).toBeUndefined(); + }); + }, + ); + + describe.each(PLACEHOLDER_CASES)('$path env placeholder', ({ path, placeholder }) => { + it('stores and returns `${ENV_VAR}` references without encryption or redaction', async () => { + const principalId = nextPrincipalId(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'role', principalId }, + body: { entries: [{ fieldPath: path, value: placeholder }] }, + }), + mockRes(), + ); + + const overrides = await readRawOverrides(principalId); + expect(getAtPath(overrides, path)).toBe(placeholder); + + const getRes = mockRes(); + await handlers.getConfig(mockReq({ params: { principalType: 'role', principalId } }), getRes); + const responseOverrides = (getRes.body!.config as { overrides: Record }) + .overrides; + expect(getAtPath(responseOverrides, path)).toBe(placeholder); + }); + }); + + describe('legacy plaintext literals stored before encryption existed', () => { + it('never returns a plaintext literal stored directly on the config document', async () => { + const principalId = nextPrincipalId(); + await mongoose.models.Config.create({ + principalType: 'role', + principalId, + principalModel: 'Role', + priority: 10, + overrides: { + speech: { tts: { openai: { apiKey: SECRET, model: 'tts-1' } } }, + ocr: { apiKey: SECRET }, + webSearch: { serperApiKey: SECRET, searchProvider: 'serper' }, + }, + }); + + const getRes = mockRes(); + await handlers.getConfig(mockReq({ params: { principalType: 'role', principalId } }), getRes); + expect(getRes.statusCode).toBe(200); + expect(JSON.stringify(getRes.body)).not.toContain(SECRET); + const overrides = (getRes.body!.config as { overrides: Record }).overrides; + expect(getAtPath(overrides, 'speech.tts.openai.model')).toBe('tts-1'); + expect(getAtPath(overrides, 'webSearch.searchProvider')).toBe('serper'); + // Documents written before this field existed have no preview companion at all. + // Redaction must not fabricate one — it only ever copies forward a companion + // that a prior encrypt actually wrote. + expect(getAtPath(overrides, 'speech.tts.openai.apiKeyPreview')).toBeUndefined(); + expect(getAtPath(overrides, 'ocr.apiKeyPreview')).toBeUndefined(); + expect(getAtPath(overrides, 'webSearch.serperApiKeyPreview')).toBeUndefined(); + + const listRes = mockRes(); + await handlers.listConfigs(mockReq(), listRes); + expect(JSON.stringify(listRes.body)).not.toContain(SECRET); + }); + }); + + describe('getBaseConfig', () => { + it('redacts literal secrets sourced from the resolved AppConfig (e.g. YAML literals)', async () => { + const appConfig = { + speech: { + tts: { + openai: { apiKey: SECRET, apiKeyPreview: getSecretPreview(SECRET), model: 'tts-1' }, + }, + }, + ocr: { apiKey: '${OCR_API_KEY}' }, + webSearch: { serperApiKey: SECRET, searchProvider: 'serper' }, + langfuse: { publicKey: 'pk-lf-1', secretKey: 'v3:stored', secretKeyPreview: 'sk-...ret' }, + paths: { uploads: '/tmp' }, + config: { + speech: { tts: { openai: { apiKey: SECRET, model: 'tts-1' } } }, + }, + }; + const { createAdminConfigHandlers } = await import('./config'); + const baseHandlers = createAdminConfigHandlers({ + listAllConfigs: async () => [], + findConfigByPrincipal: async () => null, + upsertConfig: async () => null, + patchConfigFields: async () => null, + tombstoneConfigField: async () => null, + unsetConfigField: async () => null, + deleteConfig: async () => null, + toggleConfigActive: async () => null, + hasConfigCapability: async () => true, + hasAnyConfigReadAccess: async () => true, + hasCapability: async () => true, + getAppConfig: async () => appConfig as never, + }); + + const res = mockRes(); + await baseHandlers.getBaseConfig(mockReq(), res); + expect(res.statusCode).toBe(200); + const payload = JSON.stringify(res.body); + expect(payload).not.toContain(SECRET); + expect(payload).not.toContain('v3:stored'); + const config = res.body!.config as Record; + expect(getAtPath(config, 'ocr.apiKey')).toBe('${OCR_API_KEY}'); + expect(getAtPath(config, 'speech.tts.openai.model')).toBe('tts-1'); + expect(getAtPath(config, 'speech.tts.openai.apiKey')).toBeUndefined(); + expect(getAtPath(config, 'speech.tts.openai.apiKeyPreview')).toBe(getSecretPreview(SECRET)); + expect(getAtPath(config, 'langfuse.secretKeyPreview')).toBe('sk-...ret'); + expect(getAtPath(config, 'config.speech.tts.openai.apiKey')).toBeUndefined(); + }); + }); +}); diff --git a/packages/api/src/admin/secrets.spec.ts b/packages/api/src/admin/secrets.spec.ts index 0296f38385..dfbfa3595e 100644 --- a/packages/api/src/admin/secrets.spec.ts +++ b/packages/api/src/admin/secrets.spec.ts @@ -6,9 +6,15 @@ process.env.CREDS_KEY = let decryptConfigSecret: typeof import('./secrets').decryptConfigSecret; let encryptConfigSecretFields: typeof import('./secrets').encryptConfigSecretFields; let encryptConfigSecrets: typeof import('./secrets').encryptConfigSecrets; +let getSecretPreview: typeof import('./secrets').getSecretPreview; let getConfigSecretInputError: typeof import('./secrets').getConfigSecretInputError; +let getConfigSecretMutationPaths: typeof import('./secrets').getConfigSecretMutationPaths; +let getConfigSecretSections: typeof import('./secrets').getConfigSecretSections; +let isConfigSecretAncestorPath: typeof import('./secrets').isConfigSecretAncestorPath; +let isConfigSecretDescendantPath: typeof import('./secrets').isConfigSecretDescendantPath; let preserveConfigSecrets: typeof import('./secrets').preserveConfigSecrets; let redactConfigSecrets: typeof import('./secrets').redactConfigSecrets; +let resolveConfigSecret: typeof import('./secrets').resolveConfigSecret; let decryptV3: typeof import('@librechat/data-schemas').decryptV3; beforeAll(async () => { @@ -16,9 +22,15 @@ beforeAll(async () => { decryptConfigSecret, encryptConfigSecretFields, encryptConfigSecrets, + getSecretPreview, getConfigSecretInputError, + getConfigSecretMutationPaths, + getConfigSecretSections, + isConfigSecretAncestorPath, + isConfigSecretDescendantPath, preserveConfigSecrets, redactConfigSecrets, + resolveConfigSecret, } = await import('./secrets')); ({ decryptV3 } = await import('@librechat/data-schemas')); }); @@ -32,49 +44,49 @@ describe('Langfuse config secrets', () => { expect(out['langfuse.secretKey']).toMatch(/^v3:/); expect(decryptV3(out['langfuse.secretKey'] as string)).toBe('sk-lf-secret'); - expect(out['langfuse.displaySecretKey']).toBe('sk-lf-...cret'); + expect(out['langfuse.secretKeyPreview']).toBe('sk-lf-...cret'); expect(out['langfuse.publicKey']).toBe('pk-lf-1'); }); - it('encrypts object writes and removes client-supplied display secret keys', () => { + it('encrypts object writes and removes client-supplied secret previews', () => { const out = encryptConfigSecrets({ langfuse: { publicKey: 'pk-lf-1', secretKey: 'sk-lf-secret', - displaySecretKey: 'spoofed', + secretKeyPreview: 'spoofed', }, }); expect(out.langfuse.secretKey).toMatch(/^v3:/); expect(decryptV3(out.langfuse.secretKey)).toBe('sk-lf-secret'); - expect(out.langfuse.displaySecretKey).toBe('sk-lf-...cret'); + expect(out.langfuse.secretKeyPreview).toBe('sk-lf-...cret'); expect(out.langfuse.publicKey).toBe('pk-lf-1'); }); it('clears empty or non-string secret values', () => { expect(encryptConfigSecretFields({ 'langfuse.secretKey': '' })).toEqual({ 'langfuse.secretKey': '', - 'langfuse.displaySecretKey': '', + 'langfuse.secretKeyPreview': '', }); expect( encryptConfigSecrets({ langfuse: { secretKey: null, - displaySecretKey: 'spoofed', + secretKeyPreview: 'spoofed', }, }), ).toEqual({ langfuse: { secretKey: '', - displaySecretKey: '', + secretKeyPreview: '', }, }); }); it('rejects protected display-key writes and encrypted secret submissions', () => { - expect(getConfigSecretInputError('langfuse.displaySecretKey', 'spoofed')).toContain( - 'protected display secret path', + expect(getConfigSecretInputError('langfuse.secretKeyPreview', 'spoofed')).toContain( + 'protected secret preview path', ); expect(getConfigSecretInputError('langfuse.secretKey', 'v3:attacker-controlled')).toContain( 'Encrypted config secret values', @@ -114,11 +126,11 @@ describe('Langfuse config secrets', () => { const existingLangfuse = existing.langfuse as Record; expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-old'); - expect(preservedLangfuse.displaySecretKey).toBe(existingLangfuse.displaySecretKey); + expect(preservedLangfuse.secretKeyPreview).toBe(existingLangfuse.secretKeyPreview); expect(preserved.langfuse.publicKey).toBe('pk-new'); }); - it('does not preserve plaintext existing secrets or explicitly cleared secrets', () => { + it('migrates a legacy plaintext existing secret by encrypting it, and drops explicitly cleared secrets', () => { const next = encryptConfigSecrets({ langfuse: { publicKey: 'pk-new', @@ -131,7 +143,10 @@ describe('Langfuse config secrets', () => { secretKey: 'sk-plain-existing', }, }); - expect(fromPlaintext.langfuse).toEqual({ publicKey: 'pk-new' }); + const preservedLangfuse = fromPlaintext.langfuse as Record; + expect(preservedLangfuse.publicKey).toBe('pk-new'); + expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-plain-existing'); + expect(preservedLangfuse.secretKeyPreview).toBe(getSecretPreview('sk-plain-existing')); const existing = encryptConfigSecrets({ langfuse: { @@ -146,7 +161,7 @@ describe('Langfuse config secrets', () => { expect(preserveConfigSecrets(cleared, existing)).toEqual({ langfuse: { secretKey: '', - displaySecretKey: '', + secretKeyPreview: '', }, }); }); @@ -164,30 +179,472 @@ describe('Langfuse config secrets', () => { const existingLangfuse = existing.langfuse as Record; expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-old'); - expect(preservedLangfuse.displaySecretKey).toBe(existingLangfuse.displaySecretKey); + expect(preservedLangfuse.secretKeyPreview).toBe(existingLangfuse.secretKeyPreview); expect(preserved.publicKey).toBe('pk-new'); }); - it('redacts secret values while preserving display secret keys', () => { + it('redacts secret values while preserving secret previews', () => { const redacted = redactConfigSecrets({ 'langfuse.secretKey': 'literal', - 'langfuse.displaySecretKey': 'literal-display', + 'langfuse.secretKeyPreview': 'literal-display', langfuse: { enabled: true, destination: 'eu', publicKey: 'pk-lf-1', secretKey: 'v3:abc:def', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', }, }); expect(redacted['langfuse.secretKey']).toBeUndefined(); - expect(redacted['langfuse.displaySecretKey']).toBeUndefined(); + expect(redacted['langfuse.secretKeyPreview']).toBeUndefined(); expect(redacted.langfuse).toEqual({ enabled: true, destination: 'eu', publicKey: 'pk-lf-1', - displaySecretKey: 'sk-lf-...cret', + secretKeyPreview: 'sk-lf-...cret', + }); + }); + + it('strips legacy displaySecretKey companions and migrates them on preserve', () => { + const redacted = redactConfigSecrets({ + langfuse: { publicKey: 'pk-lf-1', secretKey: 'v3:abc:def', displaySecretKey: 'sk-lf-...old' }, + }); + expect(redacted.langfuse).toEqual({ publicKey: 'pk-lf-1', secretKeyPreview: 'sk-lf-...old' }); + + const alreadyMigrated = redactConfigSecrets({ + langfuse: { + secretKey: 'v3:abc:def', + secretKeyPreview: 'sk-lf-...new', + displaySecretKey: 'sk-lf-...old', + }, + }); + expect(alreadyMigrated.langfuse).toEqual({ secretKeyPreview: 'sk-lf-...new' }); + + const encrypted = encryptConfigSecrets({ + langfuse: { secretKey: 'sk-lf-new-secret', displaySecretKey: 'sk-lf-...old' }, + }).langfuse as Record; + expect(encrypted.displaySecretKey).toBeUndefined(); + expect(encrypted.secretKeyPreview).toBe('sk-lf-...cret'); + + const existing = { + langfuse: { + secretKey: encryptConfigSecrets({ langfuse: { secretKey: 'sk-lf-old-secret' } }).langfuse + .secretKey, + displaySecretKey: 'sk-lf-...cret', + }, + }; + const preserved = preserveConfigSecrets({ langfuse: { publicKey: 'pk-new' } }, existing); + const preservedLangfuse = preserved.langfuse as Record; + expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-lf-old-secret'); + expect(preservedLangfuse.secretKeyPreview).toBe('sk-lf-...cret'); + expect(preservedLangfuse.displaySecretKey).toBeUndefined(); + + expect(getConfigSecretInputError('langfuse.displaySecretKey', 'spoofed')).toContain( + 'protected secret preview path', + ); + }); +}); + +describe('Config secret registry fields', () => { + it('exposes the registered top-level sections', () => { + expect([...getConfigSecretSections()].sort()).toEqual([ + 'endpoints', + 'langfuse', + 'ocr', + 'speech', + 'webSearch', + ]); + }); + + it('encrypts assistants endpoint keys but leaves unrelated endpoints untouched', () => { + const out = encryptConfigSecrets({ + endpoints: { + assistants: { apiKey: 'sk-assist', disableBuilder: true }, + azureAssistants: { apiKey: '${AZURE_ASSISTANTS_API_KEY}' }, + custom: [{ name: 'my-endpoint', apiKey: '${MY_KEY}', baseURL: 'https://x' }], + }, + }); + const endpoints = out.endpoints as { + assistants: Record; + azureAssistants: Record; + custom: Array>; + }; + expect(decryptV3(endpoints.assistants.apiKey as string)).toBe('sk-assist'); + expect(endpoints.assistants.disableBuilder).toBe(true); + expect(endpoints.azureAssistants.apiKey).toBe('${AZURE_ASSISTANTS_API_KEY}'); + expect(endpoints.custom[0]).toEqual({ + name: 'my-endpoint', + apiKey: '${MY_KEY}', + baseURL: 'https://x', + }); + }); + + it('encrypts speech, ocr, and webSearch literals on object writes', () => { + const out = encryptConfigSecrets({ + speech: { + tts: { openai: { apiKey: 'sk-tts', model: 'tts-1' } }, + stt: { azureOpenAI: { apiKey: 'sk-stt', instanceName: 'inst' } }, + }, + ocr: { apiKey: 'sk-ocr', mistralModel: 'mistral-ocr-latest' }, + webSearch: { serperApiKey: 'sk-serper', searchProvider: 'serper' }, + }); + + expect(decryptV3(out.speech.tts.openai.apiKey)).toBe('sk-tts'); + expect(out.speech.tts.openai.model).toBe('tts-1'); + expect(decryptV3(out.speech.stt.azureOpenAI.apiKey)).toBe('sk-stt'); + expect(out.speech.stt.azureOpenAI.instanceName).toBe('inst'); + expect(decryptV3(out.ocr.apiKey)).toBe('sk-ocr'); + expect(out.ocr.mistralModel).toBe('mistral-ocr-latest'); + expect(decryptV3(out.webSearch.serperApiKey)).toBe('sk-serper'); + expect(out.webSearch.searchProvider).toBe('serper'); + }); + + it('keeps env placeholder references as plain strings for fields that allow them', () => { + const out = encryptConfigSecrets({ + speech: { tts: { openai: { apiKey: '${TTS_API_KEY}' } } }, + ocr: { apiKey: '${OCR_API_KEY}' }, + webSearch: { serperApiKey: '${SERPER_API_KEY}' }, + }); + + expect(out.speech.tts.openai.apiKey).toBe('${TTS_API_KEY}'); + expect(out.ocr.apiKey).toBe('${OCR_API_KEY}'); + expect(out.webSearch.serperApiKey).toBe('${SERPER_API_KEY}'); + }); + + it('still encrypts placeholder-shaped Langfuse secrets (no placeholder exemption)', () => { + const out = encryptConfigSecrets({ langfuse: { secretKey: '${LANGFUSE_SECRET_KEY}' } }); + expect(out.langfuse.secretKey).toMatch(/^v3:/); + expect(decryptV3(out.langfuse.secretKey)).toBe('${LANGFUSE_SECRET_KEY}'); + }); + + it('clears a stale display mask when a literal secret is rotated to an env placeholder', () => { + const dottedOut = encryptConfigSecretFields({ 'ocr.apiKey': '${OCR_API_KEY}' }); + expect(dottedOut['ocr.apiKey']).toBe('${OCR_API_KEY}'); + expect(dottedOut['ocr.apiKeyPreview']).toBe(''); + + const objectOut = encryptConfigSecrets({ + ocr: { apiKey: '${OCR_API_KEY}', apiKeyPreview: 'sk-sta...LE00' }, + }); + expect(objectOut.ocr.apiKey).toBe('${OCR_API_KEY}'); + expect(objectOut.ocr.apiKeyPreview).toBe(''); + }); + + it('never persists a client-supplied display mask alongside an env placeholder secret', () => { + const out = encryptConfigSecrets({ + ocr: { apiKey: '${OCR_API_KEY}', apiKeyPreview: 'sk-atk...ACK' }, + }); + expect(out.ocr.apiKey).toBe('${OCR_API_KEY}'); + expect(out.ocr.apiKeyPreview).toBe(''); + }); + + it('trims whitespace from a literal secret before encrypting and masking', () => { + const out = encryptConfigSecretFields({ 'ocr.apiKey': ' sk-padded-secret ' }); + expect(decryptV3(out['ocr.apiKey'] as string)).toBe('sk-padded-secret'); + expect(out['ocr.apiKeyPreview']).toBe(getSecretPreview('sk-padded-secret')); + }); + + it('treats a whitespace-only literal secret as empty and clears it', () => { + const out = encryptConfigSecretFields({ 'ocr.apiKey': ' ' }); + expect(out['ocr.apiKey']).toBe(''); + expect(out['ocr.apiKeyPreview']).toBe(''); + }); + + it('masks short credentials fully instead of disclosing them via the preview companion', () => { + expect(getSecretPreview('short12')).toBe('*******'); + expect(getSecretPreview('0123456789')).toBe('**********'); + expect(getSecretPreview('sk-longer-secret-value')).toBe('sk-lon...alue'); + }); + + it('encrypts dotted patch writes and sets a masked preview companion for every field', () => { + const out = encryptConfigSecretFields({ + 'speech.tts.openai.apiKey': 'sk-tts', + 'webSearch.serperApiKey': '${SERPER_API_KEY}', + 'ocr.apiKey': '', + }); + + expect(decryptV3(out['speech.tts.openai.apiKey'] as string)).toBe('sk-tts'); + expect(out['speech.tts.openai.apiKeyPreview']).toBe(getSecretPreview('sk-tts')); + expect(out['webSearch.serperApiKey']).toBe('${SERPER_API_KEY}'); + expect(out['webSearch.serperApiKeyPreview']).toBe(''); + expect(out['ocr.apiKey']).toBe(''); + expect(out['ocr.apiKeyPreview']).toBe(''); + expect(Object.keys(out).sort()).toEqual([ + 'ocr.apiKey', + 'ocr.apiKeyPreview', + 'speech.tts.openai.apiKey', + 'speech.tts.openai.apiKeyPreview', + 'webSearch.serperApiKey', + 'webSearch.serperApiKeyPreview', + ]); + }); + + it('encrypts secrets nested inside object-valued ancestor patch entries and sets their preview companion', () => { + type SpeechPatch = { tts: { openai: Record } }; + const sectionPatch = encryptConfigSecretFields({ + speech: { tts: { openai: { apiKey: 'sk-tts', model: 'tts-1' } } }, + }); + const speech = sectionPatch.speech as SpeechPatch; + expect(decryptV3(speech.tts.openai.apiKey)).toBe('sk-tts'); + expect(speech.tts.openai.apiKeyPreview).toBe(getSecretPreview('sk-tts')); + expect(speech.tts.openai.model).toBe('tts-1'); + + const midPatch = encryptConfigSecretFields({ + 'speech.tts': { openai: { apiKey: 'sk-tts' } }, + }); + const tts = midPatch['speech.tts'] as SpeechPatch['tts']; + expect(decryptV3(tts.openai.apiKey)).toBe('sk-tts'); + expect(tts.openai.apiKeyPreview).toBe(getSecretPreview('sk-tts')); + + const leafParentPatch = encryptConfigSecretFields({ + 'speech.tts.openai': { apiKey: 'sk-tts', model: 'tts-1' }, + }); + const openai = leafParentPatch['speech.tts.openai'] as Record; + expect(decryptV3(openai.apiKey)).toBe('sk-tts'); + expect(openai.apiKeyPreview).toBe(getSecretPreview('sk-tts')); + }); + + it('strips dotted registry-related keys, including preview companions, from whole-override writes', () => { + const out = encryptConfigSecrets({ + 'speech.tts.openai.apiKey': 'sk-smuggled', + 'speech.tts.openai.apiKeyPreview': 'sk-spoofed...display', + 'ocr.apiKey': 'sk-smuggled', + 'webSearch.serperApiKey.nested': 'sk-smuggled', + 'speech.tts': { openai: { apiKey: 'sk-smuggled' } }, + ocr: { apiKey: 'sk-legit' } as Record, + }); + + expect(out).not.toHaveProperty(['speech.tts.openai.apiKey']); + expect(out).not.toHaveProperty(['speech.tts.openai.apiKeyPreview']); + expect(out).not.toHaveProperty(['ocr.apiKey']); + expect(out).not.toHaveProperty(['webSearch.serperApiKey.nested']); + expect(out).not.toHaveProperty(['speech.tts']); + expect(decryptV3(out.ocr.apiKey)).toBe('sk-legit'); + expect(out.ocr.apiKeyPreview).toBe(getSecretPreview('sk-legit')); + }); + + it('strips a nested array smuggled at any depth along a secret ancestor path, not just the top level', () => { + const encrypted = encryptConfigSecrets({ + speech: { tts: { openai: [{ apiKey: 'sk-smuggled-via-array' }] } }, + }); + const speechOut = encrypted.speech as { tts: Record }; + expect(speechOut.tts).not.toHaveProperty('openai'); + expect(JSON.stringify(encrypted)).not.toContain('sk-smuggled-via-array'); + + const readBack = redactConfigSecrets( + structuredClone({ + speech: { tts: { openai: [{ apiKey: 'sk-smuggled-via-array' }] } }, + }), + ); + const speechRead = readBack.speech as { tts: Record }; + expect(speechRead.tts).not.toHaveProperty('openai'); + expect(JSON.stringify(readBack)).not.toContain('sk-smuggled-via-array'); + }); + + it('redacts secrets but keeps preview companions, env placeholders, and siblings visible on read', () => { + const redacted = redactConfigSecrets({ + speech: { + tts: { + openai: { + apiKey: 'sk-literal', + apiKeyPreview: getSecretPreview('sk-literal'), + model: 'tts-1', + }, + }, + stt: { + openai: { apiKey: 'v3:abc:def', apiKeyPreview: 'sk-old...-old', model: 'whisper-1' }, + }, + }, + ocr: { apiKey: '${OCR_API_KEY}', mistralModel: 'mistral-ocr-latest' }, + webSearch: { + serperApiKey: 'sk-literal', + serperApiKeyPreview: 'sk-lite...eral', + searchProvider: 'serper', + }, + }); + + expect(redacted.speech.tts.openai).toEqual({ + apiKeyPreview: getSecretPreview('sk-literal'), + model: 'tts-1', + }); + expect(redacted.speech.stt.openai).toEqual({ + apiKeyPreview: 'sk-old...-old', + model: 'whisper-1', + }); + expect(redacted.ocr).toEqual({ + apiKey: '${OCR_API_KEY}', + mistralModel: 'mistral-ocr-latest', + }); + expect(redacted.webSearch).toEqual({ + serperApiKeyPreview: 'sk-lite...eral', + searchProvider: 'serper', + }); + }); + + it('preserves omitted encrypted secrets and their preview companion on nested object writes', () => { + const existing = encryptConfigSecrets({ + speech: { tts: { openai: { apiKey: 'sk-old', model: 'tts-1' } as Record } }, + }); + const existingDisplay = existing.speech.tts.openai.apiKeyPreview; + expect(existingDisplay).toBe(getSecretPreview('sk-old')); + + const next = preserveConfigSecrets( + { speech: { tts: { openai: { model: 'tts-2' } as Record } } }, + existing, + ); + expect(decryptV3(next.speech.tts.openai.apiKey)).toBe('sk-old'); + expect(next.speech.tts.openai.apiKeyPreview).toBe(existingDisplay); + expect(next.speech.tts.openai.model).toBe('tts-2'); + + const providerRemoved = preserveConfigSecrets({ speech: { tts: {} } }, existing); + expect(providerRemoved.speech.tts).toEqual({}); + + const ancestorPatch = preserveConfigSecrets( + { openai: { model: 'tts-2' } as Record }, + existing, + 'speech.tts', + ); + expect(decryptV3(ancestorPatch.openai.apiKey)).toBe('sk-old'); + expect(ancestorPatch.openai.apiKeyPreview).toBe(existingDisplay); + }); + + it('does not preserve explicitly cleared secrets, and clears the preview companion too', () => { + const cleared = encryptConfigSecrets({ + speech: { tts: { openai: { apiKey: '' } as Record } }, + }); + expect(cleared.speech.tts.openai.apiKeyPreview).toBe(''); + const existing = encryptConfigSecrets({ + speech: { tts: { openai: { apiKey: 'sk-old' } as Record } }, + }); + const preservedAfterClear = preserveConfigSecrets(cleared, existing); + expect(preservedAfterClear.speech.tts.openai.apiKey).toBe(''); + expect(preservedAfterClear.speech.tts.openai.apiKeyPreview).toBe(''); + }); + + it('migrates a legacy plaintext existing secret on an omitted allow-placeholder field too', () => { + const fromPlaintext = preserveConfigSecrets( + { ocr: { mistralModel: 'm' } }, + { ocr: { apiKey: 'sk-plain-existing' } }, + ); + const ocr = fromPlaintext.ocr as Record; + expect(ocr.mistralModel).toBe('m'); + expect(decryptV3(ocr.apiKey)).toBe('sk-plain-existing'); + expect(ocr.apiKeyPreview).toBe(getSecretPreview('sk-plain-existing')); + }); + + it('preserves an existing env placeholder secret verbatim without encrypting it', () => { + const fromPlaceholder = preserveConfigSecrets( + { ocr: { mistralModel: 'm' } }, + { ocr: { apiKey: '${OCR_API_KEY}' } }, + ); + const ocr = fromPlaceholder.ocr as Record; + expect(ocr.apiKey).toBe('${OCR_API_KEY}'); + expect(ocr.apiKeyPreview).toBeUndefined(); + }); + + it('resolveConfigSecret decrypts, resolves env references, and passes literals through', () => { + const encrypted = encryptConfigSecrets({ ocr: { apiKey: 'sk-ocr' } }).ocr.apiKey; + expect(resolveConfigSecret(encrypted)).toBe('sk-ocr'); + + process.env.SECRETS_SPEC_TEST_KEY = 'sk-from-env'; + expect(resolveConfigSecret('${SECRETS_SPEC_TEST_KEY}')).toBe('sk-from-env'); + delete process.env.SECRETS_SPEC_TEST_KEY; + + expect(resolveConfigSecret('sk-plain-literal')).toBe('sk-plain-literal'); + expect(resolveConfigSecret('')).toBe(''); + expect(resolveConfigSecret(undefined)).toBeUndefined(); + expect(resolveConfigSecret('v3:not-valid-ciphertext')).toBe('v3:not-valid-ciphertext'); + expect(resolveConfigSecret('v3:provider-literal-token')).toBe('v3:provider-literal-token'); + }); + + it('reports mutation paths (including the preview companion) and ancestor/descendant checks for registry fields', () => { + expect(getConfigSecretMutationPaths('speech.tts.openai.apiKey')).toEqual([ + 'speech.tts.openai.apiKey', + 'speech.tts.openai.apiKeyPreview', + ]); + expect(getConfigSecretMutationPaths('webSearch.serperApiKey')).toEqual([ + 'webSearch.serperApiKey', + 'webSearch.serperApiKeyPreview', + ]); + expect(getConfigSecretMutationPaths('langfuse.secretKey')).toEqual([ + 'langfuse.secretKey', + 'langfuse.secretKeyPreview', + ]); + expect(getConfigSecretMutationPaths('speech.tts.openai.apiKeyPreview')).toEqual([ + 'speech.tts.openai.apiKeyPreview', + ]); + + for (const path of ['speech', 'speech.tts', 'speech.tts.openai', 'ocr', 'webSearch']) { + expect(isConfigSecretAncestorPath(path)).toBe(true); + } + expect(isConfigSecretAncestorPath('speech.tts.openai.apiKey')).toBe(false); + expect(isConfigSecretAncestorPath('interface')).toBe(false); + + expect(isConfigSecretDescendantPath('speech.tts.openai.apiKey.hidden')).toBe(true); + expect(isConfigSecretDescendantPath('webSearch.serperApiKey.hidden')).toBe(true); + expect(isConfigSecretDescendantPath('speech.tts.openai.apiKeyPreview.hidden')).toBe(true); + expect(isConfigSecretDescendantPath('speech.tts.openai.model')).toBe(false); + }); + + it('rejects encrypted submissions at registry paths and inside ancestor objects', () => { + expect(getConfigSecretInputError('webSearch.serperApiKey', 'v3:attacker')).toContain( + 'Encrypted config secret values', + ); + expect( + getConfigSecretInputError('speech', { tts: { openai: { apiKey: 'v3:attacker' } } }), + ).toContain('Encrypted config secret values'); + expect(getConfigSecretInputError('speech.tts.openai', { apiKey: 'v3:attacker' })).toContain( + 'Encrypted config secret values', + ); + expect(getConfigSecretInputError('speech.tts.openai.apiKey', 'sk-legit')).toBeNull(); + expect(getConfigSecretInputError('ocr.apiKey', '${OCR_API_KEY}')).toBeNull(); + }); + + describe('preview companion fields are write-side read-only for every registered field', () => { + it.each([ + 'ocr.apiKeyPreview', + 'speech.tts.openai.apiKeyPreview', + 'speech.stt.azureOpenAI.apiKeyPreview', + 'webSearch.serperApiKeyPreview', + 'webSearch.cohereApiKeyPreview', + 'endpoints.assistants.apiKeyPreview', + 'endpoints.azureAssistants.apiKeyPreview', + 'langfuse.secretKeyPreview', + ])('rejects a direct dotted-patch write to %s', (previewPath) => { + expect(getConfigSecretInputError(previewPath, 'attacker-supplied-display-value')).toContain( + 'Cannot write protected secret preview path', + ); + }); + + it('drops a client-supplied display value when the ancestor object omits the real secret, never storing it', () => { + const out = encryptConfigSecretFields({ + speech: { tts: { openai: { model: 'tts-1', apiKeyPreview: 'attacker-injected-display' } } }, + }); + const openai = (out.speech as { tts: { openai: Record } }).tts.openai; + expect(openai).not.toHaveProperty('apiKeyPreview'); + expect(openai.model).toBe('tts-1'); + }); + + it('overwrites a client-supplied display value with the server-computed one when a real secret is also present, never persisting the attacker value', () => { + const out = encryptConfigSecretFields({ + webSearch: { + serperApiKey: 'sk-real-secret', + serperApiKeyPreview: 'v3:looks-encrypted-but-is-attacker-input', + }, + }); + const webSearch = out.webSearch as Record; + expect(decryptV3(webSearch.serperApiKey)).toBe('sk-real-secret'); + expect(webSearch.serperApiKeyPreview).toBe(getSecretPreview('sk-real-secret')); + expect(webSearch.serperApiKeyPreview).not.toBe('v3:looks-encrypted-but-is-attacker-input'); + }); + + it('never encrypts or stores a display-path value submitted without its real secret, even if it looks like a secret literal', () => { + const out = encryptConfigSecrets({ + ocr: { apiKeyPreview: 'this-should-never-be-treated-as-a-secret', mistralModel: 'x' }, + }); + expect(out.ocr).not.toHaveProperty('apiKeyPreview'); + expect(out.ocr).not.toHaveProperty('apiKey'); + expect(out.ocr.mistralModel).toBe('x'); }); }); }); diff --git a/packages/api/src/admin/secrets.ts b/packages/api/src/admin/secrets.ts index 49a4027962..e38174c17c 100644 --- a/packages/api/src/admin/secrets.ts +++ b/packages/api/src/admin/secrets.ts @@ -1,38 +1,168 @@ import isPlainObject from 'lodash/isPlainObject'; import { encryptV3, decryptV3, logger } from '@librechat/data-schemas'; +import { envVarRegex, extractEnvVariable } from 'librechat-data-provider'; -const LANGFUSE_SECTION = 'langfuse'; -const LANGFUSE_SECRET_KEY = 'secretKey'; -const LANGFUSE_DISPLAY_SECRET_KEY = 'displaySecretKey'; -const LANGFUSE_SECRET_PATH = `${LANGFUSE_SECTION}.${LANGFUSE_SECRET_KEY}`; -const LANGFUSE_DISPLAY_SECRET_PATH = `${LANGFUSE_SECTION}.${LANGFUSE_DISPLAY_SECRET_KEY}`; const ENCRYPTED_PREFIX = 'v3:'; +const ENCRYPTED_PAYLOAD_REGEX = /^v3:[0-9a-f]{32}:[0-9a-f]+$/; -export function getDisplaySecretKey(secret: string): string { +interface ConfigSecretFieldInput { + /** Dot-path of the secret value within config overrides */ + path: string; + /** When true, `${ENV_VAR}` placeholder values are stored and returned as plain references instead of being encrypted */ + allowEnvPlaceholder?: boolean; +} + +interface ConfigSecretField extends ConfigSecretFieldInput { + /** Non-secret masked-preview companion, always the sibling `Preview`. Written on encrypt, preserved by redaction. */ + previewPath: string; +} + +/** + * Registry of config fields that hold secret values. Writes through the admin + * config API encrypt these at rest, reads redact them, and omitting them on a + * subsequent write preserves the stored encrypted value. Each secret's + * masked-preview companion is derived as `Preview` — recognizing a new + * sensitive field is a one-line path addition (plus the `Preview` + * companion in the config schema). + */ +const CONFIG_SECRET_FIELDS: readonly ConfigSecretField[] = ( + [ + { path: 'langfuse.secretKey' }, + { path: 'ocr.apiKey', allowEnvPlaceholder: true }, + { path: 'speech.tts.openai.apiKey', allowEnvPlaceholder: true }, + { path: 'speech.tts.azureOpenAI.apiKey', allowEnvPlaceholder: true }, + { path: 'speech.tts.elevenlabs.apiKey', allowEnvPlaceholder: true }, + { path: 'speech.tts.localai.apiKey', allowEnvPlaceholder: true }, + { path: 'speech.stt.openai.apiKey', allowEnvPlaceholder: true }, + { path: 'speech.stt.azureOpenAI.apiKey', allowEnvPlaceholder: true }, + { path: 'webSearch.serperApiKey', allowEnvPlaceholder: true }, + { path: 'webSearch.searxngApiKey', allowEnvPlaceholder: true }, + { path: 'webSearch.firecrawlApiKey', allowEnvPlaceholder: true }, + { path: 'webSearch.tavilyApiKey', allowEnvPlaceholder: true }, + { path: 'webSearch.jinaApiKey', allowEnvPlaceholder: true }, + { path: 'webSearch.cohereApiKey', allowEnvPlaceholder: true }, + { path: 'endpoints.assistants.apiKey', allowEnvPlaceholder: true }, + { path: 'endpoints.azureAssistants.apiKey', allowEnvPlaceholder: true }, + ] satisfies ConfigSecretFieldInput[] +).map((field) => ({ ...field, previewPath: `${field.path}Preview` })); + +/** + * Preview companions written under earlier naming conventions. Stripped from + * writes and reads so stored documents self-clean; never written. + */ +const LEGACY_PREVIEW_PATHS: ReadonlyMap = new Map([ + ['langfuse.secretKey', 'langfuse.displaySecretKey'], +]); + +const SECRET_FIELDS_BY_PATH = new Map( + CONFIG_SECRET_FIELDS.map((field) => [field.path, field]), +); + +const PREVIEW_PATHS = new Set([ + ...CONFIG_SECRET_FIELDS.map((field) => field.previewPath), + ...LEGACY_PREVIEW_PATHS.values(), +]); + +const ANCESTOR_PATHS = new Set( + CONFIG_SECRET_FIELDS.flatMap((field) => { + const segments = field.path.split('.'); + return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join('.')); + }), +); + +const SECRET_SECTIONS: readonly string[] = [ + ...new Set(CONFIG_SECRET_FIELDS.map((field) => field.path.split('.')[0])), +]; + +export function getSecretPreview(secret: string): string { + if (secret.length <= 10) { + return '*'.repeat(secret.length); + } return secret.slice(0, 6) + '...' + secret.slice(-4); } +/** Top-level config sections containing registered secret fields. */ +export function getConfigSecretSections(): readonly string[] { + return SECRET_SECTIONS; +} + function normalizeSecretString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; } -function isEncryptedConfigSecret(value: unknown): boolean { +export function isEncryptedConfigSecret(value: unknown): boolean { return typeof value === 'string' && value.trim().startsWith(ENCRYPTED_PREFIX); } +function isEnvPlaceholder(value: string): boolean { + return envVarRegex.test(value.trim()); +} + function getPlainRecord(value: unknown): Record | null { return isPlainObject(value) ? (value as Record) : null; } -function getLangfuseSection(root: unknown, basePath = ''): Record | null { - const rootRecord = getPlainRecord(root); - if (!rootRecord) { +function lastSegment(path: string): string { + return path.split('.').slice(-1)[0]; +} + +/** + * Returns the segments of `path` relative to `basePath`, or null when + * `basePath` is not an ancestor of `path`. An empty `basePath` yields the + * full segment list. + */ +function relativeSegments(path: string, basePath: string): string[] | null { + if (basePath === '') { + return path.split('.'); + } + if (!path.startsWith(`${basePath}.`)) { return null; } - if (basePath === LANGFUSE_SECTION) { - return rootRecord; + return path.slice(basePath.length + 1).split('.'); +} + +/** Walks `root` along all but the last segment, returning the parent record of the final key. */ +function walkToParent(root: unknown, segments: string[]): Record | null { + let cursor = getPlainRecord(root); + for (let i = 0; cursor != null && i < segments.length - 1; i++) { + cursor = getPlainRecord(cursor[segments[i]]); } - return getPlainRecord(rootRecord[LANGFUSE_SECTION]); + return cursor; +} + +/** + * Deletes any array value found along a registered secret's ancestor chain + * (relative to `basePath`), at any depth, not just the top level. `walkToParent` + * silently stops and returns null at an array, which would otherwise let a + * secret smuggled inside an unexpected array-of-objects shape (e.g. + * `speech.tts.openai` submitted as an array) bypass both encryption and + * redaction entirely instead of being stripped like a top-level array is. + */ +function pruneSecretAncestorArrays(root: Record, basePath: string): void { + for (const field of CONFIG_SECRET_FIELDS) { + const segments = relativeSegments(field.path, basePath); + if (!segments) { + continue; + } + let cursor: Record | null = root; + for (let i = 0; cursor != null && i < segments.length - 1; i++) { + const value = cursor[segments[i]]; + if (Array.isArray(value)) { + delete cursor[segments[i]]; + cursor = null; + continue; + } + cursor = getPlainRecord(value); + } + } +} + +/** True when a dotted key equals, contains, or is contained by a registered secret or preview path. */ +function isConfigSecretRelatedPath(fieldPath: string): boolean { + if (SECRET_FIELDS_BY_PATH.has(fieldPath) || PREVIEW_PATHS.has(fieldPath)) { + return true; + } + return ANCESTOR_PATHS.has(fieldPath) || isConfigSecretDescendantPath(fieldPath); } export function decryptConfigSecret(value: unknown): string | undefined { @@ -48,93 +178,207 @@ export function decryptConfigSecret(value: unknown): string | undefined { } } +/** + * Resolves a config credential for runtime use: decrypts encrypted values and + * resolves `${ENV_VAR}` placeholders, passing plain literals through unchanged. + */ +/** + * Whether a value has the exact shape `encryptV3` produces + * (`v3:<32-hex-iv>:`). Runtime resolution uses this strict + * check so a legitimate literal credential that merely starts with `v3:` + * (e.g. from a YAML config never touched by the admin write path) resolves + * as a literal instead of failing decryption. + */ +export function isEncryptedSecretPayload(value: string): boolean { + return ENCRYPTED_PAYLOAD_REGEX.test(value.trim()); +} + +export function resolveConfigSecret(value?: string): string | undefined { + if (value == null || value === '') { + return value; + } + if (isEncryptedSecretPayload(value)) { + return decryptConfigSecret(value); + } + return extractEnvVariable(value); +} + export function getConfigSecretMutationPaths(fieldPath: string): string[] { - if (fieldPath === LANGFUSE_SECRET_PATH) { - return [LANGFUSE_SECRET_PATH, LANGFUSE_DISPLAY_SECRET_PATH]; + const field = SECRET_FIELDS_BY_PATH.get(fieldPath); + if (field?.previewPath) { + return [field.path, field.previewPath]; } return [fieldPath]; } export function isConfigSecretDescendantPath(fieldPath: string): boolean { - return ( - fieldPath.startsWith(`${LANGFUSE_SECRET_PATH}.`) || - fieldPath.startsWith(`${LANGFUSE_DISPLAY_SECRET_PATH}.`) - ); -} - -export function isConfigSecretAncestorPath(fieldPath: string): boolean { - return fieldPath === LANGFUSE_SECTION; -} - -export function getConfigSecretInputError(fieldPath: string, value: unknown): string | null { - if (fieldPath === LANGFUSE_DISPLAY_SECRET_PATH) { - return `Cannot write protected display secret path: ${fieldPath}`; - } - if (fieldPath === LANGFUSE_SECRET_PATH && isEncryptedConfigSecret(value)) { - return `Encrypted config secret values cannot be submitted: ${fieldPath}`; - } - const langfuseInput = fieldPath === LANGFUSE_SECTION ? getPlainRecord(value) : null; - if (langfuseInput && isEncryptedConfigSecret(langfuseInput[LANGFUSE_SECRET_KEY])) { - return `Encrypted config secret values cannot be submitted: ${LANGFUSE_SECRET_PATH}`; - } - return null; -} - -function removeLangfuseArraySection(root: Record): boolean { - if (Array.isArray(root[LANGFUSE_SECTION])) { - delete root[LANGFUSE_SECTION]; - return true; + for (const field of CONFIG_SECRET_FIELDS) { + if (fieldPath.startsWith(`${field.path}.`)) { + return true; + } + if (field.previewPath && fieldPath.startsWith(`${field.previewPath}.`)) { + return true; + } } return false; } -function applyLangfuseSecretWrite(section: Record): void { - if (!(LANGFUSE_SECRET_KEY in section)) { - delete section[LANGFUSE_DISPLAY_SECRET_KEY]; - return; - } +export function isConfigSecretAncestorPath(fieldPath: string): boolean { + return ANCESTOR_PATHS.has(fieldPath); +} - const value = section[LANGFUSE_SECRET_KEY]; - if (typeof value !== 'string' || value.length === 0 || value.startsWith(ENCRYPTED_PREFIX)) { - section[LANGFUSE_SECRET_KEY] = ''; - section[LANGFUSE_DISPLAY_SECRET_KEY] = ''; - return; +export function getConfigSecretInputError(fieldPath: string, value: unknown): string | null { + if (PREVIEW_PATHS.has(fieldPath)) { + return `Cannot write protected secret preview path: ${fieldPath}`; } + if (SECRET_FIELDS_BY_PATH.has(fieldPath) && isEncryptedConfigSecret(value)) { + return `Encrypted config secret values cannot be submitted: ${fieldPath}`; + } + if (!isConfigSecretAncestorPath(fieldPath)) { + return null; + } + for (const field of CONFIG_SECRET_FIELDS) { + const segments = relativeSegments(field.path, fieldPath); + if (!segments) { + continue; + } + const parent = walkToParent(value, segments); + if (parent && isEncryptedConfigSecret(parent[segments[segments.length - 1]])) { + return `Encrypted config secret values cannot be submitted: ${field.path}`; + } + } + return null; +} - section[LANGFUSE_SECRET_KEY] = encryptV3(value); - section[LANGFUSE_DISPLAY_SECRET_KEY] = getDisplaySecretKey(value); +function deleteLegacyPreviewKey(section: Record, field: ConfigSecretField): void { + const legacyPath = LEGACY_PREVIEW_PATHS.get(field.path); + if (legacyPath) { + delete section[lastSegment(legacyPath)]; + } } /** - * Returns a new field map with Langfuse secret entries encrypted and their - * displaySecretKey companion set. Empty values reset the secret and displaySecretKey. + * Translates a legacy preview companion to its `Preview` name in place, + * so reads of not-yet-migrated documents still indicate a configured secret. + * The stored document migrates for real on its next write. + */ +function migrateLegacyPreviewKey(section: Record, field: ConfigSecretField): void { + const legacyPath = LEGACY_PREVIEW_PATHS.get(field.path); + if (!legacyPath) { + return; + } + const legacyValue = section[lastSegment(legacyPath)]; + const previewKey = lastSegment(field.previewPath); + if (typeof legacyValue === 'string' && section[previewKey] === undefined) { + section[previewKey] = legacyValue; + } + delete section[lastSegment(legacyPath)]; +} + +/** + * Encrypts a secret value in place within its parent record. Empty and + * non-string values reset the secret (and preview companion). Env placeholder + * values are kept as plain references for fields that allow them. + */ +function writeSecretIntoSection(section: Record, field: ConfigSecretField): void { + const key = lastSegment(field.path); + const previewKey = field.previewPath ? lastSegment(field.previewPath) : undefined; + deleteLegacyPreviewKey(section, field); + if (!(key in section)) { + if (previewKey) { + delete section[previewKey]; + } + return; + } + + const rawValue = section[key]; + if (typeof rawValue !== 'string' || rawValue.startsWith(ENCRYPTED_PREFIX)) { + section[key] = ''; + if (previewKey) { + section[previewKey] = ''; + } + return; + } + const value = normalizeSecretString(rawValue); + if (!value) { + section[key] = ''; + if (previewKey) { + section[previewKey] = ''; + } + return; + } + if (field.allowEnvPlaceholder && isEnvPlaceholder(value)) { + section[key] = value; + if (previewKey) { + section[previewKey] = ''; + } + return; + } + + section[key] = encryptV3(value); + if (previewKey) { + section[previewKey] = getSecretPreview(value); + } +} + +function writeDottedSecret(result: Record, field: ConfigSecretField): void { + const rawValue = result[field.path]; + if (typeof rawValue !== 'string' || rawValue.startsWith(ENCRYPTED_PREFIX)) { + result[field.path] = ''; + if (field.previewPath) { + result[field.previewPath] = ''; + } + return; + } + const value = normalizeSecretString(rawValue); + if (!value) { + result[field.path] = ''; + if (field.previewPath) { + result[field.previewPath] = ''; + } + return; + } + if (field.allowEnvPlaceholder && isEnvPlaceholder(value)) { + result[field.path] = value; + if (field.previewPath) { + result[field.previewPath] = ''; + } + return; + } + result[field.path] = encryptV3(value); + if (field.previewPath) { + result[field.previewPath] = getSecretPreview(value); + } +} + +/** + * Returns a new field map with registered secret entries encrypted (and preview + * companions set where configured). Empty values reset the secret and its + * preview companion. Handles both dotted secret paths and object-valued + * ancestor entries. */ export function encryptConfigSecretFields( fields: Record, ): Record { const result: Record = { ...fields }; - if (Array.isArray(result[LANGFUSE_SECTION])) { - delete result[LANGFUSE_SECTION]; - } else { - const section = getPlainRecord(result[LANGFUSE_SECTION]); - if (section) { - result[LANGFUSE_SECTION] = encryptConfigSecrets(section, LANGFUSE_SECTION); + for (const key of Object.keys(result)) { + if (!isConfigSecretAncestorPath(key)) { + continue; + } + if (Array.isArray(result[key])) { + delete result[key]; + } else if (isPlainObject(result[key])) { + result[key] = encryptConfigSecrets(result[key], key); } } - if (!(LANGFUSE_SECRET_PATH in result) && LANGFUSE_DISPLAY_SECRET_PATH in result) { - delete result[LANGFUSE_DISPLAY_SECRET_PATH]; - } - - if (LANGFUSE_SECRET_PATH in result) { - const value = result[LANGFUSE_SECRET_PATH]; - if (typeof value !== 'string' || value.length === 0 || value.startsWith(ENCRYPTED_PREFIX)) { - result[LANGFUSE_SECRET_PATH] = ''; - result[LANGFUSE_DISPLAY_SECRET_PATH] = ''; - } else { - result[LANGFUSE_SECRET_PATH] = encryptV3(value); - result[LANGFUSE_DISPLAY_SECRET_PATH] = getDisplaySecretKey(value); + for (const field of CONFIG_SECRET_FIELDS) { + if (field.previewPath && !(field.path in result) && field.previewPath in result) { + delete result[field.previewPath]; + } + if (field.path in result) { + writeDottedSecret(result, field); } } @@ -142,8 +386,9 @@ export function encryptConfigSecretFields( } /** - * Returns a cloned config override object with Langfuse secret values encrypted - * before full-document writes. Empty secrets reset their displaySecretKey. + * Returns a cloned config object with registered secret values encrypted + * before writes. Empty secrets reset their preview companions. `basePath` + * locates `root` within the config tree ('' for whole-overrides writes). */ export function encryptConfigSecrets(root: T, basePath = ''): T { if (root == null || typeof root !== 'object') { @@ -151,23 +396,36 @@ export function encryptConfigSecrets(root: T, basePath = ''): T { } const result = structuredClone(root); + const rootRecord = result as Record; if (basePath === '') { - delete (result as Record)[LANGFUSE_SECRET_PATH]; - delete (result as Record)[LANGFUSE_DISPLAY_SECRET_PATH]; - removeLangfuseArraySection(result as Record); + for (const key of Object.keys(rootRecord)) { + if (key.includes('.') && isConfigSecretRelatedPath(key)) { + delete rootRecord[key]; + } else if (isConfigSecretAncestorPath(key) && Array.isArray(rootRecord[key])) { + delete rootRecord[key]; + } + } } + pruneSecretAncestorArrays(rootRecord, basePath); - const section = getLangfuseSection(result, basePath); - if (section) { - applyLangfuseSecretWrite(section); + for (const field of CONFIG_SECRET_FIELDS) { + const segments = relativeSegments(field.path, basePath); + if (!segments) { + continue; + } + const section = walkToParent(result, segments); + if (section) { + writeSecretIntoSection(section, field); + } } return result; } /** - * Preserves an existing encrypted Langfuse secret when a whole Langfuse object is - * replaced without a secret value. This lets redacted admin reads round-trip - * safely: omitting a secret keeps it, while setting it to an empty value clears it. + * Preserves existing encrypted secrets when an object write omits them. This + * lets redacted admin reads round-trip safely: omitting a secret keeps it, + * while setting it to an empty value clears it. `basePath` locates `next` + * within the config tree; `existing` is always the full overrides object. */ export function preserveConfigSecrets(next: T, existing?: unknown, basePath = ''): T { if ( @@ -180,31 +438,53 @@ export function preserveConfigSecrets(next: T, existing?: unknown, basePath = } const result = structuredClone(next); - const section = getLangfuseSection(result, basePath); - const existingSection = getLangfuseSection(existing); - if ( - !section || - !existingSection || - LANGFUSE_SECRET_KEY in section || - !isEncryptedConfigSecret(existingSection[LANGFUSE_SECRET_KEY]) - ) { - return result; - } + for (const field of CONFIG_SECRET_FIELDS) { + const segments = relativeSegments(field.path, basePath); + if (!segments) { + continue; + } + const section = walkToParent(result, segments); + if (!section) { + continue; + } + const key = segments[segments.length - 1]; + if (key in section) { + continue; + } - const existingSecret = normalizeSecretString(existingSection[LANGFUSE_SECRET_KEY]); - if (!existingSecret) { - return result; - } - section[LANGFUSE_SECRET_KEY] = existingSecret; - if (typeof existingSection[LANGFUSE_DISPLAY_SECRET_KEY] === 'string') { - section[LANGFUSE_DISPLAY_SECRET_KEY] = existingSection[LANGFUSE_DISPLAY_SECRET_KEY]; + const existingSection = walkToParent(existing, field.path.split('.')); + if (!existingSection) { + continue; + } + const existingSecret = normalizeSecretString(existingSection[key]); + if (!existingSecret) { + continue; + } + const isAlreadyEncrypted = isEncryptedConfigSecret(existingSecret); + const isPlaceholder = field.allowEnvPlaceholder && isEnvPlaceholder(existingSecret); + // A legacy plaintext secret stored before this field was registered has + // no ciphertext to preserve verbatim — encrypt it now instead of + // silently dropping it the first time an unrelated field is edited. + section[key] = isAlreadyEncrypted || isPlaceholder ? existingSecret : encryptV3(existingSecret); + if (field.previewPath) { + const previewKey = lastSegment(field.previewPath); + const legacyPath = LEGACY_PREVIEW_PATHS.get(field.path); + const legacyPreview = legacyPath ? existingSection[lastSegment(legacyPath)] : undefined; + const existingPreview = existingSection[previewKey] ?? legacyPreview; + if (typeof existingPreview === 'string') { + section[previewKey] = existingPreview; + } else if (!isAlreadyEncrypted && !isPlaceholder) { + section[previewKey] = getSecretPreview(existingSecret); + } + } } return result; } /** - * Deletes Langfuse secret fields from `root` in place so admin reads never - * return secret values (encrypted or otherwise). Display companions are preserved. + * Deletes registered secret values from `root` in place so admin reads never + * return them (encrypted or plaintext). Preview companions and plain + * `${ENV_VAR}` references (for fields that allow them) are preserved. * The caller passes a cloned object. */ export function redactConfigSecrets(root: T): T { @@ -212,15 +492,32 @@ export function redactConfigSecrets(root: T): T { if (!rootRecord) { return root; } - delete rootRecord[LANGFUSE_SECRET_PATH]; - delete rootRecord[LANGFUSE_DISPLAY_SECRET_PATH]; - if (Array.isArray(rootRecord[LANGFUSE_SECTION])) { - delete rootRecord[LANGFUSE_SECTION]; - return root; + + for (const key of Object.keys(rootRecord)) { + if (key.includes('.') && isConfigSecretRelatedPath(key)) { + delete rootRecord[key]; + } else if (isConfigSecretAncestorPath(key) && Array.isArray(rootRecord[key])) { + delete rootRecord[key]; + } } - const section = getPlainRecord(rootRecord[LANGFUSE_SECTION]); - if (section) { - delete section[LANGFUSE_SECRET_KEY]; + pruneSecretAncestorArrays(rootRecord, ''); + + for (const field of CONFIG_SECRET_FIELDS) { + const segments = field.path.split('.'); + const section = walkToParent(rootRecord, segments); + if (!section) { + continue; + } + migrateLegacyPreviewKey(section, field); + const key = segments[segments.length - 1]; + if (!(key in section)) { + continue; + } + const value = section[key]; + if (field.allowEnvPlaceholder && typeof value === 'string' && isEnvPlaceholder(value)) { + continue; + } + delete section[key]; } return root; } diff --git a/packages/api/src/files/mistral/crud.spec.ts b/packages/api/src/files/mistral/crud.spec.ts index b401a852fa..ecac188091 100644 --- a/packages/api/src/files/mistral/crud.spec.ts +++ b/packages/api/src/files/mistral/crud.spec.ts @@ -40,6 +40,11 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +jest.mock('~/admin/secrets', () => ({ + decryptConfigSecret: jest.fn(), + isEncryptedSecretPayload: jest.fn(), +})); + jest.mock('~/utils/axios', () => ({ createAxiosInstance: () => jest.requireMock('axios'), logAxiosError: jest.fn(({ message }) => message || 'Error'), @@ -60,6 +65,7 @@ import type { OCRResult, } from '~/types'; import { logger as mockLogger } from '@librechat/data-schemas'; +import { decryptConfigSecret, isEncryptedSecretPayload } from '~/admin/secrets'; import { readFileAsBuffer } from '~/utils/files'; import { uploadDocumentToMistral, @@ -1073,6 +1079,100 @@ describe('MistralOCR Service', () => { expect(mockLoadAuthValues).not.toHaveBeenCalled(); }); + it('should fail closed to env-var loading instead of sending a corrupted ciphertext as the apiKey', async () => { + // Simulates a stored v3 ciphertext that fails to decrypt (e.g. corrupted at rest). + const corruptedCiphertext = 'v3:corrupted-ciphertext'; + (isEncryptedSecretPayload as jest.Mock).mockReturnValueOnce(true); + (decryptConfigSecret as jest.Mock).mockReturnValueOnce(undefined); + + mockLoadAuthValues.mockResolvedValue({ OCR_API_KEY: 'env-fallback-key' }); + + mockAxios.post!.mockClear(); + mockAxios.get!.mockClear(); + + mockAxios.post!.mockImplementationOnce(() => + Promise.resolve({ + data: { + id: 'file-456', + object: 'file', + bytes: 1024, + created_at: Date.now(), + filename: 'corrupted-key.pdf', + purpose: 'ocr', + } as MistralFileUploadResponse, + }), + ); + mockAxios.get!.mockImplementationOnce(() => + Promise.resolve({ + data: { + url: 'https://signed-url.com', + expires_at: Date.now() + 86400000, + } as MistralSignedUrlResponse, + }), + ); + mockAxios.post!.mockImplementationOnce(() => + Promise.resolve({ + data: { + model: 'mistral-ocr-latest', + pages: [ + { + index: 0, + markdown: 'Processed with the env-fallback key', + images: [], + dimensions: { dpi: 300, height: 1100, width: 850 }, + }, + ], + document_annotation: '', + usage_info: { pages_processed: 1, doc_size_bytes: 1024 }, + }, + }), + ); + + const req = { + user: { id: 'user123' }, + config: { + ocr: { + apiKey: corruptedCiphertext, + baseURL: 'https://api.mistral.ai/v1', + mistralModel: 'mistral-ocr-latest', + }, + }, + } as unknown as ServerRequest; + + const file = { + path: '/tmp/upload/file.pdf', + originalname: 'corrupted-key.pdf', + mimetype: 'application/pdf', + } as Express.Multer.File; + + await uploadMistralOCR({ req, file, loadAuthValues: mockLoadAuthValues }); + + // The corrupted ciphertext must never be sent as the credential. + expect(mockAxios.post).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expect.stringContaining(corruptedCiphertext), + }), + }), + ); + + // Treated as empty, so it fails over to loading OCR_API_KEY from the environment. + expect(mockLoadAuthValues).toHaveBeenCalledWith( + expect.objectContaining({ authFields: expect.arrayContaining(['OCR_API_KEY']) }), + ); + expect(mockAxios.post).toHaveBeenCalledWith( + expect.anything(), + expect.any(Object), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer env-fallback-key', + }), + }), + ); + }); + it('should handle empty configuration values and use defaults', async () => { // Set up the mock values to be returned by loadAuthValues mockLoadAuthValues.mockResolvedValue({ diff --git a/packages/api/src/files/mistral/crud.ts b/packages/api/src/files/mistral/crud.ts index db08cde9aa..5af1ef90a9 100644 --- a/packages/api/src/files/mistral/crud.ts +++ b/packages/api/src/files/mistral/crud.ts @@ -20,6 +20,7 @@ import type { OCRResult, OCRImage, } from '~/types'; +import { decryptConfigSecret, isEncryptedSecretPayload } from '~/admin/secrets'; import { logAxiosError, createAxiosInstance } from '~/utils/axios'; import { applyAxiosProxyConfig } from '~/utils/proxy'; import { readFileAsBuffer } from '~/utils/files'; @@ -256,7 +257,10 @@ async function resolveConfigValue( async function loadAuthConfig(context: OCRContext): Promise { const appConfig = context.req.config; const ocrConfig = appConfig?.ocr; - const apiKeyConfig = ocrConfig?.apiKey || ''; + const rawApiKeyConfig = ocrConfig?.apiKey || ''; + const apiKeyConfig = isEncryptedSecretPayload(rawApiKeyConfig) + ? (decryptConfigSecret(rawApiKeyConfig) ?? '') + : rawApiKeyConfig; const baseURLConfig = ocrConfig?.baseURL || ''; if (!needsEnvLoad(apiKeyConfig) && !needsEnvLoad(baseURLConfig)) { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 07c47be50b..29e8b6abb0 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -666,6 +666,11 @@ export const assistantEndpointSchema = baseEndpointSchema.merge( ]), /* general */ apiKey: z.string().optional(), + /** Masked preview of the API key, stored at write time so admin + * reads can show which key is configured without returning the secret. + * Shared by both `endpoints.assistants` and `endpoints.azureAssistants`, + * which both use this schema. */ + apiKeyPreview: z.string().optional(), models: z .object({ default: z.array(modelItemSchema).min(1), @@ -1096,9 +1101,14 @@ export const anthropicEndpointSchema = baseEndpointSchema.merge( export type TAnthropicEndpoint = z.infer; +/** Masked preview of the API key, stored at write time so admin + * reads can show which key is configured without returning the secret. */ +const apiKeyPreviewSchema = z.string().optional(); + const ttsOpenaiSchema = z.object({ url: z.string().optional(), apiKey: z.string(), + apiKeyPreview: apiKeyPreviewSchema, model: z.string(), voices: z.array(z.string()), }); @@ -1106,6 +1116,7 @@ const ttsOpenaiSchema = z.object({ const ttsAzureOpenAISchema = z.object({ instanceName: z.string(), apiKey: z.string(), + apiKeyPreview: apiKeyPreviewSchema, deploymentName: z.string(), apiVersion: z.string(), model: z.string(), @@ -1116,6 +1127,7 @@ const ttsElevenLabsSchema = z.object({ url: z.string().optional(), websocketUrl: z.string().optional(), apiKey: z.string(), + apiKeyPreview: apiKeyPreviewSchema, model: z.string(), voices: z.array(z.string()), voice_settings: z @@ -1132,6 +1144,7 @@ const ttsElevenLabsSchema = z.object({ const ttsLocalaiSchema = z.object({ url: z.string(), apiKey: z.string().optional(), + apiKeyPreview: apiKeyPreviewSchema, voices: z.array(z.string()), backend: z.string(), }); @@ -1146,12 +1159,14 @@ const ttsSchema = z.object({ const sttOpenaiSchema = z.object({ url: z.string().optional(), apiKey: z.string(), + apiKeyPreview: apiKeyPreviewSchema, model: z.string(), }); const sttAzureOpenAISchema = z.object({ instanceName: z.string(), apiKey: z.string(), + apiKeyPreview: apiKeyPreviewSchema, deploymentName: z.string(), apiVersion: z.string(), }); @@ -1634,17 +1649,23 @@ export enum SafeSearchTypes { export const webSearchSchema = z.object({ serperApiKey: z.string().optional().default('${SERPER_API_KEY}'), + serperApiKeyPreview: apiKeyPreviewSchema, searxngInstanceUrl: z.string().optional().default('${SEARXNG_INSTANCE_URL}'), searxngApiKey: z.string().optional().default('${SEARXNG_API_KEY}'), + searxngApiKeyPreview: apiKeyPreviewSchema, firecrawlApiKey: z.string().optional().default('${FIRECRAWL_API_KEY}'), + firecrawlApiKeyPreview: apiKeyPreviewSchema, firecrawlApiUrl: z.string().optional().default('${FIRECRAWL_API_URL}'), firecrawlVersion: z.string().optional().default('${FIRECRAWL_VERSION}'), tavilyApiKey: z.string().optional().default('${TAVILY_API_KEY}'), + tavilyApiKeyPreview: apiKeyPreviewSchema, tavilySearchUrl: z.string().optional().default('${TAVILY_SEARCH_URL}'), tavilyExtractUrl: z.string().optional().default('${TAVILY_EXTRACT_URL}'), jinaApiKey: z.string().optional().default('${JINA_API_KEY}'), + jinaApiKeyPreview: apiKeyPreviewSchema, jinaApiUrl: z.string().optional().default('${JINA_API_URL}'), cohereApiKey: z.string().optional().default('${COHERE_API_KEY}'), + cohereApiKeyPreview: apiKeyPreviewSchema, searchProvider: z.nativeEnum(SearchProviders).optional(), scraperProvider: z.nativeEnum(ScraperProviders).optional(), rerankerType: z.nativeEnum(RerankerTypes).optional(), @@ -1717,6 +1738,7 @@ export type TWebSearchConfig = DeepPartial>; export const ocrSchema = z.object({ mistralModel: z.string().optional(), apiKey: z.string().optional().default('${OCR_API_KEY}'), + apiKeyPreview: apiKeyPreviewSchema, baseURL: z.string().optional().default('${OCR_BASEURL}'), strategy: z.nativeEnum(OCRStrategy).default(OCRStrategy.MISTRAL_OCR), }); @@ -1845,9 +1867,9 @@ export const langfuseConfigSchema = z.object({ enabled: z.boolean().optional(), publicKey: z.string().optional(), secretKey: z.string().optional(), - /** Non-secret display value of the secret key, stored at write time so + /** Masked preview of the secret key, stored at write time so * admin reads can show which secret key is configured without returning the secret. */ - displaySecretKey: z.string().optional(), + secretKeyPreview: z.string().optional(), /** Routing key for one of the deployment-configured tenant Langfuse destinations. */ destination: z.string().optional(), fanout: z