mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🛣️ feat: Add MCP Remote Proxy Support (#13076)
* 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
This commit is contained in:
parent
d7482ebe06
commit
05a3d1ed81
9 changed files with 1534 additions and 66 deletions
|
|
@ -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=
|
||||
|
||||
#===================================#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Array<{ address: string; family: number }>>
|
||||
>;
|
||||
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<HeaderCaptureServer> {
|
|||
};
|
||||
}
|
||||
|
||||
async function createTunnelProxyCaptureServer(): Promise<HeaderCaptureServer> {
|
||||
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<void>((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<RawResponseServer> {
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<Record<ProxyEnvKey, string>> {
|
||||
const snapshot: Partial<Record<ProxyEnvKey, string>> = {};
|
||||
for (const key of proxyEnvKeys) {
|
||||
if (process.env[key] != null) {
|
||||
snapshot[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreProxyEnv(snapshot: Partial<Record<ProxyEnvKey, string>>): 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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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<string, string> | null | undefined,
|
||||
timeout?: number,
|
||||
sseBodyTimeout?: number,
|
||||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Response>;
|
||||
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<string, unknown>,
|
||||
): Error => {
|
||||
const buildAndLogBlockedError = (reason: string, details: Record<string, unknown>): Error => {
|
||||
const message = buildBlockedMCPResponseMessage(reason, {
|
||||
maxResponseBytes,
|
||||
maxLineBytes,
|
||||
|
|
@ -509,7 +530,7 @@ function normalizeInitHeaders(init: UndiciRequestInit | undefined): Record<strin
|
|||
|
||||
function buildFetchInit(
|
||||
init: UndiciRequestInit | undefined,
|
||||
dispatcher: Agent,
|
||||
dispatcher: Dispatcher,
|
||||
requestHeaders: Record<string, string> | 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<string>(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<typeof createSSRFSafeUndiciConnect>;
|
||||
}): 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<void> {
|
||||
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<void> {
|
||||
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<UndiciResponse> {
|
||||
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<string, ManagedDispatcher>();
|
||||
const ssrfConnects = new Map<string, ReturnType<typeof createSSRFSafeUndiciConnect>>();
|
||||
|
||||
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<typeof createSSRFSafeUndiciConnect> => {
|
||||
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<string, ManagedDispatcher>();
|
||||
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<string> = 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,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, boolean | string | string[] | undefined> = {};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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://'),
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue