mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🕶 feat: Generalize Admin Config Secret Redaction (#14509)
* 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<Field> 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 <field>Preview
Replace the display*-prefixed companion names (displaySecretKey,
displayApiKey, displaySerperApiKey, ...) with a uniform <field>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>:<hex> 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 <danny@librechat.ai>
This commit is contained in:
parent
c4d30a096e
commit
f4e0888f14
13 changed files with 1765 additions and 191 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
72
api/server/services/Files/Audio/TTSService.spec.js
Normal file
72
api/server/services/Files/Audio/TTSService.spec.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> };
|
||||
};
|
||||
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<string, string> };
|
||||
|
|
@ -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<string, string> };
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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<string, unknown>).langfuse,
|
||||
);
|
||||
if (langfuseInputError) {
|
||||
return res.status(400).json({ error: langfuseInputError });
|
||||
for (const section of getConfigSecretSections()) {
|
||||
const secretInputError = getConfigSecretInputError(
|
||||
section,
|
||||
(filteredOverrides as Record<string, unknown>)[section],
|
||||
);
|
||||
if (secretInputError) {
|
||||
return res.status(400).json({ error: secretInputError });
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedOverrides = encryptConfigSecrets(filteredOverrides);
|
||||
const existingForSecrets = isObjectValuedLangfusePatch(
|
||||
'langfuse',
|
||||
(filteredOverrides as Record<string, unknown>).langfuse,
|
||||
const existingForSecrets = getConfigSecretSections().some((section) =>
|
||||
isObjectValuedSecretAncestorPatch(
|
||||
section,
|
||||
(filteredOverrides as Record<string, unknown>)[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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
598
packages/api/src/admin/secrets.integration.spec.ts
Normal file
598
packages/api/src/admin/secrets.integration.spec.ts
Normal file
|
|
@ -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<typeof import('./config').createAdminConfigHandlers>;
|
||||
|
||||
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<string, unknown>;
|
||||
/** 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<string, unknown> = {}): ServerRequest {
|
||||
return {
|
||||
user: { id: 'u1', role: 'ADMIN', _id: { toString: () => 'u1' } },
|
||||
params: {},
|
||||
body: {},
|
||||
query: {},
|
||||
...overrides,
|
||||
} as Partial<ServerRequest> as ServerRequest;
|
||||
}
|
||||
|
||||
interface MockRes {
|
||||
statusCode: number;
|
||||
body: undefined | { config?: Record<string, unknown>; 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<Response> 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<string, unknown>)[segment];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
async function readRawOverrides(principalId: string): Promise<Record<string, unknown>> {
|
||||
const doc = await mongoose.models.Config.findOne({ principalId });
|
||||
expect(doc).not.toBeNull();
|
||||
expect(doc!.$isNew).toBe(false);
|
||||
return (doc!.toObject() as { overrides: Record<string, unknown> }).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<string, unknown> })
|
||||
.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<string, unknown> })
|
||||
.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<string, unknown> })
|
||||
.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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
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<string, unknown> })
|
||||
.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<string, unknown> }).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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string>;
|
||||
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, unknown>;
|
||||
azureAssistants: Record<string, unknown>;
|
||||
custom: Array<Record<string, unknown>>;
|
||||
};
|
||||
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<string, string> } };
|
||||
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<string, string>;
|
||||
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<string, string>,
|
||||
});
|
||||
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, string> } },
|
||||
});
|
||||
const existingDisplay = existing.speech.tts.openai.apiKeyPreview;
|
||||
expect(existingDisplay).toBe(getSecretPreview('sk-old'));
|
||||
|
||||
const next = preserveConfigSecrets(
|
||||
{ speech: { tts: { openai: { model: 'tts-2' } as Record<string, string> } } },
|
||||
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<string, string> },
|
||||
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<string, string> } },
|
||||
});
|
||||
expect(cleared.speech.tts.openai.apiKeyPreview).toBe('');
|
||||
const existing = encryptConfigSecrets({
|
||||
speech: { tts: { openai: { apiKey: 'sk-old' } as Record<string, string> } },
|
||||
});
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, unknown> } }).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<string, string>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 `<field>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 `<path>Preview` — recognizing a new
|
||||
* sensitive field is a one-line path addition (plus the `<field>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<string, string> = new Map([
|
||||
['langfuse.secretKey', 'langfuse.displaySecretKey'],
|
||||
]);
|
||||
|
||||
const SECRET_FIELDS_BY_PATH = new Map<string, ConfigSecretField>(
|
||||
CONFIG_SECRET_FIELDS.map((field) => [field.path, field]),
|
||||
);
|
||||
|
||||
const PREVIEW_PATHS = new Set<string>([
|
||||
...CONFIG_SECRET_FIELDS.map((field) => field.previewPath),
|
||||
...LEGACY_PREVIEW_PATHS.values(),
|
||||
]);
|
||||
|
||||
const ANCESTOR_PATHS = new Set<string>(
|
||||
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<string, unknown> | null {
|
||||
return isPlainObject(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function getLangfuseSection(root: unknown, basePath = ''): Record<string, unknown> | 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<string, unknown> | 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<string, unknown>, basePath: string): void {
|
||||
for (const field of CONFIG_SECRET_FIELDS) {
|
||||
const segments = relativeSegments(field.path, basePath);
|
||||
if (!segments) {
|
||||
continue;
|
||||
}
|
||||
let cursor: Record<string, unknown> | 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>:<hex-ciphertext>`). 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<string, unknown>): 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<string, unknown>): 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<string, unknown>, 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 `<field>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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...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<T>(root: T, basePath = ''): T {
|
||||
if (root == null || typeof root !== 'object') {
|
||||
|
|
@ -151,23 +396,36 @@ export function encryptConfigSecrets<T>(root: T, basePath = ''): T {
|
|||
}
|
||||
|
||||
const result = structuredClone(root);
|
||||
const rootRecord = result as Record<string, unknown>;
|
||||
if (basePath === '') {
|
||||
delete (result as Record<string, unknown>)[LANGFUSE_SECRET_PATH];
|
||||
delete (result as Record<string, unknown>)[LANGFUSE_DISPLAY_SECRET_PATH];
|
||||
removeLangfuseArraySection(result as Record<string, unknown>);
|
||||
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<T>(next: T, existing?: unknown, basePath = ''): T {
|
||||
if (
|
||||
|
|
@ -180,31 +438,53 @@ export function preserveConfigSecrets<T>(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<T>(root: T): T {
|
||||
|
|
@ -212,15 +492,32 @@ export function redactConfigSecrets<T>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<AuthConfig> {
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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<typeof anthropicEndpointSchema>;
|
||||
|
||||
/** 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<z.infer<typeof webSearchSchema>>;
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue