From f4e0888f14b97f8bd205c8c529c399aab14fb727 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:55:45 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=B6=20feat:=20Generalize=20Admin=20Con?= =?UTF-8?q?fig=20Secret=20Redaction=20(#14509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: generalize admin config secret redaction to a field registry Replace the single hardcoded langfuse.secretKey handling in the admin config secrets module with a registry (CONFIG_SECRET_FIELDS) so every credential-shaped config field is encrypted at rest, redacted on read, and preserved when omitted on a subsequent write. Registry covers langfuse.secretKey plus speech tts/stt provider apiKeys, ocr.apiKey, the webSearch provider apiKeys, and the assistants / azureAssistants endpoint apiKeys. Fields that conventionally hold ${ENV_VAR} references keep those references as plain, visible values; literal secrets are always encrypted. langfuse.secretKey behavior (display companion, always-encrypt, array-section handling) is unchanged. Wire runtime decryption for the consumers that read these values from the merged app config: resolveConfigSecret in the speech STT/TTS services and decryptConfigSecret in the Mistral OCR auth loader. Legacy plaintext literals and ${ENV_VAR} references continue to resolve. * feat: add masked display companions for every registered config secret Every non-langfuse field added to the secret registry was missing the displayPath that langfuse.secretKey already had, so redacted admin reads returned nothing for those fields instead of a masked value like sk-mis...Z789. The registry-driven encrypt/redact/preserve/mutation-path logic in secrets.ts was already field-agnostic; the only backend fix is setting displayPath on the other 15 registry entries. Add the matching optional display companion to each zod schema in librechat-data-provider (ocr, speech tts/stt providers, webSearch providers, the shared assistants/azureAssistants endpoint schema) so the field is typed for consumers, mirroring langfuse.displaySecretKey. DB overrides are Mixed-typed, so nothing breaks without this at the storage layer, but the type is needed for any typed consumer of TCustomConfig. A display path can never be written as a secret: direct writes to it are rejected, and an ancestor-object write that includes a spoofed display value alongside or instead of the real secret is overwritten or dropped, never encrypted or persisted. * fix: harden the config secret write path against masking and mixing bugs getDisplaySecretKey disclosed the entire value for any secret of 10 characters or fewer, since the first-6/last-4 mask overlaps or covers the whole string at that length (e.g. self-hosted LocalAI tokens). Short secrets are now fully masked instead. writeSecretIntoSection/writeDottedSecret encrypted a literal secret's raw string verbatim, including leading/trailing whitespace, so a padded paste round-tripped with the whitespace intact and a whitespace-only value was not treated as empty. Literals are now trimmed before encrypting and masking. Both functions also returned early on an env-placeholder value without clearing the display companion, so replacing a literal secret with ${ENV_VAR} left the previous masked value stale in the stored config, and a client-supplied display value submitted alongside a placeholder secret was never overwritten. The placeholder branch now clears the display companion in both the dotted-patch and object-valued write paths. * fix: fail closed in Mistral OCR auth when a stored ciphertext can't decrypt loadAuthConfig fell back to the raw v3: ciphertext string whenever decryptConfigSecret returned undefined, so a corrupted or otherwise undecryptable stored secret was sent to the Mistral API verbatim as the apiKey instead of triggering the existing env-var fallback. isEncryptedConfigSecret is now exported so the OCR auth loader can distinguish "this looks like ciphertext and failed to decrypt" from "this was never encrypted" and treat only the former as empty, preserving literal and ${ENV_VAR} values exactly as before. * fix: omit undecryptable TTS provider headers instead of sending "undefined" openAIProvider, elevenLabsProvider, and localAIProvider built their Authorization/xi-api-key headers directly from resolveConfigSecret's return value, which is undefined on a decrypt failure. That produced a literal "Bearer undefined" header (or an undefined-valued xi-api-key header) sent to the provider instead of failing gracefully. Each provider now resolves the key once and only includes the header when it's non-empty, matching the pattern already used by azureOpenAIProvider and STTService's providers. * fix: strip secret-ancestor arrays at any depth, not just the top level encryptConfigSecrets/redactConfigSecrets only stripped an array-valued registered-secret ancestor when it appeared as a literal top-level key (e.g. a dotted "speech.tts.openai" key). A true nested array at any depth, e.g. { speech: { tts: { openai: [{ apiKey: "sk-secret" }] } } }, made walkToParent return null and silently skip that field entirely, so the literal secret was stored unencrypted and returned verbatim to any reader with section-level read access. pruneSecretAncestorArrays now walks every registered field's ancestor chain and deletes any array found at any depth before encryption or redaction runs, closing the gap for both write and read paths. * fix: migrate legacy plaintext secrets instead of dropping them on preserve preserveConfigSecrets only restored an omitted secret when the existing stored value was already v3-encrypted. Every field this PR newly registers was previously stored as plaintext with no protection at all, so any deployment upgrading into this registry has real credentials sitting in Mongo as plaintext today. The first time an admin edited an unrelated field in the same section (e.g. mistralModel next to ocr.apiKey), the omitted plaintext secret failed the "already encrypted" check and was silently dropped instead of preserved, breaking the integration. The existing value is now encrypted in place when it isn't already ciphertext or an allowed env placeholder, so the credential survives the edit and gets a computed display companion instead of being lost. * refactor: derive masked-preview companions as Preview Replace the display*-prefixed companion names (displaySecretKey, displayApiKey, displaySerperApiKey, ...) with a uniform Preview suffix (secretKeyPreview, apiKeyPreview, serperApiKeyPreview, ...) derived automatically from the registered secret path — registry entries no longer declare a displayPath, and the name never collides with display-label config fields like modelDisplayLabel. Legacy langfuse.displaySecretKey companions (the only shipped instance, with no released reader) are stripped from writes and reads and migrated to secretKeyPreview on preserve, so stored documents self-clean. Also reject MongoDB operator segments ($, $[], $[id]) in admin config field paths: isValidFieldPath previously accepted them, letting a patch like webSearch.$[].serperApiKey reach patchConfigFields as a positional update that bypassed secret-path validation. * fix: translate legacy displaySecretKey to secretKeyPreview on reads Redaction previously deleted the legacy companion outright, so the first admin read of a not-yet-migrated document showed no configured-secret indication until a later write migrated it. Reads now surface the legacy value under secretKeyPreview (when no new-name preview exists) while still stripping the legacy key from the response; stored documents migrate for real on their next write. * fix: detect runtime ciphertext by full encryptV3 payload shape Runtime resolution (resolveConfigSecret, mistral OCR auth) now identifies decryptable values by the exact v3:<32-hex-iv>: shape encryptV3 produces instead of the bare v3: prefix, so a legitimate literal credential that merely starts with v3: (e.g. from YAML, which the admin write path never encrypts) resolves as a literal instead of failing decryption. Write-side prefix rejection stays broad as spoof/echo defense. --------- Co-authored-by: Danny Avila --- api/server/services/Files/Audio/STTService.js | 11 +- api/server/services/Files/Audio/TTSService.js | 23 +- .../services/Files/Audio/TTSService.spec.js | 72 +++ packages/api/src/admin/config.handler.spec.ts | 60 +- packages/api/src/admin/config.spec.ts | 7 + packages/api/src/admin/config.ts | 34 +- packages/api/src/admin/index.ts | 1 + .../api/src/admin/secrets.integration.spec.ts | 598 ++++++++++++++++++ packages/api/src/admin/secrets.spec.ts | 495 ++++++++++++++- packages/api/src/admin/secrets.ts | 523 +++++++++++---- packages/api/src/files/mistral/crud.spec.ts | 100 +++ packages/api/src/files/mistral/crud.ts | 6 +- packages/data-provider/src/config.ts | 26 +- 13 files changed, 1765 insertions(+), 191 deletions(-) create mode 100644 api/server/services/Files/Audio/TTSService.spec.js create mode 100644 packages/api/src/admin/secrets.integration.spec.ts 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