mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-21 09:54:08 +01:00
* refactor: better SSRF Protection in Action and Tool Services - Added `createSSRFSafeAgents` function to create HTTP/HTTPS agents that block connections to private/reserved IP addresses, enhancing security against SSRF attacks. - Updated `createActionTool` to accept a `useSSRFProtection` parameter, allowing the use of SSRF-safe agents during tool execution. - Modified `processRequiredActions` and `loadAgentTools` to utilize the new SSRF protection feature based on allowed domains configuration. - Introduced `resolveHostnameSSRF` function to validate resolved IPs against private ranges, preventing potential SSRF vulnerabilities. - Enhanced tests for domain resolution and private IP detection to ensure robust SSRF protection mechanisms are in place. * feat: Implement SSRF protection in MCP connections - Added `createSSRFSafeUndiciConnect` function to provide SSRF-safe DNS lookup options for undici agents. - Updated `MCPConnection`, `MCPConnectionFactory`, and `ConnectionsRepository` to include `useSSRFProtection` parameter, enabling SSRF protection based on server configuration. - Enhanced `MCPManager` and `UserConnectionManager` to utilize SSRF protection when establishing connections. - Updated tests to validate the integration of SSRF protection across various components, ensuring robust security measures are in place. * refactor: WS MCPConnection with SSRF protection and async transport construction - Added `resolveHostnameSSRF` to validate WebSocket hostnames against private IP addresses, enhancing SSRF protection. - Updated `constructTransport` method to be asynchronous, ensuring proper handling of SSRF checks before establishing connections. - Improved error handling for WebSocket transport to prevent connections to potentially unsafe addresses. * test: Enhance ActionRequest tests for SSRF-safe agent passthrough - Added tests to verify that httpAgent and httpsAgent are correctly passed to axios.create when provided in ActionRequest. - Included scenarios to ensure agents are not included when no options are specified. - Enhanced coverage for POST requests to confirm agent passthrough functionality. - Improved overall test robustness for SSRF protection in ActionRequest execution.
541 lines
18 KiB
TypeScript
541 lines
18 KiB
TypeScript
import { logger } from '@librechat/data-schemas';
|
|
import type { TokenMethods, IUser } from '@librechat/data-schemas';
|
|
import type { FlowStateManager } from '~/flow/manager';
|
|
import type { MCPOAuthTokens } from '~/mcp/oauth';
|
|
import type * as t from '~/mcp/types';
|
|
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
|
import { MCPConnection } from '~/mcp/connection';
|
|
import { MCPOAuthHandler } from '~/mcp/oauth';
|
|
import { processMCPEnv } from '~/utils';
|
|
|
|
jest.mock('~/mcp/connection');
|
|
jest.mock('~/mcp/oauth');
|
|
jest.mock('~/utils');
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
debug: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockLogger = logger as jest.Mocked<typeof logger>;
|
|
const mockProcessMCPEnv = processMCPEnv as jest.MockedFunction<typeof processMCPEnv>;
|
|
const mockMCPConnection = MCPConnection as jest.MockedClass<typeof MCPConnection>;
|
|
const mockMCPOAuthHandler = MCPOAuthHandler as jest.Mocked<typeof MCPOAuthHandler>;
|
|
|
|
describe('MCPConnectionFactory', () => {
|
|
let mockUser: IUser | undefined;
|
|
let mockServerConfig: t.MCPOptions;
|
|
let mockFlowManager: jest.Mocked<FlowStateManager<MCPOAuthTokens | null>>;
|
|
let mockConnectionInstance: jest.Mocked<MCPConnection>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUser = {
|
|
id: 'user123',
|
|
email: 'test@example.com',
|
|
} as IUser;
|
|
|
|
mockServerConfig = {
|
|
command: 'node',
|
|
args: ['server.js'],
|
|
initTimeout: 5000,
|
|
} as t.MCPOptions;
|
|
|
|
mockFlowManager = {
|
|
createFlow: jest.fn(),
|
|
createFlowWithHandler: jest.fn(),
|
|
getFlowState: jest.fn(),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
} as unknown as jest.Mocked<FlowStateManager<MCPOAuthTokens | null>>;
|
|
|
|
mockConnectionInstance = {
|
|
connect: jest.fn(),
|
|
isConnected: jest.fn(),
|
|
setOAuthTokens: jest.fn(),
|
|
on: jest.fn().mockReturnValue(mockConnectionInstance),
|
|
once: jest.fn().mockReturnValue(mockConnectionInstance),
|
|
off: jest.fn().mockReturnValue(mockConnectionInstance),
|
|
removeListener: jest.fn().mockReturnValue(mockConnectionInstance),
|
|
emit: jest.fn(),
|
|
} as unknown as jest.Mocked<MCPConnection>;
|
|
|
|
mockMCPConnection.mockImplementation(() => mockConnectionInstance);
|
|
mockProcessMCPEnv.mockReturnValue(mockServerConfig);
|
|
});
|
|
|
|
describe('static create method', () => {
|
|
it('should create a basic connection without OAuth', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
mockConnectionInstance.isConnected.mockResolvedValue(true);
|
|
|
|
const connection = await MCPConnectionFactory.create(basicOptions);
|
|
|
|
expect(connection).toBe(mockConnectionInstance);
|
|
expect(mockProcessMCPEnv).toHaveBeenCalledWith({ options: mockServerConfig });
|
|
expect(mockMCPConnection).toHaveBeenCalledWith({
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
userId: undefined,
|
|
oauthTokens: null,
|
|
useSSRFProtection: false,
|
|
});
|
|
expect(mockConnectionInstance.connect).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should create a connection with OAuth', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
const oauthOptions = {
|
|
useOAuth: true as const,
|
|
user: mockUser,
|
|
flowManager: mockFlowManager,
|
|
tokenMethods: {
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
},
|
|
};
|
|
|
|
const mockTokens: MCPOAuthTokens = {
|
|
access_token: 'access123',
|
|
refresh_token: 'refresh123',
|
|
token_type: 'Bearer',
|
|
obtained_at: Date.now(),
|
|
};
|
|
|
|
mockFlowManager.createFlowWithHandler.mockResolvedValue(mockTokens);
|
|
mockConnectionInstance.isConnected.mockResolvedValue(true);
|
|
|
|
const connection = await MCPConnectionFactory.create(basicOptions, oauthOptions);
|
|
|
|
expect(connection).toBe(mockConnectionInstance);
|
|
expect(mockProcessMCPEnv).toHaveBeenCalledWith({ options: mockServerConfig, user: mockUser });
|
|
expect(mockMCPConnection).toHaveBeenCalledWith({
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
userId: 'user123',
|
|
oauthTokens: mockTokens,
|
|
useSSRFProtection: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OAuth token handling', () => {
|
|
it('should return null when no findToken method is provided', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
const oauthOptions: t.OAuthConnectionOptions = {
|
|
useOAuth: true as const,
|
|
user: mockUser,
|
|
flowManager: mockFlowManager,
|
|
tokenMethods: {
|
|
findToken: undefined as unknown as TokenMethods['findToken'],
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
},
|
|
};
|
|
|
|
mockConnectionInstance.isConnected.mockResolvedValue(true);
|
|
|
|
await MCPConnectionFactory.create(basicOptions, oauthOptions);
|
|
|
|
expect(mockFlowManager.createFlowWithHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle token retrieval errors gracefully', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
const oauthOptions = {
|
|
useOAuth: true as const,
|
|
user: mockUser,
|
|
flowManager: mockFlowManager,
|
|
tokenMethods: {
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
},
|
|
};
|
|
|
|
mockFlowManager.createFlowWithHandler.mockRejectedValue(new Error('Token fetch failed'));
|
|
mockConnectionInstance.isConnected.mockResolvedValue(true);
|
|
|
|
const connection = await MCPConnectionFactory.create(basicOptions, oauthOptions);
|
|
|
|
expect(connection).toBe(mockConnectionInstance);
|
|
expect(mockMCPConnection).toHaveBeenCalledWith({
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
userId: 'user123',
|
|
oauthTokens: null,
|
|
useSSRFProtection: false,
|
|
});
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
expect.stringContaining('No existing tokens found or error loading tokens'),
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('OAuth event handling', () => {
|
|
it('should handle oauthRequired event for returnOnOAuth scenario', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: {
|
|
...mockServerConfig,
|
|
url: 'https://api.example.com',
|
|
type: 'sse' as const,
|
|
} as t.SSEOptions,
|
|
};
|
|
|
|
const oauthOptions = {
|
|
useOAuth: true as const,
|
|
user: mockUser,
|
|
flowManager: mockFlowManager,
|
|
returnOnOAuth: true,
|
|
oauthStart: jest.fn(),
|
|
tokenMethods: {
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
},
|
|
};
|
|
|
|
const mockFlowData = {
|
|
authorizationUrl: 'https://auth.example.com',
|
|
flowId: 'flow123',
|
|
flowMetadata: {
|
|
serverName: 'test-server',
|
|
userId: 'user123',
|
|
serverUrl: 'https://api.example.com',
|
|
state: 'random-state',
|
|
clientInfo: { client_id: 'client123' },
|
|
},
|
|
};
|
|
|
|
mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue(mockFlowData);
|
|
mockFlowManager.createFlow.mockRejectedValue(new Error('Timeout expected'));
|
|
mockConnectionInstance.isConnected.mockResolvedValue(false);
|
|
|
|
let oauthRequiredHandler: (data: Record<string, unknown>) => Promise<void>;
|
|
mockConnectionInstance.on.mockImplementation((event, handler) => {
|
|
if (event === 'oauthRequired') {
|
|
oauthRequiredHandler = handler as (data: Record<string, unknown>) => Promise<void>;
|
|
}
|
|
return mockConnectionInstance;
|
|
});
|
|
|
|
try {
|
|
await MCPConnectionFactory.create(basicOptions, oauthOptions);
|
|
} catch {
|
|
// Expected to fail due to connection not established
|
|
}
|
|
|
|
expect(oauthRequiredHandler!).toBeDefined();
|
|
|
|
await oauthRequiredHandler!({ serverUrl: 'https://api.example.com' });
|
|
|
|
expect(mockMCPOAuthHandler.initiateOAuthFlow).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'https://api.example.com',
|
|
'user123',
|
|
{},
|
|
undefined,
|
|
);
|
|
expect(oauthOptions.oauthStart).toHaveBeenCalledWith('https://auth.example.com');
|
|
expect(mockConnectionInstance.emit).toHaveBeenCalledWith(
|
|
'oauthFailed',
|
|
expect.objectContaining({
|
|
message: 'OAuth flow initiated - return early',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should delete existing flow before creating new OAuth flow to prevent stale codeVerifier', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
user: mockUser,
|
|
};
|
|
|
|
const oauthOptions: t.OAuthConnectionOptions = {
|
|
user: mockUser,
|
|
useOAuth: true,
|
|
returnOnOAuth: true,
|
|
oauthStart: jest.fn(),
|
|
flowManager: mockFlowManager,
|
|
};
|
|
|
|
const mockFlowData = {
|
|
authorizationUrl: 'https://auth.example.com',
|
|
flowId: 'user123:test-server',
|
|
flowMetadata: {
|
|
serverName: 'test-server',
|
|
userId: 'user123',
|
|
serverUrl: 'https://api.example.com',
|
|
state: 'test-state',
|
|
codeVerifier: 'new-code-verifier-xyz',
|
|
clientInfo: { client_id: 'test-client' },
|
|
metadata: {
|
|
authorization_endpoint: 'https://auth.example.com/authorize',
|
|
token_endpoint: 'https://auth.example.com/token',
|
|
issuer: 'https://api.example.com',
|
|
},
|
|
},
|
|
};
|
|
|
|
mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue(mockFlowData);
|
|
mockFlowManager.deleteFlow.mockResolvedValue(true);
|
|
mockFlowManager.createFlow.mockRejectedValue(new Error('Timeout expected'));
|
|
mockConnectionInstance.isConnected.mockResolvedValue(false);
|
|
|
|
let oauthRequiredHandler: (data: Record<string, unknown>) => Promise<void>;
|
|
mockConnectionInstance.on.mockImplementation((event, handler) => {
|
|
if (event === 'oauthRequired') {
|
|
oauthRequiredHandler = handler as (data: Record<string, unknown>) => Promise<void>;
|
|
}
|
|
return mockConnectionInstance;
|
|
});
|
|
|
|
try {
|
|
await MCPConnectionFactory.create(basicOptions, oauthOptions);
|
|
} catch {
|
|
// Expected to fail due to connection not established
|
|
}
|
|
|
|
await oauthRequiredHandler!({ serverUrl: 'https://api.example.com' });
|
|
|
|
// Verify deleteFlow was called with correct parameters
|
|
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('user123:test-server', 'mcp_oauth');
|
|
|
|
// Verify deleteFlow was called before createFlow
|
|
const deleteCallOrder = mockFlowManager.deleteFlow.mock.invocationCallOrder[0];
|
|
const createCallOrder = mockFlowManager.createFlow.mock.invocationCallOrder[0];
|
|
expect(deleteCallOrder).toBeLessThan(createCallOrder);
|
|
|
|
// Verify createFlow was called with fresh metadata
|
|
// 4th arg is the abort signal (undefined in this test since no signal was provided)
|
|
expect(mockFlowManager.createFlow).toHaveBeenCalledWith(
|
|
'user123:test-server',
|
|
'mcp_oauth',
|
|
expect.objectContaining({
|
|
codeVerifier: 'new-code-verifier-xyz',
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('connection retry logic', () => {
|
|
it('should establish connection successfully', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig, // Use default 5000ms timeout
|
|
};
|
|
|
|
mockConnectionInstance.connect.mockResolvedValue(undefined);
|
|
mockConnectionInstance.isConnected.mockResolvedValue(true);
|
|
|
|
const connection = await MCPConnectionFactory.create(basicOptions);
|
|
|
|
expect(connection).toBe(mockConnectionInstance);
|
|
expect(mockConnectionInstance.connect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle OAuth errors during connection attempts', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
const oauthOptions = {
|
|
useOAuth: true as const,
|
|
user: mockUser,
|
|
flowManager: mockFlowManager,
|
|
oauthStart: jest.fn(),
|
|
tokenMethods: {
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
},
|
|
};
|
|
|
|
const oauthError = new Error('Non-200 status code (401)');
|
|
(oauthError as unknown as Record<string, unknown>).isOAuthError = true;
|
|
|
|
mockConnectionInstance.connect.mockRejectedValue(oauthError);
|
|
mockConnectionInstance.isConnected.mockResolvedValue(false);
|
|
|
|
await expect(MCPConnectionFactory.create(basicOptions, oauthOptions)).rejects.toThrow(
|
|
'Non-200 status code (401)',
|
|
);
|
|
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
expect.stringContaining('OAuth required, stopping connection attempts'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('isOAuthError method', () => {
|
|
it('should identify OAuth errors by message content', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
const oauthOptions = {
|
|
useOAuth: true as const,
|
|
user: mockUser,
|
|
flowManager: mockFlowManager,
|
|
tokenMethods: {
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
},
|
|
};
|
|
|
|
const error401 = new Error('401 Unauthorized');
|
|
|
|
mockConnectionInstance.connect.mockRejectedValue(error401);
|
|
mockConnectionInstance.isConnected.mockResolvedValue(false);
|
|
|
|
await expect(MCPConnectionFactory.create(basicOptions, oauthOptions)).rejects.toThrow('401');
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
expect.stringContaining('OAuth required, stopping connection attempts'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('discoverTools static method', () => {
|
|
const mockTools = [
|
|
{ name: 'tool1', description: 'First tool', inputSchema: { type: 'object' } },
|
|
{ name: 'tool2', description: 'Second tool', inputSchema: { type: 'object' } },
|
|
];
|
|
|
|
it('should discover tools from a successfully connected server', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
mockConnectionInstance.connect.mockResolvedValue(undefined);
|
|
mockConnectionInstance.isConnected.mockResolvedValue(true);
|
|
mockConnectionInstance.fetchTools = jest.fn().mockResolvedValue(mockTools);
|
|
|
|
const result = await MCPConnectionFactory.discoverTools(basicOptions);
|
|
|
|
expect(result.tools).toEqual(mockTools);
|
|
expect(result.oauthRequired).toBe(false);
|
|
expect(result.oauthUrl).toBeNull();
|
|
expect(result.connection).toBe(mockConnectionInstance);
|
|
});
|
|
|
|
it('should detect OAuth required without generating URL in discovery mode', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: {
|
|
...mockServerConfig,
|
|
url: 'https://api.example.com',
|
|
type: 'sse' as const,
|
|
} as t.SSEOptions,
|
|
};
|
|
|
|
const mockOAuthStart = jest.fn().mockResolvedValue(undefined);
|
|
|
|
const oauthOptions = {
|
|
useOAuth: true as const,
|
|
user: mockUser as unknown as IUser,
|
|
flowManager: mockFlowManager,
|
|
oauthStart: mockOAuthStart,
|
|
tokenMethods: {
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
},
|
|
};
|
|
|
|
mockConnectionInstance.isConnected.mockResolvedValue(false);
|
|
mockConnectionInstance.disconnect = jest.fn().mockResolvedValue(undefined);
|
|
|
|
let oauthHandler: (() => Promise<void>) | undefined;
|
|
mockConnectionInstance.on.mockImplementation((event, handler) => {
|
|
if (event === 'oauthRequired') {
|
|
oauthHandler = handler as () => Promise<void>;
|
|
}
|
|
return mockConnectionInstance;
|
|
});
|
|
|
|
mockConnectionInstance.connect.mockImplementation(async () => {
|
|
if (oauthHandler) {
|
|
await oauthHandler();
|
|
}
|
|
throw new Error('OAuth required');
|
|
});
|
|
|
|
const result = await MCPConnectionFactory.discoverTools(basicOptions, oauthOptions);
|
|
|
|
expect(result.connection).toBeNull();
|
|
expect(result.tools).toBeNull();
|
|
expect(result.oauthRequired).toBe(true);
|
|
expect(result.oauthUrl).toBeNull();
|
|
expect(mockOAuthStart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return null tools when discovery fails completely', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
mockConnectionInstance.connect.mockRejectedValue(new Error('Connection failed'));
|
|
mockConnectionInstance.isConnected.mockResolvedValue(false);
|
|
mockConnectionInstance.disconnect = jest.fn().mockResolvedValue(undefined);
|
|
|
|
const result = await MCPConnectionFactory.discoverTools(basicOptions);
|
|
|
|
expect(result.tools).toBeNull();
|
|
expect(result.connection).toBeNull();
|
|
expect(result.oauthRequired).toBe(false);
|
|
});
|
|
|
|
it('should handle disconnect errors gracefully during cleanup', async () => {
|
|
const basicOptions = {
|
|
serverName: 'test-server',
|
|
serverConfig: mockServerConfig,
|
|
};
|
|
|
|
mockConnectionInstance.connect.mockRejectedValue(new Error('Connection failed'));
|
|
mockConnectionInstance.isConnected.mockResolvedValue(false);
|
|
mockConnectionInstance.disconnect = jest
|
|
.fn()
|
|
.mockRejectedValue(new Error('Disconnect failed'));
|
|
|
|
const result = await MCPConnectionFactory.discoverTools(basicOptions);
|
|
|
|
expect(result.tools).toBeNull();
|
|
expect(mockLogger.debug).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|