Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
requireAnyCapability,
requireAllSectionCapabilities,
} from './capabilities';
import { sanitizeEnvPlaceholders, restoreEnvPlaceholders } from './utils/envPlaceholder';
import { BASE_CONFIG_PRINCIPAL_ID } from './constants';
import { safeFieldPath } from './utils/validation';
import { flattenObject } from '@/utils/format';
Expand Down Expand Up @@ -589,7 +590,9 @@ export const parseImportedYaml = createServerFn({ method: 'POST' })
};
}

const result = configSchema.safeParse(rawConfig);
const { sanitized, placeholders } = sanitizeEnvPlaceholders(rawConfig as t.ConfigValue);

const result = configSchema.safeParse(sanitized);

if (!result.success) {
return {
Expand All @@ -607,13 +610,18 @@ export const parseImportedYaml = createServerFn({ method: 'POST' })

try {
const appConfig = await AppService({ config: result.data });
return { success: true, error: undefined, validationErrors: undefined, appConfig };
return {
success: true,
error: undefined,
validationErrors: undefined,
appConfig: restoreEnvPlaceholders(appConfig as t.ConfigValue, placeholders),
};
} catch (appServiceError) {
console.warn(
'AppService failed for imported config, falling back to raw config:',
appServiceError instanceof Error ? appServiceError.message : appServiceError,
);
const fallbackConfig = result.data;
const fallbackConfig = restoreEnvPlaceholders(result.data as t.ConfigValue, placeholders);
return {
success: true,
error: undefined,
Expand Down
98 changes: 98 additions & 0 deletions src/server/utils/envPlaceholder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { configSchema } from 'librechat-data-provider';
import type * as t from '@/types';
import { sanitizeEnvPlaceholders, restoreEnvPlaceholders } from './envPlaceholder';

describe('sanitizeEnvPlaceholders', () => {
it('replaces env placeholders with unique sentinels and records the originals', () => {
const config: t.ConfigValue = {
mcpServers: {
posts: {
type: 'sse',
url: '${MASTRA_INTERNAL_URL}',
headers: { Authorization: 'Bearer ${MASTRA_API_KEY}' },
},
},
};

const { sanitized, placeholders } = sanitizeEnvPlaceholders(config);
const server = (sanitized as { mcpServers: { posts: Record<string, t.ConfigValue> } })
.mcpServers.posts;

expect(server.url).not.toContain('${');
expect(placeholders.get(server.url as string)).toBe('${MASTRA_INTERNAL_URL}');
expect([...placeholders.values()]).toContain('Bearer ${MASTRA_API_KEY}');
});

it('leaves placeholder-free strings untouched and records nothing', () => {
const config: t.ConfigValue = { version: '1.2.3', cache: true, count: 5 };

const { sanitized, placeholders } = sanitizeEnvPlaceholders(config);

expect(sanitized).toEqual(config);
expect(placeholders.size).toBe(0);
});

it('handles placeholders embedded inside a larger string', () => {
const { sanitized, placeholders } = sanitizeEnvPlaceholders('${BASE_URL}/api/sse');

expect(sanitized).not.toContain('${');
expect(placeholders.get(sanitized as string)).toBe('${BASE_URL}/api/sse');
});
});

describe('restoreEnvPlaceholders', () => {
it('round-trips a config back to its original placeholders', () => {
const config: t.ConfigValue = {
endpoints: ['${A}', 'static', '${B}/path'],
nested: { key: '${SECRET}', flag: false },
};

const { sanitized, placeholders } = sanitizeEnvPlaceholders(config);
const restored = restoreEnvPlaceholders(sanitized, placeholders);

expect(sanitized).not.toEqual(config);
expect(restored).toEqual(config);
});

it('returns the value unchanged when there are no placeholders', () => {
const value: t.ConfigValue = { a: 1, b: ['x'] };

expect(restoreEnvPlaceholders(value, new Map())).toEqual(value);
});
});

describe('integration with the real LibreChat configSchema', () => {
const placeholderConfig: t.ConfigValue = {
version: '1.0.0',
mcpServers: {
posts: {
type: 'sse',
url: '${MASTRA_INTERNAL_URL}',
headers: { Authorization: 'Bearer ${MASTRA_API_KEY}' },
timeout: 30000,
},
},
};

it('cannot validate the raw placeholder url because env resolution leaves it literal', () => {
expect(() => configSchema.safeParse(placeholderConfig)).toThrow(/MASTRA_INTERNAL_URL/);
});

it('accepts the sanitized config and restores the placeholder after validation', () => {
const { sanitized, placeholders } = sanitizeEnvPlaceholders(placeholderConfig);
const result = configSchema.safeParse(sanitized);

expect(result.success).toBe(true);
if (!result.success) return;

const restored = restoreEnvPlaceholders(result.data as t.ConfigValue, placeholders);
const server = (restored as { mcpServers: { posts: Record<string, t.ConfigValue> } })
.mcpServers.posts;

expect(server.url).toBe('${MASTRA_INTERNAL_URL}');
expect((server.headers as Record<string, string>).Authorization).toBe(
'Bearer ${MASTRA_API_KEY}',
);
});
});
77 changes: 77 additions & 0 deletions src/server/utils/envPlaceholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type * as t from '@/types';

/**
* LibreChat resolves `${ENV_VAR}` placeholders against `process.env` at config
* load time (via `extractEnvVariable`) before schema validation. The admin
* panel only manages the canonical config and does not — and should not — have
* every deployment's environment variables set, so those placeholders would
* otherwise fail format validators such as `z.string().url()`.
*
* To keep full structural validation while tolerating placeholders, every
* string containing a `${...}` placeholder is swapped for a unique, schema-safe
* sentinel (a valid URL, which also satisfies plain string constraints) before
* validation, then restored verbatim afterwards.
*/
const ENV_VAR_PATTERN = /\$\{[^}]+\}/;

const SENTINEL_PREFIX = 'https://env-placeholder.invalid/';

export interface SanitizedConfig {
sanitized: t.ConfigValue;
placeholders: Map<string, string>;
}

function mapObject(
node: { [key: string]: t.ConfigValue },
transform: (value: t.ConfigValue) => t.ConfigValue,
): { [key: string]: t.ConfigValue } {
const result: { [key: string]: t.ConfigValue } = {};
for (const key of Object.keys(node)) {
result[key] = transform(node[key]);
}
return result;
}

/**
* Replaces every string holding a `${ENV_VAR}` placeholder with a unique
* schema-safe sentinel, returning the sanitized config and the sentinel → original map.
*/
export function sanitizeEnvPlaceholders(value: t.ConfigValue): SanitizedConfig {
const placeholders = new Map<string, string>();
let counter = 0;

const walk = (node: t.ConfigValue): t.ConfigValue => {
if (typeof node === 'string') {
if (!ENV_VAR_PATTERN.test(node)) return node;
const sentinel = `${SENTINEL_PREFIX}${counter++}`;
placeholders.set(sentinel, node);
return sentinel;
}
if (Array.isArray(node)) return node.map(walk);
if (node && typeof node === 'object') return mapObject(node, walk);
return node;
};

return { sanitized: walk(value), placeholders };
}

/**
* Restores original `${ENV_VAR}` placeholder strings by swapping each sentinel
* back to its source value. Matching is by exact equality, since the sentinels
* are not normalized by URL or string validators.
*/
export function restoreEnvPlaceholders(
value: t.ConfigValue,
placeholders: Map<string, string>,
): t.ConfigValue {
if (placeholders.size === 0) return value;

const walk = (node: t.ConfigValue): t.ConfigValue => {
if (typeof node === 'string') return placeholders.get(node) ?? node;
if (Array.isArray(node)) return node.map(walk);
if (node && typeof node === 'object') return mapObject(node, walk);
return node;
};

return walk(value);
}