mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-09 09:32:36 +01:00
🛡️ fix: Implement TOCTOU-Safe SSRF Protection for Actions and MCP (#11722)
* 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.
This commit is contained in:
parent
d6b6f191f7
commit
924be3b647
21 changed files with 567 additions and 53 deletions
|
|
@ -1,12 +1,21 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
jest.mock('node:dns/promises', () => ({
|
||||
lookup: jest.fn(),
|
||||
}));
|
||||
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import {
|
||||
extractMCPServerDomain,
|
||||
isActionDomainAllowed,
|
||||
isEmailDomainAllowed,
|
||||
isMCPDomainAllowed,
|
||||
isPrivateIP,
|
||||
isSSRFTarget,
|
||||
resolveHostnameSSRF,
|
||||
} from './domain';
|
||||
|
||||
const mockedLookup = lookup as jest.MockedFunction<typeof lookup>;
|
||||
|
||||
describe('isEmailDomainAllowed', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -192,7 +201,154 @@ describe('isSSRFTarget', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isPrivateIP', () => {
|
||||
describe('IPv4 private ranges', () => {
|
||||
it('should detect loopback addresses', () => {
|
||||
expect(isPrivateIP('127.0.0.1')).toBe(true);
|
||||
expect(isPrivateIP('127.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 10.x.x.x private range', () => {
|
||||
expect(isPrivateIP('10.0.0.1')).toBe(true);
|
||||
expect(isPrivateIP('10.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 172.16-31.x.x private range', () => {
|
||||
expect(isPrivateIP('172.16.0.1')).toBe(true);
|
||||
expect(isPrivateIP('172.31.255.255')).toBe(true);
|
||||
expect(isPrivateIP('172.15.0.1')).toBe(false);
|
||||
expect(isPrivateIP('172.32.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect 192.168.x.x private range', () => {
|
||||
expect(isPrivateIP('192.168.0.1')).toBe(true);
|
||||
expect(isPrivateIP('192.168.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 169.254.x.x link-local range', () => {
|
||||
expect(isPrivateIP('169.254.169.254')).toBe(true);
|
||||
expect(isPrivateIP('169.254.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 0.0.0.0', () => {
|
||||
expect(isPrivateIP('0.0.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow public IPs', () => {
|
||||
expect(isPrivateIP('8.8.8.8')).toBe(false);
|
||||
expect(isPrivateIP('1.1.1.1')).toBe(false);
|
||||
expect(isPrivateIP('93.184.216.34')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv6 private ranges', () => {
|
||||
it('should detect loopback', () => {
|
||||
expect(isPrivateIP('::1')).toBe(true);
|
||||
expect(isPrivateIP('::')).toBe(true);
|
||||
expect(isPrivateIP('[::1]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect unique local (fc/fd) and link-local (fe80)', () => {
|
||||
expect(isPrivateIP('fc00::1')).toBe(true);
|
||||
expect(isPrivateIP('fd00::1')).toBe(true);
|
||||
expect(isPrivateIP('fe80::1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4-mapped IPv6 addresses', () => {
|
||||
it('should detect private IPs in IPv4-mapped IPv6 form', () => {
|
||||
expect(isPrivateIP('::ffff:169.254.169.254')).toBe(true);
|
||||
expect(isPrivateIP('::ffff:127.0.0.1')).toBe(true);
|
||||
expect(isPrivateIP('::ffff:10.0.0.1')).toBe(true);
|
||||
expect(isPrivateIP('::ffff:192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow public IPs in IPv4-mapped IPv6 form', () => {
|
||||
expect(isPrivateIP('::ffff:8.8.8.8')).toBe(false);
|
||||
expect(isPrivateIP('::ffff:93.184.216.34')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHostnameSSRF', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should detect domains that resolve to private IPs (nip.io bypass)', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }] as never);
|
||||
expect(await resolveHostnameSSRF('169.254.169.254.nip.io')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect domains that resolve to loopback', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as never);
|
||||
expect(await resolveHostnameSSRF('loopback.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect when any resolved address is private', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([
|
||||
{ address: '93.184.216.34', family: 4 },
|
||||
{ address: '10.0.0.1', family: 4 },
|
||||
] as never);
|
||||
expect(await resolveHostnameSSRF('dual.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow domains that resolve to public IPs', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }] as never);
|
||||
expect(await resolveHostnameSSRF('example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip literal IPv4 addresses (handled by isSSRFTarget)', async () => {
|
||||
expect(await resolveHostnameSSRF('169.254.169.254')).toBe(false);
|
||||
expect(mockedLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip literal IPv6 addresses', async () => {
|
||||
expect(await resolveHostnameSSRF('::1')).toBe(false);
|
||||
expect(mockedLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail open on DNS resolution failure', async () => {
|
||||
mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND'));
|
||||
expect(await resolveHostnameSSRF('nonexistent.example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActionDomainAllowed - DNS resolution SSRF protection', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should block domains resolving to cloud metadata IP (169.254.169.254)', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }] as never);
|
||||
expect(await isActionDomainAllowed('169.254.169.254.nip.io', null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should block domains resolving to private 10.x range', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
|
||||
expect(await isActionDomainAllowed('internal.attacker.com', null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should block domains resolving to 172.16.x range', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([{ address: '172.16.0.1', family: 4 }] as never);
|
||||
expect(await isActionDomainAllowed('docker.attacker.com', null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow domains resolving to public IPs when no allowlist', async () => {
|
||||
mockedLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }] as never);
|
||||
expect(await isActionDomainAllowed('example.com', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not perform DNS check when allowedDomains is configured', async () => {
|
||||
expect(await isActionDomainAllowed('example.com', ['example.com'])).toBe(true);
|
||||
expect(mockedLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActionDomainAllowed', () => {
|
||||
beforeEach(() => {
|
||||
mockedLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
|
@ -541,6 +697,9 @@ describe('extractMCPServerDomain', () => {
|
|||
});
|
||||
|
||||
describe('isMCPDomainAllowed', () => {
|
||||
beforeEach(() => {
|
||||
mockedLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue