From 05a3d1ed810ddcd4ed7d0d71e5fcc1fc14cda04c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 15:28:54 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A3=EF=B8=8F=20feat:=20Add=20MCP=20Rem?= =?UTF-8?q?ote=20Proxy=20Support=20(#13076)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MCP remote proxy support * fix: Harden MCP Proxy Review Findings * fix: Honor MCP Proxy Env Precedence * fix: Harden MCP proxy routing * fix: Align MCP proxy bypass semantics * test: Pin MCP proxy admin scope --- .env.example | 2 + librechat.example.yaml | 1 + .../mcp/__tests__/MCPConnectionSSRF.test.ts | 865 ++++++++++++++++++ packages/api/src/mcp/__tests__/mcp.spec.ts | 26 + packages/api/src/mcp/connection.ts | 567 ++++++++++-- packages/api/src/utils/env.spec.ts | 43 + packages/api/src/utils/env.ts | 12 + packages/data-provider/specs/mcp.spec.ts | 59 +- packages/data-provider/src/mcp.ts | 25 + 9 files changed, 1534 insertions(+), 66 deletions(-) diff --git a/.env.example b/.env.example index bf992b67cc..35a2a84043 100644 --- a/.env.example +++ b/.env.example @@ -141,6 +141,8 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic +# Optional outbound proxy for server-side requests, including remote MCP HTTP/SSE transports. +# Remote MCP transports also honor HTTP_PROXY, HTTPS_PROXY, and NO_PROXY when PROXY is unset. PROXY= #===================================# diff --git a/librechat.example.yaml b/librechat.example.yaml index d3e0d006cc..58dab0300a 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -313,6 +313,7 @@ actions: # everything: # # type: sse # type can optionally be omitted # url: http://localhost:3001/sse +# # proxy: "${MCP_PROXY_URL}" # optional outbound proxy (http/https/socks/socks5) # timeout: 60000 # 1 minute timeout for this server, this is the default timeout for MCP servers. # puppeteer: # type: stdio diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts index 60de0db5e4..aaec527e46 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -13,6 +13,7 @@ import * as net from 'net'; import * as http from 'http'; +import { lookup } from 'node:dns/promises'; import { randomUUID } from 'crypto'; import { Request as UndiciRequest } from 'undici'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -38,6 +39,10 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +jest.mock('node:dns/promises', () => ({ + lookup: jest.fn(), +})); + jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => ({ lookup: (_hostname: string, optionsOrCallback: unknown, maybeCallback?: LookupCallback) => { @@ -51,6 +56,7 @@ jest.mock('~/auth', () => ({ callback(null, '127.0.0.1', 4); }, })), + isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); @@ -61,10 +67,18 @@ jest.mock('~/mcp/mcpConfig', () => ({ const mockedResolveHostnameSSRF = resolveHostnameSSRF as jest.MockedFunction< typeof resolveHostnameSSRF >; +const mockedLookup = lookup as unknown as jest.MockedFunction< + (hostname: string, options: { all: true }) => Promise> +>; const mockedCreateSSRFSafeUndiciConnect = createSSRFSafeUndiciConnect as jest.MockedFunction< typeof createSSRFSafeUndiciConnect >; +beforeEach(() => { + mockedLookup.mockReset(); + mockedLookup.mockResolvedValue([{ address: '203.0.113.10', family: 4 }]); +}); + function getLookupCallback( optionsOrCallback: unknown, maybeCallback?: LookupCallback, @@ -534,6 +548,60 @@ async function createHeaderCaptureServer(): Promise { }; } +async function createTunnelProxyCaptureServer(): Promise { + const headers: http.IncomingHttpHeaders[] = []; + const requests: CapturedRequest[] = []; + const server = http.createServer((_req, res) => { + res.writeHead(502); + res.end(); + }); + server.on('connect', (req, clientSocket, head) => { + headers.push({ ...req.headers }); + requests.push({ + method: 'CONNECT', + headers: { ...req.headers }, + body: req.url ?? '', + }); + + let buffer = Buffer.from(head); + let responded = false; + const respondIfRequestComplete = () => { + if (responded || !buffer.includes('\r\n\r\n')) { + return; + } + responded = true; + const requestLine = buffer.toString('utf8').split('\r\n')[0] ?? ''; + requests.push({ + method: requestLine.split(' ')[0] ?? '', + headers: {}, + body: requestLine, + }); + clientSocket.write( + 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}', + ); + clientSocket.end(); + }; + + clientSocket.on('error', () => undefined); + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + clientSocket.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + respondIfRequestComplete(); + }); + respondIfRequestComplete(); + }); + + const destroySockets = trackSockets(server); + const port = await getFreePort(); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return { + url: `http://127.0.0.1:${port}/`, + receivedHeaders: headers, + receivedRequests: requests, + close: destroySockets, + }; +} + async function createRawResponseServer(handler: http.RequestListener): Promise { const server = http.createServer(handler); const destroySockets = trackSockets(server); @@ -940,6 +1008,803 @@ describe('MCP SSRF protection – customFetch input shapes', () => { return factory.call(connection, () => null, undefined, undefined, undefined, undefined, true); } + function createBaseUrlFetch(connection: MCPConnection, baseUrl: string): CustomFetch { + const factory = ( + connection as unknown as { + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + } + ).createFetchFunction; + return factory.call(connection, () => null, undefined, 300000, undefined, baseUrl); + } + + function createBaseUrlDispatchers(connection: MCPConnection, baseUrl: string): string[] { + const privateSelf = connection as unknown as { + agents: Array<{ constructor: { name: string } }>; + }; + createBaseUrlFetch(connection, baseUrl); + return privateSelf.agents.map((agent) => agent.constructor.name); + } + + const proxyEnvKeys = [ + 'PROXY', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + ] as const; + type ProxyEnvKey = (typeof proxyEnvKeys)[number]; + + function snapshotProxyEnv(): Partial> { + const snapshot: Partial> = {}; + for (const key of proxyEnvKeys) { + if (process.env[key] != null) { + snapshot[key] = process.env[key]; + } + } + return snapshot; + } + + function restoreProxyEnv(snapshot: Partial>): void { + for (const key of proxyEnvKeys) { + if (snapshot[key] == null) { + delete process.env[key]; + } else { + process.env[key] = snapshot[key]; + } + } + } + + function clearProxyEnv(): void { + for (const key of proxyEnvKeys) { + delete process.env[key]; + } + } + + it('should allocate proxy dispatchers for streamable-http when proxy is configured', () => { + conn = new MCPConnection({ + serverName: 'customfetch-proxy-dispatchers', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: 'http://proxy.example.com:8080', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + }); + + it('should use the PROXY env var for streamable-http when server proxy is not configured', () => { + const originalProxy = process.env.PROXY; + process.env.PROXY = 'http://env-proxy.example.com:8080'; + try { + conn = new MCPConnection({ + serverName: 'customfetch-env-proxy-dispatchers', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + } + }); + + it('should use standard HTTP proxy env vars for streamable-http when PROXY is absent', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalHttpsProxy = process.env.HTTPS_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerHttpsProxy = process.env.https_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080'; + process.env.NO_PROXY = 'localhost,127.0.0.1'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-proxy-dispatchers', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalHttpsProxy == null) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = originalHttpsProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerHttpsProxy == null) { + delete process.env.https_proxy; + } else { + process.env.https_proxy = originalLowerHttpsProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor NO_PROXY when standard HTTP proxy env vars are configured', () => { + const originalProxy = process.env.PROXY; + const originalHttpsProxy = process.env.HTTPS_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpsProxy = process.env.https_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.https_proxy; + delete process.env.no_proxy; + process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080'; + process.env.NO_PROXY = 'mcp.example.com'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-no-proxy', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpsProxy == null) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = originalHttpsProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpsProxy == null) { + delete process.env.https_proxy; + } else { + process.env.https_proxy = originalLowerHttpsProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor bare IPv6 NO_PROXY entries without parsing a port suffix', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = '::1'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-no-proxy-ipv6', + serverConfig: { + type: 'streamable-http', + url: 'http://[::1]:3000/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://[::1]:3000/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor wildcard tokens in NO_PROXY lists', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = 'localhost,*'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-no-proxy-wildcard-list', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor CIDR and IP range patterns in NO_PROXY lists', async () => { + const originalEnv = snapshotProxyEnv(); + clearProxyEnv(); + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = '10.0.0.0/8,192.168.1.10-192.168.1.20'; + + const expectDispatcherNamesForUrl = async ( + url: string, + expectedNames: string[], + ): Promise => { + await safeDisconnect(conn); + conn = new MCPConnection({ + serverName: `customfetch-no-proxy-${url}`, + serverConfig: { + type: 'streamable-http', + url, + }, + useSSRFProtection: false, + }); + expect(createBaseUrlDispatchers(conn, url)).toEqual(expectedNames); + }; + + try { + await expectDispatcherNamesForUrl('http://10.2.3.4/mcp', ['Agent', 'Agent']); + await expectDispatcherNamesForUrl('http://192.168.1.15/mcp', ['Agent', 'Agent']); + await expectDispatcherNamesForUrl('http://192.168.1.25/mcp', ['ProxyAgent', 'ProxyAgent']); + } finally { + restoreProxyEnv(originalEnv); + } + }); + + it('should match NO_PROXY host entries like undici env proxy agents', async () => { + const originalEnv = snapshotProxyEnv(); + clearProxyEnv(); + process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080'; + + const expectDispatcherNamesForUrl = async ( + noProxy: string, + url: string, + expectedNames: string[], + ): Promise => { + await safeDisconnect(conn); + process.env.NO_PROXY = noProxy; + conn = new MCPConnection({ + serverName: `customfetch-no-proxy-host-${noProxy}-${url}`, + serverConfig: { + type: 'streamable-http', + url, + }, + useSSRFProtection: false, + }); + expect(createBaseUrlDispatchers(conn, url)).toEqual(expectedNames); + }; + + try { + await expectDispatcherNamesForUrl('example.com', 'https://example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('example.com', 'https://api.example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('*.example.com', 'https://api.example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('*.example.com', 'https://example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('.example.com', 'https://example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('.example.com', 'https://api.example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('example.com', 'https://badexample.com/mcp', [ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + restoreProxyEnv(originalEnv); + } + }); + + it('should let empty lowercase proxy env vars disable uppercase fallbacks', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.http_proxy = ''; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-empty-lowercase-proxy', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should let empty lowercase no_proxy disable uppercase fallbacks', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = 'mcp.example.com'; + process.env.no_proxy = ''; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-empty-lowercase-no-proxy', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should recompute proxy dispatchers from the resolved request URL', async () => { + const originalEnv = snapshotProxyEnv(); + const capture = await createHeaderCaptureServer(); + clearProxyEnv(); + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = '127.0.0.1'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-recompute-proxy-dispatcher', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const customFetch = createBaseUrlFetch(conn, 'http://mcp.example.com/mcp'); + const response = await customFetch(capture.url); + + expect(response.status).toBe(200); + await response.body?.cancel(); + expect(capture.receivedRequests).toHaveLength(1); + expect( + (conn as unknown as { agents: Array<{ constructor: { name: string } }> }).agents.map( + (agent) => agent.constructor.name, + ), + ).toEqual(['ProxyAgent', 'ProxyAgent', 'Agent']); + } finally { + restoreProxyEnv(originalEnv); + await capture.close(); + } + }); + + it('should preflight proxied targets before dispatching network requests', async () => { + mockedResolveHostnameSSRF.mockResolvedValueOnce(true); + + conn = new MCPConnection({ + serverName: 'customfetch-proxy-ssrf', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: 'http://proxy.example.com:8080', + }, + useSSRFProtection: true, + }); + + const customFetch = getCustomFetch(conn); + + await expect(customFetch('http://blocked.example.com/mcp')).rejects.toThrow( + /proxied MCP request target/, + ); + expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith('blocked.example.com', null, '80'); + }); + + it('should fail closed when proxied target DNS cannot be resolved before dispatch', async () => { + mockedResolveHostnameSSRF.mockResolvedValueOnce(false); + mockedLookup.mockRejectedValueOnce( + Object.assign(new Error('getaddrinfo ENOTFOUND'), { + code: 'ENOTFOUND', + }), + ); + + conn = new MCPConnection({ + serverName: 'customfetch-proxy-ssrf-dns-fail-closed', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: 'http://proxy.example.com:8080', + }, + useSSRFProtection: true, + }); + + const customFetch = getCustomFetch(conn); + + await expect(customFetch('http://proxy-only.internal/mcp')).rejects.toThrow( + /could not be resolved before proxying/, + ); + expect(mockedLookup).toHaveBeenCalledWith('proxy-only.internal', { all: true }); + }); + + it('should skip proxied DNS preflight for explicitly allowed target hosts', async () => { + const proxy = await createTunnelProxyCaptureServer(); + mockedResolveHostnameSSRF.mockClear(); + mockedLookup.mockClear(); + mockedLookup.mockRejectedValueOnce( + Object.assign(new Error('getaddrinfo ENOTFOUND'), { + code: 'ENOTFOUND', + }), + ); + + try { + conn = new MCPConnection({ + serverName: 'customfetch-proxy-ssrf-allowed-dns', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: proxy.url, + }, + useSSRFProtection: true, + allowedAddresses: ['proxy-only.internal:80'], + }); + + const customFetch = getCustomFetch(conn); + const response = await customFetch('http://proxy-only.internal/mcp'); + + expect(response.status).toBe(200); + await response.body?.cancel().catch(() => undefined); + expect(proxy.receivedRequests[0]?.method).toBe('CONNECT'); + expect(mockedResolveHostnameSSRF).not.toHaveBeenCalled(); + expect(mockedLookup).not.toHaveBeenCalled(); + } finally { + await proxy.close(); + } + }); + it.each<['string' | 'URL' | 'Request']>([['string'], ['URL'], ['Request']])( 'should accept a %s input without throwing on URL derivation', async (shape) => { diff --git a/packages/api/src/mcp/__tests__/mcp.spec.ts b/packages/api/src/mcp/__tests__/mcp.spec.ts index d5cc44569f..de2839371d 100644 --- a/packages/api/src/mcp/__tests__/mcp.spec.ts +++ b/packages/api/src/mcp/__tests__/mcp.spec.ts @@ -150,6 +150,18 @@ describe('Environment Variable Extraction (MCP)', () => { expect(result.headers).toEqual(options.headers); }); + it('should validate proxy URLs for remote HTTP transports', () => { + const options = { + type: 'streamable-http', + url: 'https://example.com/api', + proxy: 'http://proxy.example.com:8080', + }; + + const result = StreamableHTTPOptionsSchema.parse(options); + + expect(result.proxy).toBe('http://proxy.example.com:8080'); + }); + it('should accept "http" as an alias for "streamable-http"', () => { const options = { type: 'http', @@ -324,6 +336,20 @@ describe('Environment Variable Extraction (MCP)', () => { }); }); + it('should process proxy in streamable-http options', () => { + process.env.MCP_PROXY_URL = 'http://proxy.example.com:8080'; + const options: MCPOptions = { + type: 'streamable-http', + url: 'https://example.com', + proxy: '${MCP_PROXY_URL}', + }; + + const result = processMCPEnv({ options }); + + expect('proxy' in result && result.proxy).toBe('http://proxy.example.com:8080'); + delete process.env.MCP_PROXY_URL; + }); + it('should maintain streamable-http type in processed options', () => { const options: MCPOptions = { type: 'streamable-http', diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 0bb4c033d4..8edc01f2f4 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -1,6 +1,8 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; import { EventEmitter } from 'events'; import { logger } from '@librechat/data-schemas'; -import { fetch as undiciFetch, Agent } from 'undici'; +import { fetch as undiciFetch, Agent, ProxyAgent } from 'undici'; import { StdioClientTransport, getDefaultEnvironment, @@ -15,16 +17,38 @@ import type { RequestInit as UndiciRequestInit, RequestInfo as UndiciRequestInfo, Response as UndiciResponse, + Dispatcher, } from 'undici'; import type { MCPOAuthTokens } from './oauth/types'; import type * as t from './types'; -import { createSSRFSafeUndiciConnect, resolveHostnameSSRF } from '~/auth'; +import { createSSRFSafeUndiciConnect, isSSRFTarget, resolveHostnameSSRF } from '~/auth'; +import { isAddressAllowed } from '~/auth/domain'; import { runOutsideTracing } from '~/utils/tracing'; import { sanitizeUrlForLogging } from './utils'; import { withTimeout } from '~/utils/promise'; import { mcpConfig } from './mcpConfig'; type FetchLike = (url: string | URL, init?: RequestInit) => Promise; +type ManagedDispatcher = Agent | ProxyAgent; +type ParsedIP = { version: 4 | 6; bits: 32 | 128; value: bigint }; + +const BIGINT_ZERO = BigInt(0); +const BIGINT_ONE = BigInt(1); +const BIGINT_EIGHT = BigInt(8); +const BIGINT_SIXTEEN = BigInt(16); +const UINT16_MASK = BigInt(0xffff); + +type MCPProxyConfig = + | { + type: 'explicit'; + proxyUrl: string; + } + | { + type: 'env'; + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; + }; function isStdioOptions(options: t.MCPOptions): options is t.StdioOptions { return 'command' in options; @@ -273,10 +297,7 @@ async function guardMCPStreamableHTTPResponse( const sseEventDataLines: string[] = []; const unresolvedRequestIds = new Set(context.requestIds ?? []); - const buildAndLogBlockedError = ( - reason: string, - details: Record, - ): Error => { + const buildAndLogBlockedError = (reason: string, details: Record): Error => { const message = buildBlockedMCPResponseMessage(reason, { maxResponseBytes, maxLineBytes, @@ -509,7 +530,7 @@ function normalizeInitHeaders(init: UndiciRequestInit | undefined): Record | null | undefined, ): UndiciRequestInit { const hasInitHeaders = init?.headers != null; @@ -541,6 +562,343 @@ function getUrlPort(url: URL | string): string { return ''; } +function getTrimmedEnv(...keys: string[]): string | undefined { + for (const key of keys) { + const rawValue = process.env[key]; + if (rawValue != null) { + return rawValue.trim() || undefined; + } + } + return undefined; +} + +function getMCPProxyConfig(options: t.MCPOptions): MCPProxyConfig | undefined { + const configuredProxy = + 'proxy' in options && typeof options.proxy === 'string' ? options.proxy.trim() : ''; + if (configuredProxy) { + return { type: 'explicit', proxyUrl: configuredProxy }; + } + + const libreChatProxy = process.env.PROXY?.trim() ?? ''; + if (libreChatProxy) { + return { type: 'explicit', proxyUrl: libreChatProxy }; + } + + const httpProxy = getTrimmedEnv('http_proxy', 'HTTP_PROXY'); + const httpsProxy = getTrimmedEnv('https_proxy', 'HTTPS_PROXY'); + if (!httpProxy && !httpsProxy) { + return undefined; + } + + return { + type: 'env', + httpProxy, + httpsProxy, + noProxy: getTrimmedEnv('no_proxy', 'NO_PROXY'), + }; +} + +function parseIPv4ToBigInt(ip: string): bigint | null { + const octets = ip.split('.'); + if (octets.length !== 4) { + return null; + } + + let value = BIGINT_ZERO; + for (const octet of octets) { + if (!/^\d{1,3}$/.test(octet)) { + return null; + } + const parsed = Number.parseInt(octet, 10); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 255) { + return null; + } + value = (value << BIGINT_EIGHT) + BigInt(parsed); + } + return value; +} + +function parseIPv6ToBigInt(ip: string): bigint | null { + let normalized = ip.toLowerCase().replace(/^\[|\]$/g, ''); + const zoneIndex = normalized.indexOf('%'); + if (zoneIndex !== -1) { + normalized = normalized.slice(0, zoneIndex); + } + + if (normalized.includes('.')) { + const lastColon = normalized.lastIndexOf(':'); + if (lastColon === -1) { + return null; + } + const ipv4Value = parseIPv4ToBigInt(normalized.slice(lastColon + 1)); + if (ipv4Value == null) { + return null; + } + const hi = Number((ipv4Value >> BIGINT_SIXTEEN) & UINT16_MASK).toString(16); + const lo = Number(ipv4Value & UINT16_MASK).toString(16); + normalized = `${normalized.slice(0, lastColon)}:${hi}:${lo}`; + } + + const halves = normalized.split('::'); + if (halves.length > 2) { + return null; + } + + const left = halves[0] ? halves[0].split(':') : []; + const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []; + const missing = halves.length === 2 ? 8 - left.length - right.length : 0; + if (missing < 0 || (halves.length === 1 && left.length !== 8)) { + return null; + } + + const parts = [...left, ...Array(missing).fill('0'), ...right]; + if (parts.length !== 8 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) { + return null; + } + + return parts.reduce( + (value, part) => (value << BIGINT_SIXTEEN) + BigInt(Number.parseInt(part, 16)), + BIGINT_ZERO, + ); +} + +function parseIPLiteral(value: string): ParsedIP | null { + const normalized = value + .toLowerCase() + .trim() + .replace(/^\[|\]$/g, ''); + const version = isIP(normalized); + if (version === 4) { + const parsed = parseIPv4ToBigInt(normalized); + return parsed == null ? null : { version: 4, bits: 32, value: parsed }; + } + if (version === 6) { + const parsed = parseIPv6ToBigInt(normalized); + return parsed == null ? null : { version: 6, bits: 128, value: parsed }; + } + return null; +} + +function ipMatchesCIDR(hostname: string, cidr: string): boolean { + const [rangeAddress, prefixLength, extra] = cidr.split('/'); + if (!rangeAddress || prefixLength == null || extra != null || !/^\d+$/.test(prefixLength)) { + return false; + } + + const hostIP = parseIPLiteral(hostname); + const rangeIP = parseIPLiteral(rangeAddress); + if (!hostIP || !rangeIP || hostIP.version !== rangeIP.version) { + return false; + } + + const prefix = Number.parseInt(prefixLength, 10); + if (!Number.isInteger(prefix) || prefix < 0 || prefix > rangeIP.bits) { + return false; + } + + const bits = BigInt(rangeIP.bits); + const mask = + prefix === 0 + ? BIGINT_ZERO + : (((BIGINT_ONE << bits) - BIGINT_ONE) << BigInt(rangeIP.bits - prefix)) & + ((BIGINT_ONE << bits) - BIGINT_ONE); + return (hostIP.value & mask) === (rangeIP.value & mask); +} + +function ipMatchesRange(hostname: string, range: string): boolean { + const [startAddress, endAddress, extra] = range.split('-'); + if (!startAddress || !endAddress || extra != null) { + return false; + } + + const hostIP = parseIPLiteral(hostname); + const startIP = parseIPLiteral(startAddress); + const endIP = parseIPLiteral(endAddress); + if ( + !hostIP || + !startIP || + !endIP || + hostIP.version !== startIP.version || + hostIP.version !== endIP.version + ) { + return false; + } + + const min = startIP.value <= endIP.value ? startIP.value : endIP.value; + const max = startIP.value <= endIP.value ? endIP.value : startIP.value; + return hostIP.value >= min && hostIP.value <= max; +} + +function matchesNoProxyIPPattern(hostname: string, entryHostname: string): boolean { + if (entryHostname.includes('/')) { + return ipMatchesCIDR(hostname, entryHostname); + } + if (entryHostname.includes('-')) { + return ipMatchesRange(hostname, entryHostname); + } + return false; +} + +function getProxyEntryPort(entry: string): { + hostname: string; + port: number; +} { + const trimmed = entry.trim(); + const bracketed = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (bracketed) { + return { + hostname: bracketed[1].toLowerCase(), + port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : 0, + }; + } + + const separatorCount = (trimmed.match(/:/g) ?? []).length; + const parsed = separatorCount === 1 ? trimmed.match(/^(.+):(\d+)$/) : null; + const hostname = (parsed ? parsed[1] : trimmed).replace(/^\[|\]$/g, '').toLowerCase(); + return { + hostname: hostname.replace(/^\*?\./, ''), + port: parsed ? Number.parseInt(parsed[2], 10) : 0, + }; +} + +function shouldBypassEnvProxy(url: URL, noProxy?: string): boolean { + if (!noProxy) { + return false; + } + + const trimmed = noProxy.trim(); + if (!trimmed) { + return false; + } + if (trimmed === '*') { + return true; + } + + const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + const port = Number.parseInt(getUrlPort(url), 10) || 0; + + for (const entry of trimmed.split(/[,\s]/)) { + if (!entry) { + continue; + } + if (entry === '*') { + return true; + } + + const proxyEntry = getProxyEntryPort(entry); + if (proxyEntry.port && proxyEntry.port !== port) { + continue; + } + if (matchesNoProxyIPPattern(hostname, proxyEntry.hostname)) { + return true; + } + if (hostname === proxyEntry.hostname || hostname.endsWith(`.${proxyEntry.hostname}`)) { + return true; + } + } + + return false; +} + +function getProxyUrlForRequest( + proxyConfig: MCPProxyConfig | undefined, + urlString: string, +): string | undefined { + if (!proxyConfig || !urlString) { + return undefined; + } + if (proxyConfig.type === 'explicit') { + return proxyConfig.proxyUrl; + } + + const url = new URL(urlString); + if (shouldBypassEnvProxy(url, proxyConfig.noProxy)) { + return undefined; + } + if (url.protocol === 'https:') { + return proxyConfig.httpsProxy ?? proxyConfig.httpProxy; + } + if (url.protocol === 'http:') { + return proxyConfig.httpProxy; + } + return undefined; +} + +function createMCPDispatcher(options: { + bodyTimeout: number; + headersTimeout: number; + proxyUrl?: string; + keepAliveTimeout?: number; + keepAliveMaxTimeout?: number; + connect?: ReturnType; +}): ManagedDispatcher { + const { bodyTimeout, headersTimeout, proxyUrl, keepAliveTimeout, keepAliveMaxTimeout, connect } = + options; + + const baseOptions = { + bodyTimeout, + headersTimeout, + ...(keepAliveTimeout != null ? { keepAliveTimeout } : {}), + ...(keepAliveMaxTimeout != null ? { keepAliveMaxTimeout } : {}), + }; + + if (proxyUrl) { + return new ProxyAgent({ + uri: proxyUrl, + ...baseOptions, + }); + } + + return new Agent({ + ...baseOptions, + ...(connect != null ? { connect } : {}), + }); +} + +async function assertProxiedRequestTargetResolvable(hostname: string): Promise { + if (parseIPLiteral(hostname)) { + return; + } + + try { + await lookup(hostname, { all: true }); + } catch { + throw new Error( + `SSRF protection: proxied MCP request target "${hostname}" could not be resolved before proxying`, + ); + } +} + +async function assertProxiedRequestTargetAllowed( + urlString: string, + proxyConfig: MCPProxyConfig | undefined, + useSSRFProtection: boolean, + allowedAddresses?: string[] | null, +): Promise { + if (!proxyConfig || !useSSRFProtection) { + return; + } + + const targetUrl = new URL(urlString); + const port = getUrlPort(targetUrl); + if (isAddressAllowed(targetUrl.hostname, allowedAddresses, port)) { + return; + } + + const isBlockedTarget = + isSSRFTarget(targetUrl.hostname, allowedAddresses, port) || + (await resolveHostnameSSRF(targetUrl.hostname, allowedAddresses, port)); + + if (!isBlockedTarget) { + await assertProxiedRequestTargetResolvable(targetUrl.hostname); + return; + } + + throw new Error( + `SSRF protection: proxied MCP request target "${targetUrl.hostname}" resolved to a private/reserved address`, + ); +} + /** * Drops credential-bearing headers when a 307/308 redirect crosses an origin * boundary. Removes the always-forbidden set plus any caller-supplied secret @@ -758,7 +1116,7 @@ export class MCPConnection extends EventEmitter { private isReconnecting = false; private isInitializing = false; private reconnectAttempts = 0; - private agents: Agent[] = []; + private agents: Dispatcher[] = []; private readonly userId?: string; private lastPingTime: number; private lastConnectionCheckAt: number = 0; @@ -768,6 +1126,7 @@ export class MCPConnection extends EventEmitter { private oauthRecovery = false; private readonly useSSRFProtection: boolean; private readonly allowedAddresses?: string[] | null; + private readonly proxyConfig?: MCPProxyConfig; iconPath?: string; timeout?: number; sseReadTimeout?: number; @@ -883,6 +1242,7 @@ export class MCPConnection extends EventEmitter { this.userId = params.userId; this.useSSRFProtection = params.useSSRFProtection === true; this.allowedAddresses = params.allowedAddresses ?? null; + this.proxyConfig = getMCPProxyConfig(params.serverConfig); this.iconPath = params.serverConfig.iconPath; this.timeout = params.serverConfig.timeout; this.sseReadTimeout = params.serverConfig.sseReadTimeout; @@ -926,58 +1286,81 @@ export class MCPConnection extends EventEmitter { baseUrl?: string, guardStreamableHTTPResponses = false, ): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise { - const basePort = baseUrl ? getUrlPort(baseUrl) : ''; - const ssrfConnect = this.useSSRFProtection - ? createSSRFSafeUndiciConnect(this.allowedAddresses, basePort) - : undefined; - const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {}; + const proxyConfig = this.proxyConfig; + const useSSRFProtection = this.useSSRFProtection; + const allowedAddresses = this.allowedAddresses; /** Capture only the fields needed by the fetch closure; see factory note above. */ const agents = this.agents; const logPrefix = this.getLogPrefix(); const effectiveTimeout = timeout || DEFAULT_TIMEOUT; - const postAgent = new Agent({ - bodyTimeout: effectiveTimeout, - headersTimeout: effectiveTimeout, - ...connectOpts, - }); - this.agents.push(postAgent); + const requestDispatchers = new Map(); + const ssrfConnects = new Map>(); - let getAgent: Agent | undefined; - if (sseBodyTimeout != null) { - getAgent = new Agent({ - bodyTimeout: sseBodyTimeout, - headersTimeout: effectiveTimeout, - ...connectOpts, - }); - this.agents.push(getAgent); - } + const getSSRFConnect = ( + targetPort: string, + dispatcherAllowedAddresses: string[] | null | undefined, + forceSafeDirectConnect: boolean, + ): ReturnType => { + const key = `${forceSafeDirectConnect ? 'redirect' : 'configured'}:${targetPort}`; + const existingConnect = ssrfConnects.get(key); + if (existingConnect) { + return existingConnect; + } + + const connect = forceSafeDirectConnect + ? createSSRFSafeUndiciConnect() + : createSSRFSafeUndiciConnect(dispatcherAllowedAddresses, targetPort); + ssrfConnects.set(key, connect); + return connect; + }; - let safeRedirectPostAgent: Agent | undefined; - let safeRedirectGetAgent: Agent | undefined; /** - * Allowlist mode keeps the original MCP URL admin-approved, but redirect - * targets are server-controlled. These agents add connect-time DNS checks - * for those cross-origin hops so DNS rebinding cannot beat the standalone - * resolveHostnameSSRF pre-check. + * Proxy selection depends on the resolved request URL, not just the + * configured MCP base URL. SSE message endpoints can be absolute URLs, so + * cache dispatchers by the target URL's proxy decision and connect policy. */ - const createSafeRedirectAgent = (bodyTimeout: number): Agent => { - const redirectSSRFConnect = createSSRFSafeUndiciConnect(); - const agent = new Agent({ + const getRequestDispatcher = ( + isGetRequest: boolean, + targetUrlString: string, + dispatcherAllowedAddresses: string[] | null | undefined, + forceSafeDirectConnect = false, + ): ManagedDispatcher => { + const bodyTimeout = + isGetRequest && sseBodyTimeout != null ? sseBodyTimeout : effectiveTimeout; + const proxyUrl = getProxyUrlForRequest(proxyConfig, targetUrlString); + const targetPort = getUrlPort(targetUrlString); + const needsSSRFConnect = !proxyUrl && (useSSRFProtection || forceSafeDirectConnect); + const key = [ + bodyTimeout, + proxyUrl ?? 'direct', + needsSSRFConnect ? targetPort : 'open', + forceSafeDirectConnect ? 'redirect' : 'configured', + ].join(':'); + const existingAgent = requestDispatchers.get(key); + if (existingAgent) { + return existingAgent; + } + + const connect = needsSSRFConnect + ? getSSRFConnect(targetPort, dispatcherAllowedAddresses, forceSafeDirectConnect) + : undefined; + const agent = createMCPDispatcher({ bodyTimeout, headersTimeout: effectiveTimeout, - connect: redirectSSRFConnect, + proxyUrl, + ...(connect != null ? { connect } : {}), }); + requestDispatchers.set(key, agent); agents.push(agent); return agent; }; - const getSafeRedirectDispatcher = (isGetRequest: boolean): Agent => { - if (!isGetRequest || sseBodyTimeout == null) { - safeRedirectPostAgent ??= createSafeRedirectAgent(effectiveTimeout); - return safeRedirectPostAgent; + + if (baseUrl) { + getRequestDispatcher(false, baseUrl, allowedAddresses); + if (sseBodyTimeout != null) { + getRequestDispatcher(true, baseUrl, allowedAddresses); } - safeRedirectGetAgent ??= createSafeRedirectAgent(sseBodyTimeout); - return safeRedirectGetAgent; - }; + } return async function customFetch( input: UndiciRequestInfo, @@ -995,7 +1378,6 @@ export class MCPConnection extends EventEmitter { const { urlString, resolvedInit } = await resolveFetchInput(input, init); const isGet = (resolvedInit?.method ?? 'GET').toUpperCase() === 'GET'; - const dispatcher = isGet && getAgent ? getAgent : postAgent; const requestHeaders = getHeaders(); /** * Headers that originated from user/server configuration — runtime @@ -1008,10 +1390,22 @@ export class MCPConnection extends EventEmitter { ...(configuredSecretHeaderKeys ?? []), ]); - let currentInit = buildFetchInit(resolvedInit, dispatcher, requestHeaders); let currentUrlString = urlString; + let currentAllowedAddresses = allowedAddresses; + let forceRedirectSSRFConnect = false; + let currentInit = buildFetchInit( + resolvedInit, + getRequestDispatcher(isGet, currentUrlString, currentAllowedAddresses), + requestHeaders, + ); const originalOrigin = new URL(currentUrlString).origin; for (let redirects = 0; ; redirects++) { + await assertProxiedRequestTargetAllowed( + currentUrlString, + proxyConfig, + useSSRFProtection, + currentAllowedAddresses, + ); const response = await undiciFetch(currentUrlString, currentInit); const isMethodPreservingRedirect = response.status === 307 || response.status === 308; const responseContext = { @@ -1051,7 +1445,7 @@ export class MCPConnection extends EventEmitter { * design — letting redirect targets inherit the exemption would open * an SSRF amplification primitive. */ - if (await resolveHostnameSSRF(targetUrl.hostname)) { + if (isSSRFTarget(targetUrl.hostname) || (await resolveHostnameSSRF(targetUrl.hostname))) { logger.warn( `[MCP] Blocked redirect to private/reserved address: ${sanitizeUrlForLogging(targetUrl)}`, ); @@ -1071,6 +1465,8 @@ export class MCPConnection extends EventEmitter { } if (isCrossOriginRedirect) { + currentAllowedAddresses = null; + forceRedirectSSRFConnect = true; /** * Once a server-controlled cross-origin hop is seen, keep the safe * dispatcher for the rest of this redirect chain. Restoring the @@ -1081,7 +1477,22 @@ export class MCPConnection extends EventEmitter { */ currentInit = { ...currentInit, - dispatcher: getSafeRedirectDispatcher(isGet), + dispatcher: getRequestDispatcher( + isGet, + targetUrl.href, + currentAllowedAddresses, + forceRedirectSSRFConnect, + ), + }; + } else { + currentInit = { + ...currentInit, + dispatcher: getRequestDispatcher( + isGet, + targetUrl.href, + currentAllowedAddresses, + forceRedirectSSRFConnect, + ), }; } @@ -1177,17 +1588,33 @@ export class MCPConnection extends EventEmitter { * The connect timeout is extended because proxies may delay initial response. */ const sseTimeout = this.timeout || SSE_CONNECT_TIMEOUT; - const ssrfConnect = this.useSSRFProtection - ? createSSRFSafeUndiciConnect(this.allowedAddresses, getUrlPort(url)) - : undefined; - const sseAgent = new Agent({ - bodyTimeout: sseTimeout, - headersTimeout: sseTimeout, - keepAliveTimeout: sseTimeout, - keepAliveMaxTimeout: sseTimeout * 2, - ...(ssrfConnect != null ? { connect: ssrfConnect } : {}), - }); - this.agents.push(sseAgent); + const sseAgents = new Map(); + const getSSEDispatcher = (targetUrlString: string): ManagedDispatcher => { + const proxyUrl = getProxyUrlForRequest(this.proxyConfig, targetUrlString); + const targetPort = getUrlPort(targetUrlString); + const key = `${proxyUrl ?? 'direct'}:${this.useSSRFProtection && !proxyUrl ? targetPort : 'open'}`; + const existingAgent = sseAgents.get(key); + if (existingAgent) { + return existingAgent; + } + + const connect = + this.useSSRFProtection && !proxyUrl + ? createSSRFSafeUndiciConnect(this.allowedAddresses, targetPort) + : undefined; + const agent = createMCPDispatcher({ + bodyTimeout: sseTimeout, + headersTimeout: sseTimeout, + keepAliveTimeout: sseTimeout, + keepAliveMaxTimeout: sseTimeout * 2, + proxyUrl, + ...(connect != null ? { connect } : {}), + }); + sseAgents.set(key, agent); + this.agents.push(agent); + return agent; + }; + getSSEDispatcher(options.url); const sseConfiguredSecretHeaderKeys: ReadonlySet = new Set( Object.keys(headers).map((key) => key.toLowerCase()), ); @@ -1198,15 +1625,25 @@ export class MCPConnection extends EventEmitter { signal: abortController.signal, }, eventSourceInit: { - fetch: (url, init) => { + fetch: async (url, init) => { + const { urlString, resolvedInit } = await resolveFetchInput( + url as UndiciRequestInfo, + init as UndiciRequestInit, + ); + await assertProxiedRequestTargetAllowed( + urlString, + this.proxyConfig, + this.useSSRFProtection, + this.allowedAddresses, + ); /** Merge headers: SSE defaults < init headers < user headers (user wins) */ const fetchHeaders = new Headers( - Object.assign({}, SSE_REQUEST_HEADERS, init?.headers, headers), + Object.assign({}, SSE_REQUEST_HEADERS, resolvedInit?.headers, headers), ); - return undiciFetch(url, { - ...init, + return undiciFetch(urlString, { + ...resolvedInit, redirect: 'manual', - dispatcher: sseAgent, + dispatcher: getSSEDispatcher(urlString), headers: fetchHeaders, }); }, diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index e1244fa605..52013d943d 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -990,6 +990,7 @@ describe('processMCPEnv', () => { process.env.OAUTH_CLIENT_ID = 'oauth-client-id-value'; process.env.OAUTH_CLIENT_SECRET = 'oauth-client-secret-value'; process.env.MCP_SERVER_URL = 'https://mcp.example.com'; + process.env.MCP_PROXY_URL = 'http://proxy.example.com:8080'; }); afterEach(() => { @@ -998,6 +999,7 @@ describe('processMCPEnv', () => { delete process.env.OAUTH_CLIENT_ID; delete process.env.OAUTH_CLIENT_SECRET; delete process.env.MCP_SERVER_URL; + delete process.env.MCP_PROXY_URL; }); it('should return null/undefined as-is', () => { @@ -1045,6 +1047,47 @@ describe('processMCPEnv', () => { }); }); + it('should process outbound proxy for remote MCP options', () => { + const options: MCPOptions = { + type: 'sse', + url: '${MCP_SERVER_URL}/sse', + proxy: '${MCP_PROXY_URL}', + }; + + const result = processMCPEnv({ options }); + + expect(result).toEqual({ + type: 'sse', + url: 'https://mcp.example.com/sse', + proxy: 'http://proxy.example.com:8080', + }); + }); + + it('should not process user-controlled placeholders in outbound proxy', () => { + const user = createTestUser({ id: 'user-proxy-target' }); + const body = { conversationId: 'conv-1', parentMessageId: 'parent-1', messageId: 'msg-1' }; + const options: MCPOptions = { + type: 'sse', + url: '${MCP_SERVER_URL}/sse', + proxy: + 'http://proxy.example.com/{{CUSTOM_PROXY_PATH}}/{{LIBRECHAT_USER_ID}}/{{LIBRECHAT_BODY_MESSAGEID}}', + }; + + const result = processMCPEnv({ + options, + user, + body, + customUserVars: { CUSTOM_PROXY_PATH: 'tenant-proxy' }, + }); + + expect(result).toEqual({ + type: 'sse', + url: 'https://mcp.example.com/sse', + proxy: + 'http://proxy.example.com/{{CUSTOM_PROXY_PATH}}/{{LIBRECHAT_USER_ID}}/{{LIBRECHAT_BODY_MESSAGEID}}', + }); + }); + it('should process OAuth configuration with environment variables', () => { const options: MCPOptions = { type: 'streamable-http', diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index b5220f1ae8..61c308682b 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -271,6 +271,13 @@ function processSingleValue({ return value; } +function processAdminValue(originalValue: string, dbSourced: boolean): string { + if (typeof originalValue !== 'string') { + return String(originalValue); + } + return dbSourced ? originalValue : extractEnvVariable(originalValue); +} + /** * Recursively processes an object to replace environment variables in string values * @param params - Processing parameters @@ -383,6 +390,11 @@ export function processMCPEnv(params: { }); } + // Process outbound proxy if it exists (for SSE and StreamableHTTP types) + if ('proxy' in newObj && newObj.proxy) { + newObj.proxy = processAdminValue(newObj.proxy, dbSourced); + } + // Process OAuth configuration if it exists (for all transport types) if ('oauth' in newObj && newObj.oauth) { const processedOAuth: Record = {}; diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts index 573769c4fa..6892c2ab16 100644 --- a/packages/data-provider/specs/mcp.spec.ts +++ b/packages/data-provider/specs/mcp.spec.ts @@ -1,4 +1,8 @@ -import { SSEOptionsSchema, MCPServerUserInputSchema } from '../src/mcp'; +import { + SSEOptionsSchema, + StreamableHTTPOptionsSchema, + MCPServerUserInputSchema, +} from '../src/mcp'; describe('MCPServerUserInputSchema', () => { describe('env variable exfiltration prevention', () => { @@ -52,6 +56,59 @@ describe('MCPServerUserInputSchema', () => { }); }); + describe('proxy field restrictions', () => { + it('should accept admin-configured proxies for SSE', () => { + const result = SSEOptionsSchema.safeParse({ + type: 'sse', + url: 'https://mcp-server.com/sse', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.proxy).toBe('http://proxy.example.com:8080'); + } + }); + + it('should accept admin-configured proxies for streamable-http', () => { + const result = StreamableHTTPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.proxy).toBe('http://proxy.example.com:8080'); + } + }); + + it('should reject unsupported proxy protocols', () => { + const result = StreamableHTTPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + proxy: 'ftp://proxy.example.com', + }); + expect(result.success).toBe(false); + }); + + it('should reject SSE proxy configuration from user input', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'sse', + url: 'https://mcp-server.com/sse', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(false); + }); + + it('should reject streamable-http proxy configuration from user input', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(false); + }); + }); + describe('protocol allowlisting', () => { it('should reject file:// URLs for SSE', () => { const result = MCPServerUserInputSchema.safeParse({ diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index b22a599b9b..4236d49096 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -103,6 +103,25 @@ const BaseOptionsSchema = z.object({ .optional(), }); +const ProxyUrlSchema = z + .string() + .transform((val: string) => extractEnvVariable(val)) + .pipe(z.string().url()) + .refine( + (val: string) => { + const protocol = new URL(val).protocol; + return ( + protocol === 'http:' || + protocol === 'https:' || + protocol === 'socks:' || + protocol === 'socks5:' + ); + }, + { + message: 'Proxy URL must use http://, https://, socks://, or socks5://', + }, + ); + export const StdioOptionsSchema = BaseOptionsSchema.extend({ type: z.literal('stdio').default('stdio'), /** @@ -163,6 +182,8 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({ export const SSEOptionsSchema = BaseOptionsSchema.extend({ type: z.literal('sse').default('sse'), headers: z.record(z.string(), z.string()).optional(), + /** Optional outbound proxy URL for this remote MCP transport */ + proxy: ProxyUrlSchema.optional(), url: z .string() .transform((val: string) => extractEnvVariable(val)) @@ -181,6 +202,8 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({ export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({ type: z.union([z.literal('streamable-http'), z.literal('http')]), headers: z.record(z.string(), z.string()).optional(), + /** Optional outbound proxy URL for this remote MCP transport */ + proxy: ProxyUrlSchema.optional(), url: z .string() .transform((val: string) => extractEnvVariable(val)) @@ -261,9 +284,11 @@ export const MCPServerUserInputSchema = z.union([ url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'), }), omitServerManagedFields(SSEOptionsSchema).extend({ + proxy: z.never().optional(), url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'), }), omitServerManagedFields(StreamableHTTPOptionsSchema).extend({ + proxy: z.never().optional(), url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'), }), ]);