mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🗝️ feat: Custom Endpoint API Key Encryption via Array Secret Registry (#14510)
Extend the admin-config secret registry (#14509) with array-item secrets, registering endpoints.custom[*].apiKey: encryptV3 at rest with apiKeyPreview companions, redaction on admin reads (plaintext-legacy included, with verbatim-name omit-to-keep preservation, duplicate-identity skip, and legacy-plaintext self-healing), passthrough for user_provided/${ENV} refs, strict-payload runtime decryption at getCustomEndpointConfig, the custom model fetch, and the provider fallback, and rejection of named/indexed/ positional writes beneath the protected array path.
This commit is contained in:
parent
7b6900d556
commit
becfc5a373
9 changed files with 710 additions and 25 deletions
|
|
@ -646,6 +646,69 @@ describe('createAdminConfigHandlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('encrypts custom endpoint API keys on full override writes and redacts responses', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({
|
||||
_id: 'c1',
|
||||
configVersion: 1,
|
||||
overrides,
|
||||
})),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
overrides: {
|
||||
endpoints: {
|
||||
custom: [
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
apiKey: 'sk-or-secret-key',
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
},
|
||||
{ name: 'EnvRef', apiKey: '${OPENROUTER_KEY}' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
|
||||
const [saved, envRef] = savedOverrides.endpoints.custom as Array<Record<string, string>>;
|
||||
expect(saved.apiKey).toBe('v3:test:sk-or-secret-key');
|
||||
expect(saved.apiKeyPreview).toBe('sk-or-...-key');
|
||||
expect(envRef.apiKey).toBe('${OPENROUTER_KEY}');
|
||||
expect(envRef.apiKeyPreview).toBeUndefined();
|
||||
const responseConfig = res.body!.config as {
|
||||
overrides: { endpoints: { custom: Array<Record<string, string>> } };
|
||||
};
|
||||
expect(responseConfig.overrides.endpoints.custom[0].apiKey).toBeUndefined();
|
||||
expect(responseConfig.overrides.endpoints.custom[0].apiKeyPreview).toBe('sk-or-...-key');
|
||||
expect(responseConfig.overrides.endpoints.custom[1].apiKey).toBe('${OPENROUTER_KEY}');
|
||||
});
|
||||
|
||||
it('rejects encrypted custom endpoint API key submissions on full override writes', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
overrides: {
|
||||
endpoints: {
|
||||
custom: [{ name: 'A', apiKey: 'v3:attacker-controlled' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
it('preserves UI sub-keys in composite permission fields like mcpServers', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
getConfigSecretSections,
|
||||
isConfigSecretAncestorPath,
|
||||
isConfigSecretDescendantPath,
|
||||
isConfigSecretPreservablePatch,
|
||||
preserveConfigSecrets,
|
||||
redactConfigSecrets,
|
||||
} from './secrets';
|
||||
|
|
@ -321,22 +322,13 @@ function redactAppConfigForResponse(appConfig: AppConfig): AppConfig {
|
|||
return safeConfig;
|
||||
}
|
||||
|
||||
function isObjectValuedSecretAncestorPatch(fieldPath: string, value: unknown): boolean {
|
||||
return (
|
||||
isConfigSecretAncestorPath(fieldPath) &&
|
||||
value != null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function preservePatchedConfigSecretFields(
|
||||
fields: Record<string, unknown>,
|
||||
existingOverrides?: unknown,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...fields };
|
||||
for (const [fieldPath, value] of Object.entries(result)) {
|
||||
if (isObjectValuedSecretAncestorPatch(fieldPath, value)) {
|
||||
if (isConfigSecretPreservablePatch(fieldPath, value)) {
|
||||
result[fieldPath] = preserveConfigSecrets(value, existingOverrides, fieldPath);
|
||||
}
|
||||
}
|
||||
|
|
@ -609,7 +601,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
|
||||
const encryptedOverrides = encryptConfigSecrets(filteredOverrides);
|
||||
const existingForSecrets = getConfigSecretSections().some((section) =>
|
||||
isObjectValuedSecretAncestorPatch(
|
||||
isConfigSecretPreservablePatch(
|
||||
section,
|
||||
(filteredOverrides as Record<string, unknown>)[section],
|
||||
),
|
||||
|
|
@ -761,7 +753,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
const requestedPriority = hasBroadManage ? priority : undefined;
|
||||
|
||||
const hasObjectValuedSecretPatch = Object.entries(fields).some(([fieldPath, value]) =>
|
||||
isObjectValuedSecretAncestorPatch(fieldPath, value),
|
||||
isConfigSecretPreservablePatch(fieldPath, value),
|
||||
);
|
||||
const existing =
|
||||
requestedPriority == null || hasObjectValuedSecretPatch
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ let isConfigSecretDescendantPath: typeof import('./secrets').isConfigSecretDesce
|
|||
let preserveConfigSecrets: typeof import('./secrets').preserveConfigSecrets;
|
||||
let redactConfigSecrets: typeof import('./secrets').redactConfigSecrets;
|
||||
let resolveConfigSecret: typeof import('./secrets').resolveConfigSecret;
|
||||
let resolveCustomEndpointSecrets: typeof import('./secrets').resolveCustomEndpointSecrets;
|
||||
let decryptV3: typeof import('@librechat/data-schemas').decryptV3;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -31,6 +32,7 @@ beforeAll(async () => {
|
|||
preserveConfigSecrets,
|
||||
redactConfigSecrets,
|
||||
resolveConfigSecret,
|
||||
resolveCustomEndpointSecrets,
|
||||
} = await import('./secrets'));
|
||||
({ decryptV3 } = await import('@librechat/data-schemas'));
|
||||
});
|
||||
|
|
@ -648,3 +650,284 @@ describe('Config secret registry fields', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom endpoint config secrets', () => {
|
||||
const endpointsWith = (custom: Array<Record<string, unknown>>) => ({ endpoints: { custom } });
|
||||
|
||||
it('encrypts literal API keys on full-document writes and stores display companions', () => {
|
||||
const out = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'OpenRouter', apiKey: 'sk-or-super-secret', baseURL: 'https://openrouter.ai' },
|
||||
]),
|
||||
);
|
||||
const entry = out.endpoints.custom[0] as Record<string, string>;
|
||||
|
||||
expect(entry.apiKey).toMatch(/^v3:/);
|
||||
expect(decryptV3(entry.apiKey)).toBe('sk-or-super-secret');
|
||||
expect(entry.apiKeyPreview).toBe('sk-or-...cret');
|
||||
expect(entry.baseURL).toBe('https://openrouter.ai');
|
||||
});
|
||||
|
||||
it('leaves user_provided and env-reference API keys readable', () => {
|
||||
const out = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'A', apiKey: 'user_provided', apiKeyPreview: 'spoofed' },
|
||||
{ name: 'B', apiKey: '${OPENROUTER_KEY}' },
|
||||
]),
|
||||
);
|
||||
const [a, b] = out.endpoints.custom as Array<Record<string, string>>;
|
||||
|
||||
expect(a.apiKey).toBe('user_provided');
|
||||
expect(a.apiKeyPreview).toBeUndefined();
|
||||
expect(b.apiKey).toBe('${OPENROUTER_KEY}');
|
||||
expect(b.apiKeyPreview).toBeUndefined();
|
||||
});
|
||||
|
||||
it('encrypts section and array patched values from field maps', () => {
|
||||
const viaSection = encryptConfigSecretFields({
|
||||
endpoints: { custom: [{ name: 'A', apiKey: 'sk-section-key' }] },
|
||||
});
|
||||
const sectionEntry = (viaSection.endpoints as { custom: Array<Record<string, string>> })
|
||||
.custom[0];
|
||||
expect(decryptV3(sectionEntry.apiKey)).toBe('sk-section-key');
|
||||
expect(sectionEntry.apiKeyPreview).toBe('sk-sec...-key');
|
||||
|
||||
const viaArray = encryptConfigSecretFields({
|
||||
'endpoints.custom': [{ name: 'A', apiKey: 'sk-array-key0' }],
|
||||
});
|
||||
const arrayEntry = (viaArray['endpoints.custom'] as Array<Record<string, string>>)[0];
|
||||
expect(decryptV3(arrayEntry.apiKey)).toBe('sk-array-key0');
|
||||
expect(arrayEntry.apiKeyPreview).toBe('sk-arr...key0');
|
||||
});
|
||||
|
||||
it('clears empty, non-string, or pre-encrypted API key submissions', () => {
|
||||
const out = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'A', apiKey: '' },
|
||||
{ name: 'B', apiKey: null },
|
||||
{ name: 'C', apiKey: 'v3:smuggled', apiKeyPreview: 'spoofed' },
|
||||
]),
|
||||
);
|
||||
|
||||
for (const item of out.endpoints.custom as Array<Record<string, string>>) {
|
||||
expect(item.apiKey).toBe('');
|
||||
expect(item.apiKeyPreview).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects encrypted submissions and indexed secret writes', () => {
|
||||
expect(
|
||||
getConfigSecretInputError('endpoints', { custom: [{ name: 'A', apiKey: 'v3:smuggled' }] }),
|
||||
).toContain('Encrypted config secret values');
|
||||
expect(
|
||||
getConfigSecretInputError('endpoints.custom', [{ name: 'A', apiKey: 'v3:smuggled' }]),
|
||||
).toContain('Encrypted config secret values');
|
||||
expect(getConfigSecretInputError('endpoints', [])).toBeNull();
|
||||
expect(getConfigSecretInputError('endpoints.custom.0.apiKey', 'sk-new')).toContain(
|
||||
'Cannot write secret fields by array index',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom.0.apiKeyPreview', undefined)).toContain(
|
||||
'Cannot write secret fields by array index',
|
||||
);
|
||||
expect(
|
||||
getConfigSecretInputError('endpoints.custom.0', { name: 'A', apiKey: 'sk-new' }),
|
||||
).toContain('Cannot replace endpoints.custom entries by array index');
|
||||
expect(getConfigSecretInputError('endpoints.custom.0', { name: 'A' })).toContain(
|
||||
'Cannot replace endpoints.custom entries by array index',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom.0.baseURL', 'https://x')).toBeNull();
|
||||
expect(getConfigSecretInputError('endpoints.custom.apiKey', 'sk-smuggled')).toContain(
|
||||
'has no named fields',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom.slot.apiKey', 'sk-smuggled')).toContain(
|
||||
'has no named fields',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom.apiKeyPreview', 'spoofed')).toContain(
|
||||
'has no named fields',
|
||||
);
|
||||
expect(
|
||||
getConfigSecretInputError('endpoints.custom', [{ name: 'A', apiKey: 'sk-plain-1234' }]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects and strips non-array protected containers', () => {
|
||||
expect(getConfigSecretInputError('endpoints', { custom: { apiKey: 'sk-smuggled' } })).toContain(
|
||||
'Protected secret container must be an array',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom', { apiKey: 'sk-smuggled' })).toContain(
|
||||
'Protected secret container must be an array',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints', { custom: null })).toContain(
|
||||
'Protected secret container must be an array',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom', null)).toContain(
|
||||
'Protected secret container must be an array',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom', undefined)).toBeNull();
|
||||
|
||||
const encrypted = encryptConfigSecrets({ endpoints: { custom: { apiKey: 'sk-smuggled' } } });
|
||||
expect(encrypted.endpoints).toEqual({});
|
||||
|
||||
const nullStripped = encryptConfigSecrets({ endpoints: { custom: null } });
|
||||
expect(nullStripped.endpoints).toEqual({});
|
||||
|
||||
const redacted = redactConfigSecrets({ endpoints: { custom: { apiKey: 'sk-smuggled' } } });
|
||||
expect(redacted.endpoints).toEqual({});
|
||||
|
||||
const fields = encryptConfigSecretFields({ 'endpoints.custom': { apiKey: 'sk-smuggled' } });
|
||||
expect(fields['endpoints.custom']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects positional-operator writes to secret fields', () => {
|
||||
expect(getConfigSecretInputError('endpoints.custom.$[].apiKey', 'sk-new-value')).toContain(
|
||||
'Cannot write secret fields by array index',
|
||||
);
|
||||
expect(getConfigSecretInputError('endpoints.custom.$.apiKeyPreview', 'spoof')).toContain(
|
||||
'Cannot write secret fields by array index',
|
||||
);
|
||||
expect(
|
||||
getConfigSecretInputError('endpoints.custom.$[elem]', { name: 'A', apiKey: 'sk-new' }),
|
||||
).toContain('Cannot replace endpoints.custom entries by array index');
|
||||
});
|
||||
|
||||
it('fully masks display previews of short secrets', () => {
|
||||
const out = encryptConfigSecrets(endpointsWith([{ name: 'A', apiKey: 'secret' }]));
|
||||
const entry = out.endpoints.custom[0] as Record<string, string>;
|
||||
|
||||
expect(decryptV3(entry.apiKey)).toBe('secret');
|
||||
expect(entry.apiKeyPreview).toBe('******');
|
||||
});
|
||||
|
||||
it('preserves omitted API keys by endpoint name across redacted round-trips', () => {
|
||||
const existing = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'OpenRouter', apiKey: 'sk-or-old-secret' },
|
||||
{ name: 'Renamed', apiKey: 'sk-renamed-1234' },
|
||||
]),
|
||||
);
|
||||
|
||||
const next = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'OpenRouter', baseURL: 'https://openrouter.ai' },
|
||||
{ name: 'BrandNew', baseURL: 'https://new.example' },
|
||||
]),
|
||||
);
|
||||
const preserved = preserveConfigSecrets(next, existing);
|
||||
const [openRouter, brandNew] = preserved.endpoints.custom as Array<Record<string, string>>;
|
||||
|
||||
expect(decryptV3(openRouter.apiKey)).toBe('sk-or-old-secret');
|
||||
expect(openRouter.apiKeyPreview).toBe('sk-or-...cret');
|
||||
expect(brandNew.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves and encrypts plaintext-legacy API keys on redacted round-trips', () => {
|
||||
const existing = endpointsWith([
|
||||
{ name: 'Legacy', apiKey: 'sk-legacy-plaintext' },
|
||||
{ name: 'EnvRef', apiKey: '${OPENROUTER_KEY}' },
|
||||
]);
|
||||
const next = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'Legacy', baseURL: 'https://legacy.example' },
|
||||
{ name: 'EnvRef', baseURL: 'https://ref.example' },
|
||||
]),
|
||||
);
|
||||
|
||||
const preserved = preserveConfigSecrets(next, existing);
|
||||
const [legacy, envRef] = preserved.endpoints.custom as Array<Record<string, string>>;
|
||||
|
||||
expect(legacy.apiKey).toMatch(/^v3:/);
|
||||
expect(decryptV3(legacy.apiKey)).toBe('sk-legacy-plaintext');
|
||||
expect(legacy.apiKeyPreview).toBe('sk-leg...text');
|
||||
expect(envRef.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches identities verbatim so whitespace-distinct names keep their own keys', () => {
|
||||
const existing = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'Prod', apiKey: 'sk-prod-exact-key' },
|
||||
{ name: ' Prod ', apiKey: 'sk-prod-spaced-key' },
|
||||
]),
|
||||
);
|
||||
const next = encryptConfigSecrets(endpointsWith([{ name: 'Prod' }, { name: ' Prod ' }]));
|
||||
|
||||
const preserved = preserveConfigSecrets(next, existing);
|
||||
const [exact, spaced] = preserved.endpoints.custom as Array<Record<string, string>>;
|
||||
|
||||
expect(decryptV3(exact.apiKey)).toBe('sk-prod-exact-key');
|
||||
expect(decryptV3(spaced.apiKey)).toBe('sk-prod-spaced-key');
|
||||
});
|
||||
|
||||
it('does not preserve keys for duplicated endpoint identities', () => {
|
||||
const existing = encryptConfigSecrets(
|
||||
endpointsWith([
|
||||
{ name: 'Doubled', apiKey: 'sk-first-key-value' },
|
||||
{ name: 'Doubled', apiKey: 'sk-second-key-value' },
|
||||
{ name: 'Unique', apiKey: 'sk-unique-key-value' },
|
||||
]),
|
||||
);
|
||||
const next = encryptConfigSecrets(endpointsWith([{ name: 'Doubled' }, { name: 'Unique' }]));
|
||||
|
||||
const preserved = preserveConfigSecrets(next, existing);
|
||||
const [doubled, unique] = preserved.endpoints.custom as Array<Record<string, string>>;
|
||||
|
||||
expect(doubled.apiKey).toBeUndefined();
|
||||
expect(decryptV3(unique.apiKey)).toBe('sk-unique-key-value');
|
||||
});
|
||||
|
||||
it('preserves omitted API keys for array-valued patches, not cleared ones', () => {
|
||||
const existing = encryptConfigSecrets(endpointsWith([{ name: 'A', apiKey: 'sk-old-value' }]));
|
||||
|
||||
const kept = preserveConfigSecrets(
|
||||
[{ name: 'A', baseURL: 'https://a.example' }],
|
||||
existing,
|
||||
'endpoints.custom',
|
||||
) as Array<Record<string, string>>;
|
||||
expect(decryptV3(kept[0].apiKey)).toBe('sk-old-value');
|
||||
|
||||
const cleared = preserveConfigSecrets(
|
||||
encryptConfigSecrets([{ name: 'A', apiKey: '' }], 'endpoints.custom'),
|
||||
existing,
|
||||
'endpoints.custom',
|
||||
) as Array<Record<string, string>>;
|
||||
expect(cleared[0].apiKey).toBe('');
|
||||
expect(cleared[0].apiKeyPreview).toBe('');
|
||||
});
|
||||
|
||||
it('redacts encrypted and plaintext-legacy keys while keeping readable references', () => {
|
||||
const redacted = redactConfigSecrets({
|
||||
endpoints: {
|
||||
custom: [
|
||||
{ name: 'A', apiKey: 'v3:abc:def', apiKeyPreview: 'sk-a...key' },
|
||||
{ name: 'B', apiKey: 'sk-plaintext-legacy' },
|
||||
{ name: 'C', apiKey: 'user_provided' },
|
||||
{ name: 'D', apiKey: '${OPENROUTER_KEY}' },
|
||||
{ name: 'E', apiKey: '' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const [a, b, c, d, e] = redacted.endpoints.custom as Array<Record<string, string>>;
|
||||
|
||||
expect(a.apiKey).toBeUndefined();
|
||||
expect(a.apiKeyPreview).toBe('sk-a...key');
|
||||
expect(b.apiKey).toBeUndefined();
|
||||
expect(c.apiKey).toBe('user_provided');
|
||||
expect(d.apiKey).toBe('${OPENROUTER_KEY}');
|
||||
expect(e.apiKey).toBe('');
|
||||
});
|
||||
|
||||
it('resolves stored values for runtime use', () => {
|
||||
const encrypted = encryptConfigSecrets(endpointsWith([{ name: 'A', apiKey: 'sk-runtime' }]))
|
||||
.endpoints.custom[0] as Record<string, string>;
|
||||
|
||||
expect(resolveConfigSecret(encrypted.apiKey)).toBe('sk-runtime');
|
||||
expect(resolveConfigSecret('sk-plain')).toBe('sk-plain');
|
||||
expect(resolveConfigSecret('${OPENROUTER_KEY}')).toBe('${OPENROUTER_KEY}');
|
||||
expect(resolveConfigSecret('v3:provider-literal-token')).toBe('v3:provider-literal-token');
|
||||
expect(resolveConfigSecret('v3:not-valid-ciphertext')).toBe('v3:not-valid-ciphertext');
|
||||
|
||||
const resolved = resolveCustomEndpointSecrets({ name: 'A', apiKey: encrypted.apiKey });
|
||||
expect(resolved.apiKey).toBe('sk-runtime');
|
||||
const passthrough = { name: 'B', apiKey: 'user_provided' };
|
||||
expect(resolveCustomEndpointSecrets(passthrough)).toBe(passthrough);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import { encryptV3, decryptV3, logger } from '@librechat/data-schemas';
|
||||
import { envVarRegex, extractEnvVariable } from 'librechat-data-provider';
|
||||
import { isUserProvided } from '~/utils/common';
|
||||
|
||||
const ENCRYPTED_PREFIX = 'v3:';
|
||||
const ENCRYPTED_PAYLOAD_REGEX = /^v3:[0-9a-f]{32}:[0-9a-f]+$/;
|
||||
|
|
@ -54,6 +55,37 @@ const LEGACY_PREVIEW_PATHS: ReadonlyMap<string, string> = new Map([
|
|||
['langfuse.secretKey', 'langfuse.displaySecretKey'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* A secret stored on every item of an array config field, which dot-path
|
||||
* registry entries cannot express.
|
||||
*/
|
||||
interface ArraySecretField {
|
||||
/** Dot-path of the array container within config overrides */
|
||||
arrayPath: string;
|
||||
secretKey: string;
|
||||
/** Masked-preview companion on each item, always the sibling `<secretKey>Preview`. */
|
||||
previewKey: string;
|
||||
/** Item field matched verbatim across writes for omit-to-keep round-trips. */
|
||||
identityKey: string;
|
||||
/** Reference values that must stay readable and never encrypt, e.g. `user_provided`, `${ENV_VAR}`. */
|
||||
isPassthroughValue: (value: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of array-item secret locations, the sibling of
|
||||
* `CONFIG_SECRET_FIELDS` for secrets that live on entries of an array
|
||||
* (e.g. `endpoints.custom[*].apiKey`).
|
||||
*/
|
||||
const ARRAY_SECRET_FIELDS: readonly ArraySecretField[] = [
|
||||
{
|
||||
arrayPath: 'endpoints.custom',
|
||||
secretKey: 'apiKey',
|
||||
previewKey: 'apiKeyPreview',
|
||||
identityKey: 'name',
|
||||
isPassthroughValue: (value) => isUserProvided(value) || envVarRegex.test(value),
|
||||
},
|
||||
];
|
||||
|
||||
const SECRET_FIELDS_BY_PATH = new Map<string, ConfigSecretField>(
|
||||
CONFIG_SECRET_FIELDS.map((field) => [field.path, field]),
|
||||
);
|
||||
|
|
@ -71,7 +103,10 @@ const ANCESTOR_PATHS = new Set<string>(
|
|||
);
|
||||
|
||||
const SECRET_SECTIONS: readonly string[] = [
|
||||
...new Set(CONFIG_SECRET_FIELDS.map((field) => field.path.split('.')[0])),
|
||||
...new Set([
|
||||
...CONFIG_SECRET_FIELDS.map((field) => field.path.split('.')[0]),
|
||||
...ARRAY_SECRET_FIELDS.map((field) => field.arrayPath.split('.')[0]),
|
||||
]),
|
||||
];
|
||||
|
||||
export function getSecretPreview(secret: string): string {
|
||||
|
|
@ -162,6 +197,13 @@ function isConfigSecretRelatedPath(fieldPath: string): boolean {
|
|||
if (SECRET_FIELDS_BY_PATH.has(fieldPath) || PREVIEW_PATHS.has(fieldPath)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
ARRAY_SECRET_FIELDS.some(
|
||||
(field) => fieldPath === field.arrayPath || fieldPath.startsWith(`${field.arrayPath}.`),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return ANCESTOR_PATHS.has(fieldPath) || isConfigSecretDescendantPath(fieldPath);
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +276,10 @@ export function getConfigSecretInputError(fieldPath: string, value: unknown): st
|
|||
if (SECRET_FIELDS_BY_PATH.has(fieldPath) && isEncryptedConfigSecret(value)) {
|
||||
return `Encrypted config secret values cannot be submitted: ${fieldPath}`;
|
||||
}
|
||||
const arrayError = getArraySecretInputError(fieldPath, value);
|
||||
if (arrayError) {
|
||||
return arrayError;
|
||||
}
|
||||
if (!isConfigSecretAncestorPath(fieldPath)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -275,6 +321,238 @@ function migrateLegacyPreviewKey(section: Record<string, unknown>, field: Config
|
|||
delete section[lastSegment(legacyPath)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a registered secret array within `root`, where `basePath` identifies
|
||||
* what `root` is: `''` for a whole overrides/config object, the array's parent
|
||||
* section, or the array path itself.
|
||||
*/
|
||||
function getSecretArray(root: unknown, field: ArraySecretField, basePath = ''): unknown[] | null {
|
||||
if (basePath === field.arrayPath) {
|
||||
return Array.isArray(root) ? root : null;
|
||||
}
|
||||
const segments = relativeSegments(field.arrayPath, basePath);
|
||||
if (!segments) {
|
||||
return null;
|
||||
}
|
||||
const container = walkToParent(root, segments);
|
||||
const array = container?.[segments[segments.length - 1]];
|
||||
return Array.isArray(array) ? array : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-string entry identity for preserve matching. Deliberately untrimmed:
|
||||
* the runtime config merge keys entries by their verbatim name, so `"Prod"`
|
||||
* and `" Prod "` are distinct endpoints with distinct credentials.
|
||||
*/
|
||||
function getEntryIdentity(
|
||||
entry: Record<string, unknown> | null,
|
||||
field: ArraySecretField,
|
||||
): string | undefined {
|
||||
const value = entry?.[field.identityKey];
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a present non-array protected container (e.g. an object- or
|
||||
* null-valued `endpoints.custom`) so malformed input can never carry secrets
|
||||
* past the encryption and redaction traversals.
|
||||
*/
|
||||
function removeMalformedSecretContainers(root: unknown, basePath = ''): void {
|
||||
for (const field of ARRAY_SECRET_FIELDS) {
|
||||
const segments = relativeSegments(field.arrayPath, basePath);
|
||||
if (!segments) {
|
||||
continue;
|
||||
}
|
||||
const container = walkToParent(root, segments);
|
||||
const arrayKey = segments[segments.length - 1];
|
||||
if (container != null && arrayKey in container && !Array.isArray(container[arrayKey])) {
|
||||
delete container[arrayKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyArraySecretWrites(entries: unknown[], field: ArraySecretField): void {
|
||||
for (const item of entries) {
|
||||
const entry = getPlainRecord(item);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (!(field.secretKey in entry)) {
|
||||
delete entry[field.previewKey];
|
||||
continue;
|
||||
}
|
||||
const rawValue = entry[field.secretKey];
|
||||
if (typeof rawValue !== 'string' || rawValue.startsWith(ENCRYPTED_PREFIX)) {
|
||||
entry[field.secretKey] = '';
|
||||
entry[field.previewKey] = '';
|
||||
continue;
|
||||
}
|
||||
const value = normalizeSecretString(rawValue);
|
||||
if (!value) {
|
||||
entry[field.secretKey] = '';
|
||||
entry[field.previewKey] = '';
|
||||
continue;
|
||||
}
|
||||
if (field.isPassthroughValue(value)) {
|
||||
entry[field.secretKey] = value;
|
||||
delete entry[field.previewKey];
|
||||
continue;
|
||||
}
|
||||
entry[field.secretKey] = encryptV3(value);
|
||||
entry[field.previewKey] = getSecretPreview(value);
|
||||
}
|
||||
}
|
||||
|
||||
function preserveArraySecrets(result: unknown, existing: unknown, basePath: string): void {
|
||||
for (const field of ARRAY_SECRET_FIELDS) {
|
||||
const entries = getSecretArray(result, field, basePath);
|
||||
const existingEntries = getSecretArray(existing, field);
|
||||
if (!entries || !existingEntries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const duplicateIdentities = new Set<string>();
|
||||
const existingByIdentity = new Map<string, Record<string, unknown>>();
|
||||
for (const item of existingEntries) {
|
||||
const entry = getPlainRecord(item);
|
||||
const identity = getEntryIdentity(entry, field);
|
||||
if (!entry || identity === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (existingByIdentity.has(identity)) {
|
||||
duplicateIdentities.add(identity);
|
||||
continue;
|
||||
}
|
||||
existingByIdentity.set(identity, entry);
|
||||
}
|
||||
|
||||
for (const item of entries) {
|
||||
const entry = getPlainRecord(item);
|
||||
if (!entry || field.secretKey in entry) {
|
||||
continue;
|
||||
}
|
||||
const identity = getEntryIdentity(entry, field);
|
||||
if (identity === undefined || duplicateIdentities.has(identity)) {
|
||||
continue;
|
||||
}
|
||||
const existingEntry = existingByIdentity.get(identity);
|
||||
const existingSecret = normalizeSecretString(existingEntry?.[field.secretKey]);
|
||||
if (!existingEntry || !existingSecret) {
|
||||
continue;
|
||||
}
|
||||
if (isEncryptedConfigSecret(existingSecret)) {
|
||||
entry[field.secretKey] = existingSecret;
|
||||
if (typeof existingEntry[field.previewKey] === 'string') {
|
||||
entry[field.previewKey] = existingEntry[field.previewKey];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (field.isPassthroughValue(existingSecret)) {
|
||||
continue;
|
||||
}
|
||||
entry[field.secretKey] = encryptV3(existingSecret);
|
||||
entry[field.previewKey] = getSecretPreview(existingSecret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRedactArraySecretValue(value: unknown, field: ArraySecretField): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (isEncryptedConfigSecret(value)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeSecretString(value);
|
||||
return normalized != null && !field.isPassthroughValue(normalized);
|
||||
}
|
||||
|
||||
/** Numeric indices plus MongoDB positional operators (`$`, `$[]`, `$[id]`). */
|
||||
function isArrayIndexSegment(segment: string): boolean {
|
||||
return /^\d+$/.test(segment) || segment.includes('$');
|
||||
}
|
||||
|
||||
function getArraySecretPathError(fieldPath: string): string | null {
|
||||
for (const field of ARRAY_SECRET_FIELDS) {
|
||||
const prefix = `${field.arrayPath}.`;
|
||||
if (!fieldPath.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const segments = fieldPath.slice(prefix.length).split('.');
|
||||
if (!isArrayIndexSegment(segments[0])) {
|
||||
return `${field.arrayPath} is an array and has no named fields: ${fieldPath}. Write the ${field.arrayPath} array instead`;
|
||||
}
|
||||
if (segments.length === 1) {
|
||||
return `Cannot replace ${field.arrayPath} entries by array index: ${fieldPath}. Write the ${field.arrayPath} array instead`;
|
||||
}
|
||||
if (segments[1] === field.secretKey || segments[1] === field.previewKey) {
|
||||
return `Cannot write secret fields by array index: ${fieldPath}. Write the ${field.arrayPath} array instead`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getArraySecretInputError(fieldPath: string, value: unknown): string | null {
|
||||
const pathError = getArraySecretPathError(fieldPath);
|
||||
if (pathError) {
|
||||
return pathError;
|
||||
}
|
||||
for (const field of ARRAY_SECRET_FIELDS) {
|
||||
const basePath =
|
||||
fieldPath === field.arrayPath || relativeSegments(field.arrayPath, fieldPath) != null
|
||||
? fieldPath
|
||||
: null;
|
||||
if (basePath == null) {
|
||||
continue;
|
||||
}
|
||||
if (fieldPath === field.arrayPath) {
|
||||
if (value !== undefined && !Array.isArray(value)) {
|
||||
return `Protected secret container must be an array: ${field.arrayPath}`;
|
||||
}
|
||||
} else {
|
||||
const segments = relativeSegments(field.arrayPath, fieldPath) ?? [];
|
||||
const container = walkToParent(value, segments);
|
||||
const arrayKey = segments[segments.length - 1];
|
||||
if (container != null && arrayKey in container && !Array.isArray(container[arrayKey])) {
|
||||
return `Protected secret container must be an array: ${field.arrayPath}`;
|
||||
}
|
||||
}
|
||||
const entries = getSecretArray(value, field, fieldPath);
|
||||
if (
|
||||
entries?.some((entry) => isEncryptedConfigSecret(getPlainRecord(entry)?.[field.secretKey]))
|
||||
) {
|
||||
return `Encrypted config secret values cannot be submitted: ${field.arrayPath}[].${field.secretKey}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of a custom endpoint config with its stored `apiKey`
|
||||
* decrypted for runtime use; unencrypted configs return unchanged. Decryption
|
||||
* failures resolve to an empty string (never the ciphertext) so downstream
|
||||
* requests fail visibly instead of sending an encrypted blob as a credential.
|
||||
*/
|
||||
export function resolveCustomEndpointSecrets<T extends { apiKey?: string }>(endpointConfig: T): T {
|
||||
const apiKey = endpointConfig.apiKey;
|
||||
if (typeof apiKey !== 'string' || !isEncryptedSecretPayload(apiKey)) {
|
||||
return endpointConfig;
|
||||
}
|
||||
return { ...endpointConfig, apiKey: decryptConfigSecret(apiKey) ?? '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a patched value at `fieldPath` is shaped such that omitted secrets
|
||||
* should be preserved from the existing overrides: an object at a registered
|
||||
* ancestor path, or an array at a registered array-secret path.
|
||||
*/
|
||||
export function isConfigSecretPreservablePatch(fieldPath: string, value: unknown): boolean {
|
||||
if (isConfigSecretAncestorPath(fieldPath) && isPlainObject(value)) {
|
||||
return true;
|
||||
}
|
||||
return ARRAY_SECRET_FIELDS.some((field) => field.arrayPath === fieldPath && Array.isArray(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a secret value in place within its parent record. Empty and
|
||||
* non-string values reset the secret (and preview companion). Env placeholder
|
||||
|
|
@ -363,6 +641,18 @@ export function encryptConfigSecretFields(
|
|||
const result: Record<string, unknown> = { ...fields };
|
||||
|
||||
for (const key of Object.keys(result)) {
|
||||
if (getArraySecretPathError(key) !== null) {
|
||||
delete result[key];
|
||||
continue;
|
||||
}
|
||||
if (ARRAY_SECRET_FIELDS.some((field) => field.arrayPath === key)) {
|
||||
if (Array.isArray(result[key])) {
|
||||
result[key] = encryptConfigSecrets(result[key], key);
|
||||
} else {
|
||||
delete result[key];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isConfigSecretAncestorPath(key)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -407,6 +697,7 @@ export function encryptConfigSecrets<T>(root: T, basePath = ''): T {
|
|||
}
|
||||
}
|
||||
pruneSecretAncestorArrays(rootRecord, basePath);
|
||||
removeMalformedSecretContainers(rootRecord, basePath);
|
||||
|
||||
for (const field of CONFIG_SECRET_FIELDS) {
|
||||
const segments = relativeSegments(field.path, basePath);
|
||||
|
|
@ -418,6 +709,13 @@ export function encryptConfigSecrets<T>(root: T, basePath = ''): T {
|
|||
writeSecretIntoSection(section, field);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ARRAY_SECRET_FIELDS) {
|
||||
const entries = getSecretArray(result, field, basePath);
|
||||
if (entries) {
|
||||
applyArraySecretWrites(entries, field);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -478,6 +776,8 @@ export function preserveConfigSecrets<T>(next: T, existing?: unknown, basePath =
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
preserveArraySecrets(result, existing, basePath);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -501,6 +801,7 @@ export function redactConfigSecrets<T>(root: T): T {
|
|||
}
|
||||
}
|
||||
pruneSecretAncestorArrays(rootRecord, '');
|
||||
removeMalformedSecretContainers(rootRecord);
|
||||
|
||||
for (const field of CONFIG_SECRET_FIELDS) {
|
||||
const segments = field.path.split('.');
|
||||
|
|
@ -519,5 +820,18 @@ export function redactConfigSecrets<T>(root: T): T {
|
|||
}
|
||||
delete section[key];
|
||||
}
|
||||
|
||||
for (const field of ARRAY_SECRET_FIELDS) {
|
||||
const entries = getSecretArray(rootRecord, field);
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
for (const item of entries) {
|
||||
const entry = getPlainRecord(item);
|
||||
if (entry && shouldRedactArraySecretValue(entry[field.secretKey], field)) {
|
||||
delete entry[field.secretKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { getTransactionsConfig, getBalanceConfig, getCustomEndpointConfig } from './config';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { logger, encryptV3 } from '@librechat/data-schemas';
|
||||
import { FileSources, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { getTransactionsConfig, getBalanceConfig, getCustomEndpointConfig } from './config';
|
||||
|
||||
// Helper function to create a minimal AppConfig for testing
|
||||
const createTestAppConfig = (overrides: Partial<AppConfig> = {}): AppConfig => {
|
||||
|
|
@ -32,11 +32,19 @@ const createTestAppConfig = (overrides: Partial<AppConfig> = {}): AppConfig => {
|
|||
};
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => {
|
||||
process.env.CREDS_KEY =
|
||||
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
const actual = jest.requireActual('@librechat/data-schemas');
|
||||
return {
|
||||
encryptV3: actual.encryptV3,
|
||||
decryptV3: actual.decryptV3,
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
isEnabled: jest.fn((value) => value === 'true'),
|
||||
|
|
@ -318,6 +326,25 @@ describe('getCustomEndpointConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should decrypt admin-encrypted API keys without mutating the stored config', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
endpoints: {
|
||||
[EModelEndpoint.custom]: [
|
||||
{
|
||||
name: 'Encrypted',
|
||||
apiKey: encryptV3('sk-real-key'),
|
||||
baseURL: 'https://encrypted.example',
|
||||
} as TEndpoint,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = getCustomEndpointConfig({ endpoint: 'Encrypted', appConfig });
|
||||
expect(result?.apiKey).toBe('sk-real-key');
|
||||
expect(result?.baseURL).toBe('https://encrypted.example');
|
||||
expect(appConfig.endpoints?.[EModelEndpoint.custom]?.[0].apiKey).toMatch(/^v3:/);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching for Ollama endpoint', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
endpoints: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { resolveCustomEndpointSecrets } from '~/admin/secrets';
|
||||
import { isEnabled } from '~/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -63,8 +64,8 @@ export const getCustomEndpointConfig = ({
|
|||
}
|
||||
|
||||
const customEndpoints = appConfig.endpoints?.[EModelEndpoint.custom] ?? [];
|
||||
return customEndpoints.find(
|
||||
(endpointConfig) =>
|
||||
normalizeEndpointName(endpointConfig.name) === normalizeEndpointName(endpoint),
|
||||
const endpointConfig = customEndpoints.find(
|
||||
(config) => normalizeEndpointName(config.name) === normalizeEndpointName(endpoint),
|
||||
);
|
||||
return endpointConfig && resolveCustomEndpointSecrets(endpointConfig);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { GetAppConfigOptions } from '~/app/service';
|
|||
import { fetchModels as defaultFetchModels } from '~/endpoints/models';
|
||||
import { getTokenConfigKey } from '~/endpoints/custom/initialize';
|
||||
import { getAppConfigOptionsFromUser } from '~/app/service';
|
||||
import { resolveConfigSecret } from '~/admin/secrets';
|
||||
import { validateEndpointURL } from '~/auth';
|
||||
import { tokenConfigCache } from '~/cache';
|
||||
import { isUserProvided } from '~/utils';
|
||||
|
|
@ -107,7 +108,7 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) {
|
|||
endpointsMap[name] = endpoint;
|
||||
modelsConfig[name] = [];
|
||||
|
||||
const resolvedApiKey = extractEnvVariable(apiKey);
|
||||
const resolvedApiKey = resolveConfigSecret(apiKey) ?? '';
|
||||
const resolvedBaseURL = extractEnvVariable(baseURL);
|
||||
const entry: ResolvedEndpoint = {
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { EModelEndpoint } from 'librechat-data-provider';
|
|||
import type { TEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { BaseInitializeParams, InitializeResultBase } from '~/types';
|
||||
import { resolveCustomEndpointSecrets } from '~/admin/secrets';
|
||||
import { initializeAnthropic } from '../anthropic/initialize';
|
||||
import { initializeBedrock } from '../bedrock/initialize';
|
||||
import { initializeCustom } from '../custom/initialize';
|
||||
|
|
@ -191,7 +192,7 @@ export function getProviderConfig({
|
|||
`Provider ${provider} is ambiguous: multiple custom endpoints match case-insensitively (${names}). Rename one or use the exact-case provider value.`,
|
||||
);
|
||||
}
|
||||
customEndpointConfig = matches[0];
|
||||
customEndpointConfig = matches[0] && resolveCustomEndpointSecrets(matches[0]);
|
||||
}
|
||||
if (!customEndpointConfig) {
|
||||
throw new Error(`Provider ${provider} not supported`);
|
||||
|
|
|
|||
|
|
@ -988,6 +988,9 @@ export const endpointSchema = baseEndpointSchema.merge(
|
|||
).join(', ')}`,
|
||||
}),
|
||||
apiKey: z.string(),
|
||||
/** Masked preview of the API key, stored at write time so admin
|
||||
* reads can show which key is configured without returning the secret. */
|
||||
apiKeyPreview: z.string().optional(),
|
||||
baseURL: z.string(),
|
||||
models: z.object({
|
||||
default: z.array(modelItemSchema).min(1),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue