mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 16:07:30 +00:00
Merge 33514c579c into 6b5596ec36
This commit is contained in:
commit
6ccc5b60ec
9 changed files with 627 additions and 35 deletions
|
|
@ -116,6 +116,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=
|
||||
|
||||
#===================================#
|
||||
|
|
|
|||
|
|
@ -304,6 +304,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
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ jest.mock('~/auth', () => ({
|
|||
callback(null, '127.0.0.1', 4);
|
||||
},
|
||||
})),
|
||||
isSSRFTarget: jest.fn(() => false),
|
||||
resolveHostnameSSRF: jest.fn(async () => false),
|
||||
}));
|
||||
|
||||
|
|
@ -852,6 +853,269 @@ describe('MCP SSRF protection – customFetch input shapes', () => {
|
|||
return factory.call(connection, () => null);
|
||||
}
|
||||
|
||||
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 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.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,6 @@
|
|||
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 +15,30 @@ 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 { 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 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;
|
||||
|
|
@ -164,7 +178,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;
|
||||
|
|
@ -196,6 +210,166 @@ function getUrlPort(url: URL | string): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
function getTrimmedEnv(...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
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 getProxyEntryPort(entry: string): { hostname: string; port: number } {
|
||||
const parsed = entry.match(/^(.+):(\d+)$/);
|
||||
return {
|
||||
hostname: (parsed ? parsed[1] : entry)
|
||||
.replace(/^\*?\./, '')
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.toLowerCase(),
|
||||
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;
|
||||
}
|
||||
|
||||
const proxyEntry = getProxyEntryPort(entry);
|
||||
if (proxyEntry.port && proxyEntry.port !== port) {
|
||||
continue;
|
||||
}
|
||||
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 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);
|
||||
const isBlockedTarget =
|
||||
isSSRFTarget(targetUrl.hostname, allowedAddresses, port) ||
|
||||
(await resolveHostnameSSRF(targetUrl.hostname, allowedAddresses, port));
|
||||
|
||||
if (!isBlockedTarget) {
|
||||
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
|
||||
|
|
@ -413,7 +587,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;
|
||||
|
|
@ -423,6 +597,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;
|
||||
|
|
@ -538,6 +713,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;
|
||||
|
|
@ -580,57 +756,69 @@ export class MCPConnection extends EventEmitter {
|
|||
configuredSecretHeaderKeys?: ReadonlySet<string>,
|
||||
baseUrl?: string,
|
||||
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
|
||||
const proxyConfig = this.proxyConfig;
|
||||
const initialProxyUrl = baseUrl ? getProxyUrlForRequest(proxyConfig, baseUrl) : undefined;
|
||||
const basePort = baseUrl ? getUrlPort(baseUrl) : '';
|
||||
const ssrfConnect = this.useSSRFProtection
|
||||
? createSSRFSafeUndiciConnect(this.allowedAddresses, basePort)
|
||||
: undefined;
|
||||
const ssrfConnect =
|
||||
this.useSSRFProtection && !initialProxyUrl
|
||||
? createSSRFSafeUndiciConnect(this.allowedAddresses, basePort)
|
||||
: undefined;
|
||||
const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {};
|
||||
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 effectiveTimeout = timeout || DEFAULT_TIMEOUT;
|
||||
const postAgent = new Agent({
|
||||
const postAgent = createMCPDispatcher({
|
||||
bodyTimeout: effectiveTimeout,
|
||||
headersTimeout: effectiveTimeout,
|
||||
proxyUrl: initialProxyUrl,
|
||||
...connectOpts,
|
||||
});
|
||||
this.agents.push(postAgent);
|
||||
|
||||
let getAgent: Agent | undefined;
|
||||
let getAgent: ManagedDispatcher | undefined;
|
||||
if (sseBodyTimeout != null) {
|
||||
getAgent = new Agent({
|
||||
getAgent = createMCPDispatcher({
|
||||
bodyTimeout: sseBodyTimeout,
|
||||
headersTimeout: effectiveTimeout,
|
||||
proxyUrl: initialProxyUrl,
|
||||
...connectOpts,
|
||||
});
|
||||
this.agents.push(getAgent);
|
||||
}
|
||||
|
||||
let safeRedirectPostAgent: Agent | undefined;
|
||||
let safeRedirectGetAgent: Agent | undefined;
|
||||
const safeRedirectAgents = new Map<string, ManagedDispatcher>();
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const createSafeRedirectAgent = (bodyTimeout: number): Agent => {
|
||||
const redirectSSRFConnect = createSSRFSafeUndiciConnect();
|
||||
const agent = new Agent({
|
||||
const getSafeRedirectDispatcher = (
|
||||
isGetRequest: boolean,
|
||||
targetUrlString: string,
|
||||
): ManagedDispatcher => {
|
||||
const bodyTimeout =
|
||||
isGetRequest && sseBodyTimeout != null ? sseBodyTimeout : effectiveTimeout;
|
||||
const redirectProxyUrl = getProxyUrlForRequest(proxyConfig, targetUrlString);
|
||||
const key = `${bodyTimeout}:${redirectProxyUrl ?? 'direct'}`;
|
||||
const existingAgent = safeRedirectAgents.get(key);
|
||||
if (existingAgent) {
|
||||
return existingAgent;
|
||||
}
|
||||
|
||||
const redirectSSRFConnect = redirectProxyUrl ? undefined : createSSRFSafeUndiciConnect();
|
||||
const agent = createMCPDispatcher({
|
||||
bodyTimeout,
|
||||
headersTimeout: effectiveTimeout,
|
||||
connect: redirectSSRFConnect,
|
||||
proxyUrl: redirectProxyUrl,
|
||||
...(redirectSSRFConnect != null ? { connect: redirectSSRFConnect } : {}),
|
||||
});
|
||||
safeRedirectAgents.set(key, agent);
|
||||
agents.push(agent);
|
||||
return agent;
|
||||
};
|
||||
const getSafeRedirectDispatcher = (isGetRequest: boolean): Agent => {
|
||||
if (!isGetRequest || sseBodyTimeout == null) {
|
||||
safeRedirectPostAgent ??= createSafeRedirectAgent(effectiveTimeout);
|
||||
return safeRedirectPostAgent;
|
||||
}
|
||||
safeRedirectGetAgent ??= createSafeRedirectAgent(sseBodyTimeout);
|
||||
return safeRedirectGetAgent;
|
||||
};
|
||||
|
||||
return async function customFetch(
|
||||
input: UndiciRequestInfo,
|
||||
|
|
@ -663,9 +851,16 @@ export class MCPConnection extends EventEmitter {
|
|||
|
||||
let currentInit = buildFetchInit(resolvedInit, dispatcher, requestHeaders);
|
||||
let currentUrlString = urlString;
|
||||
let currentAllowedAddresses = allowedAddresses;
|
||||
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;
|
||||
|
||||
|
|
@ -695,7 +890,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)}`,
|
||||
);
|
||||
|
|
@ -715,6 +910,7 @@ export class MCPConnection extends EventEmitter {
|
|||
}
|
||||
|
||||
if (isCrossOriginRedirect) {
|
||||
currentAllowedAddresses = null;
|
||||
/**
|
||||
* Once a server-controlled cross-origin hop is seen, keep the safe
|
||||
* dispatcher for the rest of this redirect chain. Restoring the
|
||||
|
|
@ -725,7 +921,7 @@ export class MCPConnection extends EventEmitter {
|
|||
*/
|
||||
currentInit = {
|
||||
...currentInit,
|
||||
dispatcher: getSafeRedirectDispatcher(isGet),
|
||||
dispatcher: getSafeRedirectDispatcher(isGet, targetUrl.href),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -821,14 +1017,17 @@ 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({
|
||||
const sseProxyUrl = getProxyUrlForRequest(this.proxyConfig, options.url);
|
||||
const ssrfConnect =
|
||||
this.useSSRFProtection && !sseProxyUrl
|
||||
? createSSRFSafeUndiciConnect(this.allowedAddresses, getUrlPort(url))
|
||||
: undefined;
|
||||
const sseAgent = createMCPDispatcher({
|
||||
bodyTimeout: sseTimeout,
|
||||
headersTimeout: sseTimeout,
|
||||
keepAliveTimeout: sseTimeout,
|
||||
keepAliveMaxTimeout: sseTimeout * 2,
|
||||
proxyUrl: sseProxyUrl,
|
||||
...(ssrfConnect != null ? { connect: ssrfConnect } : {}),
|
||||
});
|
||||
this.agents.push(sseAgent);
|
||||
|
|
@ -842,13 +1041,23 @@ 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,
|
||||
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,22 @@ 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 process OAuth configuration with environment variables', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'streamable-http',
|
||||
|
|
|
|||
|
|
@ -383,6 +383,17 @@ export function processMCPEnv(params: {
|
|||
});
|
||||
}
|
||||
|
||||
// Process outbound proxy if it exists (for SSE and StreamableHTTP types)
|
||||
if ('proxy' in newObj && newObj.proxy) {
|
||||
newObj.proxy = processSingleValue({
|
||||
user,
|
||||
body,
|
||||
dbSourced,
|
||||
customUserVars,
|
||||
originalValue: newObj.proxy,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,38 @@ describe('MCPServerUserInputSchema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('proxy field restrictions', () => {
|
||||
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 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