From becfc5a37384ea0e8a81b776526db2cec974be99 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 29 Jul 2026 15:37:46 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=9D=EF=B8=8F=20feat:=20Custom=20Endpoi?= =?UTF-8?q?nt=20API=20Key=20Encryption=20via=20Array=20Secret=20Registry?= =?UTF-8?q?=20(#14510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/api/src/admin/config.handler.spec.ts | 63 ++++ packages/api/src/admin/config.ts | 16 +- packages/api/src/admin/secrets.spec.ts | 283 ++++++++++++++++ packages/api/src/admin/secrets.ts | 316 +++++++++++++++++- packages/api/src/app/config.test.ts | 41 ++- packages/api/src/app/config.ts | 7 +- packages/api/src/endpoints/config/models.ts | 3 +- .../api/src/endpoints/config/providers.ts | 3 +- packages/data-provider/src/config.ts | 3 + 9 files changed, 710 insertions(+), 25 deletions(-) diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index babc42c0b9..100f4a566a 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -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>; + 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> } }; + }; + 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 }), diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index f5709b8b19..171bdbdaab 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -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, existingOverrides?: unknown, ): Record { 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)[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 diff --git a/packages/api/src/admin/secrets.spec.ts b/packages/api/src/admin/secrets.spec.ts index dfbfa3595e..39557454a6 100644 --- a/packages/api/src/admin/secrets.spec.ts +++ b/packages/api/src/admin/secrets.spec.ts @@ -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>) => ({ 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; + + 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>; + + 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> }) + .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>)[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>) { + 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; + + 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>; + + 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>; + + 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>; + + 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>; + + 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>; + expect(decryptV3(kept[0].apiKey)).toBe('sk-old-value'); + + const cleared = preserveConfigSecrets( + encryptConfigSecrets([{ name: 'A', apiKey: '' }], 'endpoints.custom'), + existing, + 'endpoints.custom', + ) as Array>; + 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>; + + 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; + + 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); + }); +}); diff --git a/packages/api/src/admin/secrets.ts b/packages/api/src/admin/secrets.ts index e38174c17c..2e80d3b5e4 100644 --- a/packages/api/src/admin/secrets.ts +++ b/packages/api/src/admin/secrets.ts @@ -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 = 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 `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( CONFIG_SECRET_FIELDS.map((field) => [field.path, field]), ); @@ -71,7 +103,10 @@ const ANCESTOR_PATHS = new Set( ); 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, 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 | 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(); + const existingByIdentity = new Map>(); + 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(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 = { ...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(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(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(next: T, existing?: unknown, basePath = } } } + + preserveArraySecrets(result, existing, basePath); return result; } @@ -501,6 +801,7 @@ export function redactConfigSecrets(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(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; } diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index a3e7401efd..b04a58d292 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -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 => { @@ -32,11 +32,19 @@ const createTestAppConfig = (overrides: Partial = {}): 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: { diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts index db5917da09..e651e8fbac 100644 --- a/packages/api/src/app/config.ts +++ b/packages/api/src/app/config.ts @@ -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); }; diff --git a/packages/api/src/endpoints/config/models.ts b/packages/api/src/endpoints/config/models.ts index 9128d14d96..9525064e4d 100644 --- a/packages/api/src/endpoints/config/models.ts +++ b/packages/api/src/endpoints/config/models.ts @@ -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, diff --git a/packages/api/src/endpoints/config/providers.ts b/packages/api/src/endpoints/config/providers.ts index b0d34abfe7..0ad90143cf 100644 --- a/packages/api/src/endpoints/config/providers.ts +++ b/packages/api/src/endpoints/config/providers.ts @@ -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`); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 428c32b4da..6c88fc7d27 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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),