mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-09 09:32:36 +01:00
🔒 fix: Hex-normalized IPv4-mapped IPv6 in Domain Validation (#12130)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔒 fix: handle hex-normalized IPv4-mapped IPv6 in domain validation
* fix: Enhance IPv6 private address detection in domain validation
- Added tests for detecting IPv4-compatible, 6to4, NAT64, and Teredo addresses.
- Implemented `extractEmbeddedIPv4` function to identify private IPv4 addresses within various IPv6 formats.
- Updated `isPrivateIP` function to utilize the new extraction logic for improved accuracy in address validation.
* fix: Update private IPv4 detection logic in domain validation
- Enhanced the `isPrivateIPv4` function to accurately identify additional private and non-routable IPv4 ranges.
- Adjusted the return logic in `resolveHostnameSSRF` to utilize the updated private IP detection for improved hostname validation.
* test: Expand private IP detection tests in domain validation
- Added tests for additional private IPv4 ranges including 0.0.0.0/8, 100.64.0.0/10, 192.0.0.0/24, and 198.18.0.0/15.
- Updated existing tests to ensure accurate detection of private and multicast IP addresses in the `isPrivateIP` function.
- Enhanced `resolveHostnameSSRF` to correctly identify private literal IPv4 addresses without DNS lookup.
* refactor: Rename and enhance embedded IPv4 detection in IPv6 addresses
- Renamed `extractEmbeddedIPv4` to `hasPrivateEmbeddedIPv4` for clarity on its purpose.
- Updated logic to accurately check for private IPv4 addresses embedded in Teredo, 6to4, and NAT64 IPv6 formats.
- Improved the `isPrivateIP` function to utilize the new naming and logic for better readability and accuracy.
- Enhanced documentation for clarity on the functionality of the updated methods.
* feat: Enhance private IPv4 detection in embedded IPv6 addresses
- Added additional checks in `hasPrivateEmbeddedIPv4` to ensure only valid private IPv4 formats are recognized.
- Improved the logic for identifying private IPv4 addresses embedded within various IPv6 formats, enhancing overall accuracy.
* test: Add additional test for hostname resolution in SSRF detection
- Included a new test case in `resolveHostnameSSRF` to validate the detection of private IPv4 addresses embedded in IPv6 formats for the hostname 'meta.example.com'.
- Enhanced existing tests to ensure comprehensive coverage of hostname resolution scenarios.
* fix: Set redirect option to 'manual' in undiciFetch calls
- Updated undiciFetch calls in MCPConnection to include the redirect option set to 'manual' for better control over HTTP redirects.
- Added documentation comments regarding SSRF pre-checks for WebSocket connections, highlighting the limitations of the current SDK regarding DNS resolution.
* test: Add integration tests for MCP SSRF protections
- Introduced a new test suite for MCP SSRF protections, verifying that MCPConnection does not follow HTTP redirects to private IPs and blocks WebSocket connections to private IPs when SSRF protection is enabled.
- Implemented tests to ensure correct behavior of the connection under various scenarios, including redirect handling and WebSocket DNS resolution.
* refactor: Improve SSRF protection logic for WebSocket connections
- Enhanced the SSRF pre-check for WebSocket connections to validate resolved IPs, ensuring that allowlisting a domain does not grant trust to its resolved IPs at runtime.
- Updated documentation comments to clarify the limitations of the current SDK regarding DNS resolution and the implications for SSRF protection.
* test: Enhance MCP SSRF protection tests for redirect handling and WebSocket connections
- Updated tests to ensure that MCPConnection does not follow HTTP redirects to private IPs, regardless of SSRF protection settings.
- Added checks to verify that WebSocket connections to hosts resolving to private IPs are blocked, even when SSRF protection is disabled.
- Improved documentation comments for clarity on the behavior of the tests and the implications for SSRF protection.
* test: Refactor MCP SSRF protection test for WebSocket connection errors
- Updated the test to use `await expect(...).rejects.not.toThrow(...)` for better readability and clarity.
- Simplified the error handling logic while ensuring that SSRF rejections are correctly validated during connection failures.
This commit is contained in:
parent
2ac62a2e71
commit
4a8a5b5994
4 changed files with 627 additions and 33 deletions
|
|
@ -153,8 +153,9 @@ describe('isSSRFTarget', () => {
|
||||||
expect(isSSRFTarget('169.254.0.1')).toBe(true);
|
expect(isSSRFTarget('169.254.0.1')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should block 0.0.0.0', () => {
|
it('should block 0.0.0.0/8 (current network)', () => {
|
||||||
expect(isSSRFTarget('0.0.0.0')).toBe(true);
|
expect(isSSRFTarget('0.0.0.0')).toBe(true);
|
||||||
|
expect(isSSRFTarget('0.1.2.3')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow public IPs', () => {
|
it('should allow public IPs', () => {
|
||||||
|
|
@ -230,8 +231,36 @@ describe('isPrivateIP', () => {
|
||||||
expect(isPrivateIP('169.254.0.1')).toBe(true);
|
expect(isPrivateIP('169.254.0.1')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect 0.0.0.0', () => {
|
it('should detect 0.0.0.0/8 (current network)', () => {
|
||||||
expect(isPrivateIP('0.0.0.0')).toBe(true);
|
expect(isPrivateIP('0.0.0.0')).toBe(true);
|
||||||
|
expect(isPrivateIP('0.1.2.3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect 100.64.0.0/10 (CGNAT / shared address space)', () => {
|
||||||
|
expect(isPrivateIP('100.64.0.1')).toBe(true);
|
||||||
|
expect(isPrivateIP('100.127.255.255')).toBe(true);
|
||||||
|
expect(isPrivateIP('100.63.255.255')).toBe(false);
|
||||||
|
expect(isPrivateIP('100.128.0.1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect 192.0.0.0/24 (IETF protocol assignments)', () => {
|
||||||
|
expect(isPrivateIP('192.0.0.1')).toBe(true);
|
||||||
|
expect(isPrivateIP('192.0.0.255')).toBe(true);
|
||||||
|
expect(isPrivateIP('192.0.1.1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect 198.18.0.0/15 (benchmarking)', () => {
|
||||||
|
expect(isPrivateIP('198.18.0.1')).toBe(true);
|
||||||
|
expect(isPrivateIP('198.19.255.255')).toBe(true);
|
||||||
|
expect(isPrivateIP('198.17.0.1')).toBe(false);
|
||||||
|
expect(isPrivateIP('198.20.0.1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved)', () => {
|
||||||
|
expect(isPrivateIP('224.0.0.1')).toBe(true);
|
||||||
|
expect(isPrivateIP('239.255.255.255')).toBe(true);
|
||||||
|
expect(isPrivateIP('240.0.0.1')).toBe(true);
|
||||||
|
expect(isPrivateIP('255.255.255.255')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow public IPs', () => {
|
it('should allow public IPs', () => {
|
||||||
|
|
@ -270,6 +299,144 @@ describe('isPrivateIP', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isPrivateIP - IPv4-mapped IPv6 hex-normalized form (CVE-style SSRF bypass)', () => {
|
||||||
|
/**
|
||||||
|
* Node.js URL parser normalizes IPv4-mapped IPv6 from dotted-decimal to hex:
|
||||||
|
* new URL('http://[::ffff:169.254.169.254]/').hostname → '::ffff:a9fe:a9fe'
|
||||||
|
*
|
||||||
|
* These tests confirm whether isPrivateIP catches the hex form that actually
|
||||||
|
* reaches it in production (via parseDomainSpec → new URL → hostname).
|
||||||
|
*/
|
||||||
|
it('should detect hex-normalized AWS metadata address (::ffff:a9fe:a9fe)', () => {
|
||||||
|
// ::ffff:169.254.169.254 → hex form after URL parsing
|
||||||
|
expect(isPrivateIP('::ffff:a9fe:a9fe')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect hex-normalized loopback (::ffff:7f00:1)', () => {
|
||||||
|
// ::ffff:127.0.0.1 → hex form after URL parsing
|
||||||
|
expect(isPrivateIP('::ffff:7f00:1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect hex-normalized 192.168.x.x (::ffff:c0a8:101)', () => {
|
||||||
|
// ::ffff:192.168.1.1 → hex form after URL parsing
|
||||||
|
expect(isPrivateIP('::ffff:c0a8:101')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect hex-normalized 10.x.x.x (::ffff:a00:1)', () => {
|
||||||
|
// ::ffff:10.0.0.1 → hex form after URL parsing
|
||||||
|
expect(isPrivateIP('::ffff:a00:1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect hex-normalized 172.16.x.x (::ffff:ac10:1)', () => {
|
||||||
|
// ::ffff:172.16.0.1 → hex form after URL parsing
|
||||||
|
expect(isPrivateIP('::ffff:ac10:1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect hex-normalized 0.0.0.0 (::ffff:0:0)', () => {
|
||||||
|
// ::ffff:0.0.0.0 → hex form after URL parsing
|
||||||
|
expect(isPrivateIP('::ffff:0:0')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow hex-normalized public IPs (::ffff:808:808 = 8.8.8.8)', () => {
|
||||||
|
expect(isPrivateIP('::ffff:808:808')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect IPv4-compatible addresses without ffff prefix (::XXXX:XXXX)', () => {
|
||||||
|
expect(isPrivateIP('::7f00:1')).toBe(true);
|
||||||
|
expect(isPrivateIP('::a9fe:a9fe')).toBe(true);
|
||||||
|
expect(isPrivateIP('::c0a8:101')).toBe(true);
|
||||||
|
expect(isPrivateIP('::a00:1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow public IPs in IPv4-compatible form', () => {
|
||||||
|
expect(isPrivateIP('::808:808')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect 6to4 addresses embedding private IPv4 (2002:XXXX:XXXX::)', () => {
|
||||||
|
expect(isPrivateIP('2002:7f00:1::')).toBe(true);
|
||||||
|
expect(isPrivateIP('2002:a9fe:a9fe::')).toBe(true);
|
||||||
|
expect(isPrivateIP('2002:c0a8:101::')).toBe(true);
|
||||||
|
expect(isPrivateIP('2002:a00:1::')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow 6to4 addresses embedding public IPv4', () => {
|
||||||
|
expect(isPrivateIP('2002:808:808::')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect NAT64 addresses embedding private IPv4 (64:ff9b::XXXX:XXXX)', () => {
|
||||||
|
expect(isPrivateIP('64:ff9b::7f00:1')).toBe(true);
|
||||||
|
expect(isPrivateIP('64:ff9b::a9fe:a9fe')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Teredo addresses with complement-encoded private IPv4 (RFC 4380)', () => {
|
||||||
|
// Teredo stores external IPv4 as bitwise complement in last 32 bits
|
||||||
|
// 127.0.0.1 → complement: 0x80ff:0xfffe
|
||||||
|
expect(isPrivateIP('2001::80ff:fffe')).toBe(true);
|
||||||
|
// 169.254.169.254 → complement: 0x5601:0x5601
|
||||||
|
expect(isPrivateIP('2001::5601:5601')).toBe(true);
|
||||||
|
// 10.0.0.1 → complement: 0xf5ff:0xfffe
|
||||||
|
expect(isPrivateIP('2001::f5ff:fffe')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow Teredo addresses with complement-encoded public IPv4', () => {
|
||||||
|
// 8.8.8.8 → complement: 0xf7f7:0xf7f7
|
||||||
|
expect(isPrivateIP('2001::f7f7:f7f7')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should confirm URL parser produces the hex form that bypasses dotted regex', () => {
|
||||||
|
// This test documents the exact normalization gap
|
||||||
|
const hostname = new URL('http://[::ffff:169.254.169.254]/').hostname.replace(/^\[|\]$/g, '');
|
||||||
|
expect(hostname).toBe('::ffff:a9fe:a9fe'); // hex, not dotted
|
||||||
|
// The hostname that actually reaches isPrivateIP must be caught
|
||||||
|
expect(isPrivateIP(hostname)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isActionDomainAllowed - IPv4-mapped IPv6 hex SSRF bypass (end-to-end)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block http://[::ffff:169.254.169.254]/ (AWS metadata via IPv6)', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[::ffff:169.254.169.254]/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block http://[::ffff:127.0.0.1]/ (loopback via IPv6)', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[::ffff:127.0.0.1]/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block http://[::ffff:192.168.1.1]/ (private via IPv6)', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[::ffff:192.168.1.1]/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block http://[::ffff:10.0.0.1]/ (private via IPv6)', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[::ffff:10.0.0.1]/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow http://[::ffff:8.8.8.8]/ (public via IPv6)', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[::ffff:8.8.8.8]/', null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block IPv4-compatible IPv6 without ffff prefix', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[::127.0.0.1]/', null)).toBe(false);
|
||||||
|
expect(await isActionDomainAllowed('http://[::169.254.169.254]/', null)).toBe(false);
|
||||||
|
expect(await isActionDomainAllowed('http://[0:0:0:0:0:0:127.0.0.1]/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block 6to4 addresses embedding private IPv4', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[2002:7f00:1::]/', null)).toBe(false);
|
||||||
|
expect(await isActionDomainAllowed('http://[2002:a9fe:a9fe::]/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block NAT64 addresses embedding private IPv4', async () => {
|
||||||
|
expect(await isActionDomainAllowed('http://[64:ff9b::127.0.0.1]/', null)).toBe(false);
|
||||||
|
expect(await isActionDomainAllowed('http://[64:ff9b::169.254.169.254]/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('resolveHostnameSSRF', () => {
|
describe('resolveHostnameSSRF', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
@ -298,16 +465,50 @@ describe('resolveHostnameSSRF', () => {
|
||||||
expect(await resolveHostnameSSRF('example.com')).toBe(false);
|
expect(await resolveHostnameSSRF('example.com')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip literal IPv4 addresses (handled by isSSRFTarget)', async () => {
|
it('should detect private literal IPv4 addresses without DNS lookup', async () => {
|
||||||
expect(await resolveHostnameSSRF('169.254.169.254')).toBe(false);
|
expect(await resolveHostnameSSRF('169.254.169.254')).toBe(true);
|
||||||
|
expect(await resolveHostnameSSRF('127.0.0.1')).toBe(true);
|
||||||
|
expect(await resolveHostnameSSRF('10.0.0.1')).toBe(true);
|
||||||
expect(mockedLookup).not.toHaveBeenCalled();
|
expect(mockedLookup).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip literal IPv6 addresses', async () => {
|
it('should allow public literal IPv4 addresses without DNS lookup', async () => {
|
||||||
expect(await resolveHostnameSSRF('::1')).toBe(false);
|
expect(await resolveHostnameSSRF('8.8.8.8')).toBe(false);
|
||||||
|
expect(await resolveHostnameSSRF('93.184.216.34')).toBe(false);
|
||||||
expect(mockedLookup).not.toHaveBeenCalled();
|
expect(mockedLookup).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should detect private IPv6 literals without DNS lookup', async () => {
|
||||||
|
expect(await resolveHostnameSSRF('::1')).toBe(true);
|
||||||
|
expect(await resolveHostnameSSRF('fc00::1')).toBe(true);
|
||||||
|
expect(await resolveHostnameSSRF('fe80::1')).toBe(true);
|
||||||
|
expect(mockedLookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect hex-normalized IPv4-mapped IPv6 literals', async () => {
|
||||||
|
expect(await resolveHostnameSSRF('::ffff:a9fe:a9fe')).toBe(true);
|
||||||
|
expect(await resolveHostnameSSRF('::ffff:7f00:1')).toBe(true);
|
||||||
|
expect(await resolveHostnameSSRF('[::ffff:a9fe:a9fe]')).toBe(true);
|
||||||
|
expect(mockedLookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow public IPv6 literals without DNS lookup', async () => {
|
||||||
|
expect(await resolveHostnameSSRF('2001:db8::1')).toBe(false);
|
||||||
|
expect(await resolveHostnameSSRF('::ffff:808:808')).toBe(false);
|
||||||
|
expect(mockedLookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect private IPv6 addresses returned from DNS lookup', async () => {
|
||||||
|
mockedLookup.mockResolvedValueOnce([{ address: '::1', family: 6 }] as never);
|
||||||
|
expect(await resolveHostnameSSRF('ipv6-loopback.example.com')).toBe(true);
|
||||||
|
|
||||||
|
mockedLookup.mockResolvedValueOnce([{ address: 'fc00::1', family: 6 }] as never);
|
||||||
|
expect(await resolveHostnameSSRF('ula.example.com')).toBe(true);
|
||||||
|
|
||||||
|
mockedLookup.mockResolvedValueOnce([{ address: '::ffff:a9fe:a9fe', family: 6 }] as never);
|
||||||
|
expect(await resolveHostnameSSRF('meta.example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fail open on DNS resolution failure', async () => {
|
it('should fail open on DNS resolution failure', async () => {
|
||||||
mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND'));
|
mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND'));
|
||||||
expect(await resolveHostnameSSRF('nonexistent.example.com')).toBe(false);
|
expect(await resolveHostnameSSRF('nonexistent.example.com')).toBe(false);
|
||||||
|
|
@ -915,4 +1116,44 @@ describe('isMCPDomainAllowed', () => {
|
||||||
expect(await isMCPDomainAllowed({ url: 'wss://example.com' }, ['example.com'])).toBe(true);
|
expect(await isMCPDomainAllowed({ url: 'wss://example.com' }, ['example.com'])).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IPv4-mapped IPv6 hex SSRF bypass', () => {
|
||||||
|
it('should block MCP server targeting AWS metadata via IPv6-mapped address', async () => {
|
||||||
|
const config = { url: 'http://[::ffff:169.254.169.254]/mcp' };
|
||||||
|
expect(await isMCPDomainAllowed(config, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block MCP server targeting loopback via IPv6-mapped address', async () => {
|
||||||
|
const config = { url: 'http://[::ffff:127.0.0.1]/mcp' };
|
||||||
|
expect(await isMCPDomainAllowed(config, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block MCP server targeting private range via IPv6-mapped address', async () => {
|
||||||
|
expect(await isMCPDomainAllowed({ url: 'http://[::ffff:10.0.0.1]/mcp' }, null)).toBe(false);
|
||||||
|
expect(await isMCPDomainAllowed({ url: 'http://[::ffff:192.168.1.1]/mcp' }, null)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block WebSocket MCP targeting private range via IPv6-mapped address', async () => {
|
||||||
|
expect(await isMCPDomainAllowed({ url: 'ws://[::ffff:127.0.0.1]/mcp' }, null)).toBe(false);
|
||||||
|
expect(await isMCPDomainAllowed({ url: 'wss://[::ffff:10.0.0.1]/mcp' }, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow MCP server targeting public IP via IPv6-mapped address', async () => {
|
||||||
|
const config = { url: 'http://[::ffff:8.8.8.8]/mcp' };
|
||||||
|
expect(await isMCPDomainAllowed(config, null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block MCP server targeting 6to4 embedded private IPv4', async () => {
|
||||||
|
expect(await isMCPDomainAllowed({ url: 'http://[2002:7f00:1::]/mcp' }, null)).toBe(false);
|
||||||
|
expect(await isMCPDomainAllowed({ url: 'ws://[2002:a9fe:a9fe::]/mcp' }, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block MCP server targeting NAT64 embedded private IPv4', async () => {
|
||||||
|
expect(await isMCPDomainAllowed({ url: 'http://[64:ff9b::127.0.0.1]/mcp' }, null)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,26 +24,79 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
|
||||||
return allowedDomains.some((allowedDomain) => allowedDomain?.toLowerCase() === domain);
|
return allowedDomains.some((allowedDomain) => allowedDomain?.toLowerCase() === domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checks if IPv4 octets fall within private, reserved, or link-local ranges */
|
/** Checks if IPv4 octets fall within private, reserved, or non-routable ranges */
|
||||||
function isPrivateIPv4(a: number, b: number, c: number): boolean {
|
function isPrivateIPv4(a: number, b: number, c: number): boolean {
|
||||||
if (a === 127) {
|
if (a === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (a === 10) {
|
if (a === 10) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (a === 127) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a === 169 && b === 254) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (a === 172 && b >= 16 && b <= 31) {
|
if (a === 172 && b >= 16 && b <= 31) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (a === 192 && b === 168) {
|
if (a === 192 && b === 168) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (a === 169 && b === 254) {
|
if (a === 192 && b === 0 && c === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (a === 0 && b === 0 && c === 0) {
|
if (a === 198 && (b === 18 || b === 19)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (a >= 224) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks if an IPv6 address embeds a private IPv4 via 6to4, NAT64, or Teredo */
|
||||||
|
function hasPrivateEmbeddedIPv4(ipv6: string): boolean {
|
||||||
|
if (!ipv6.startsWith('2002:') && !ipv6.startsWith('64:ff9b::') && !ipv6.startsWith('2001::')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const segments = ipv6.split(':').filter((s) => s !== '');
|
||||||
|
|
||||||
|
if (ipv6.startsWith('2002:') && segments.length >= 3) {
|
||||||
|
const hi = parseInt(segments[1], 16);
|
||||||
|
const lo = parseInt(segments[2], 16);
|
||||||
|
if (!isNaN(hi) && !isNaN(lo)) {
|
||||||
|
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipv6.startsWith('64:ff9b::')) {
|
||||||
|
const lastTwo = segments.slice(-2);
|
||||||
|
if (lastTwo.length === 2) {
|
||||||
|
const hi = parseInt(lastTwo[0], 16);
|
||||||
|
const lo = parseInt(lastTwo[1], 16);
|
||||||
|
if (!isNaN(hi) && !isNaN(lo)) {
|
||||||
|
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 4380: Teredo stores external IPv4 as bitwise complement in last 32 bits
|
||||||
|
if (ipv6.startsWith('2001::')) {
|
||||||
|
const lastTwo = segments.slice(-2);
|
||||||
|
if (lastTwo.length === 2) {
|
||||||
|
const hi = parseInt(lastTwo[0], 16);
|
||||||
|
const lo = parseInt(lastTwo[1], 16);
|
||||||
|
if (!isNaN(hi) && !isNaN(lo)) {
|
||||||
|
return isPrivateIPv4((~hi >> 8) & 0xff, ~hi & 0xff, (~lo >> 8) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +105,10 @@ function isPrivateIPv4(a: number, b: number, c: number): boolean {
|
||||||
* Handles IPv4, IPv6, and IPv4-mapped IPv6 addresses (::ffff:A.B.C.D).
|
* Handles IPv4, IPv6, and IPv4-mapped IPv6 addresses (::ffff:A.B.C.D).
|
||||||
*/
|
*/
|
||||||
export function isPrivateIP(ip: string): boolean {
|
export function isPrivateIP(ip: string): boolean {
|
||||||
const normalized = ip.toLowerCase().trim();
|
const normalized = ip
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/^\[|\]$/g, '');
|
||||||
|
|
||||||
const mappedMatch = normalized.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
const mappedMatch = normalized.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
if (mappedMatch) {
|
if (mappedMatch) {
|
||||||
|
|
@ -60,42 +116,52 @@ export function isPrivateIP(ip: string): boolean {
|
||||||
return isPrivateIPv4(a, b, c);
|
return isPrivateIPv4(a, b, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hexMappedMatch = normalized.match(/^(?:::ffff:|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||||
|
if (hexMappedMatch) {
|
||||||
|
const hi = parseInt(hexMappedMatch[1], 16);
|
||||||
|
const lo = parseInt(hexMappedMatch[2], 16);
|
||||||
|
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
if (ipv4Match) {
|
if (ipv4Match) {
|
||||||
const [, a, b, c] = ipv4Match.map(Number);
|
const [, a, b, c] = ipv4Match.map(Number);
|
||||||
return isPrivateIPv4(a, b, c);
|
return isPrivateIPv4(a, b, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ipv6 = normalized.replace(/^\[|\]$/g, '');
|
|
||||||
if (
|
if (
|
||||||
ipv6 === '::1' ||
|
normalized === '::1' ||
|
||||||
ipv6 === '::' ||
|
normalized === '::' ||
|
||||||
ipv6.startsWith('fc') ||
|
normalized.startsWith('fc') ||
|
||||||
ipv6.startsWith('fd') ||
|
normalized.startsWith('fd') ||
|
||||||
ipv6.startsWith('fe80')
|
normalized.startsWith('fe80')
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasPrivateEmbeddedIPv4(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a hostname via DNS and checks if any resolved address is a private/reserved IP.
|
* Checks if a hostname resolves to a private/reserved IP address.
|
||||||
* Detects DNS-based SSRF bypasses (e.g., nip.io wildcard DNS, attacker-controlled nameservers).
|
* Directly validates literal IPv4 and IPv6 addresses without DNS lookup.
|
||||||
* Fails open: returns false if DNS resolution fails, since hostname-only checks still apply
|
* For hostnames, resolves via DNS and checks all returned addresses.
|
||||||
* and the actual HTTP request would also fail.
|
* Fails open on DNS errors (returns false), since the HTTP request would also fail.
|
||||||
*/
|
*/
|
||||||
export async function resolveHostnameSSRF(hostname: string): Promise<boolean> {
|
export async function resolveHostnameSSRF(hostname: string): Promise<boolean> {
|
||||||
const normalizedHost = hostname.toLowerCase().trim();
|
const normalizedHost = hostname.toLowerCase().trim();
|
||||||
|
|
||||||
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(normalizedHost)) {
|
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(normalizedHost)) {
|
||||||
return false;
|
return isPrivateIP(normalizedHost);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ipv6Check = normalizedHost.replace(/^\[|\]$/g, '');
|
const ipv6Check = normalizedHost.replace(/^\[|\]$/g, '');
|
||||||
if (ipv6Check.includes(':')) {
|
if (ipv6Check.includes(':')) {
|
||||||
return false;
|
return isPrivateIP(ipv6Check);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
277
packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts
Normal file
277
packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for MCP SSRF protections.
|
||||||
|
*
|
||||||
|
* These tests spin up real in-process HTTP servers and verify that MCPConnection:
|
||||||
|
*
|
||||||
|
* 1. Does NOT follow HTTP redirects from SSE/StreamableHTTP transports
|
||||||
|
* (redirect: 'manual' prevents SSRF via server-controlled 301/302)
|
||||||
|
* 2. Blocks WebSocket connections to hosts that DNS-resolve to private IPs,
|
||||||
|
* regardless of whether useSSRFProtection is enabled (allowlist scenario)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as net from 'net';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import type { Socket } from 'net';
|
||||||
|
import { MCPConnection } from '~/mcp/connection';
|
||||||
|
import { resolveHostnameSSRF } from '~/auth';
|
||||||
|
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: {
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/auth', () => ({
|
||||||
|
createSSRFSafeUndiciConnect: jest.fn(() => undefined),
|
||||||
|
resolveHostnameSSRF: jest.fn(async () => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/mcp/mcpConfig', () => ({
|
||||||
|
mcpConfig: { CONNECTION_CHECK_TTL: 0 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedResolveHostnameSSRF = resolveHostnameSSRF as jest.MockedFunction<
|
||||||
|
typeof resolveHostnameSSRF
|
||||||
|
>;
|
||||||
|
|
||||||
|
async function safeDisconnect(conn: MCPConnection | null): Promise<void> {
|
||||||
|
if (!conn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(conn as unknown as { shouldStopReconnecting: boolean }).shouldStopReconnecting = true;
|
||||||
|
conn.removeAllListeners();
|
||||||
|
await conn.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestServer {
|
||||||
|
url: string;
|
||||||
|
redirectHit: boolean;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = net.createServer();
|
||||||
|
srv.listen(0, '127.0.0.1', () => {
|
||||||
|
const addr = srv.address() as net.AddressInfo;
|
||||||
|
srv.close((err) => (err ? reject(err) : resolve(addr.port)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackSockets(httpServer: http.Server): () => Promise<void> {
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
httpServer.on('connection', (socket: Socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.once('close', () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
return () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
sockets.clear();
|
||||||
|
httpServer.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an HTTP server that responds with a 301 redirect to a target URL.
|
||||||
|
* A second server is spun up at the redirect target to detect whether the
|
||||||
|
* redirect was actually followed.
|
||||||
|
*/
|
||||||
|
async function createRedirectingServer(redirectTarget: string): Promise<TestServer> {
|
||||||
|
const state = { redirectHit: false };
|
||||||
|
|
||||||
|
const targetPort = new URL(redirectTarget).port || '80';
|
||||||
|
const targetServer = http.createServer((_req, res) => {
|
||||||
|
state.redirectHit = true;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('You should not be here');
|
||||||
|
});
|
||||||
|
const destroyTargetSockets = trackSockets(targetServer);
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
targetServer.listen(parseInt(targetPort), '127.0.0.1', resolve),
|
||||||
|
);
|
||||||
|
|
||||||
|
const httpServer = http.createServer((_req, res) => {
|
||||||
|
res.writeHead(301, { Location: redirectTarget });
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const destroySockets = trackSockets(httpServer);
|
||||||
|
const port = await getFreePort();
|
||||||
|
await new Promise<void>((resolve) => httpServer.listen(port, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}/`,
|
||||||
|
get redirectHit() {
|
||||||
|
return state.redirectHit;
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
await destroySockets();
|
||||||
|
await destroyTargetSockets();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a real StreamableHTTP MCP server for baseline connectivity tests.
|
||||||
|
*/
|
||||||
|
async function createStreamableServer(): Promise<Omit<TestServer, 'redirectHit'>> {
|
||||||
|
const sessions = new Map<string, StreamableHTTPServerTransport>();
|
||||||
|
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
const sid = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
let transport = sid ? sessions.get(sid) : undefined;
|
||||||
|
|
||||||
|
if (!transport) {
|
||||||
|
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
|
||||||
|
const mcp = new McpServer({ name: 'test-ssrf', version: '0.0.1' });
|
||||||
|
await mcp.connect(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
|
||||||
|
if (transport.sessionId && !sessions.has(transport.sessionId)) {
|
||||||
|
sessions.set(transport.sessionId, transport);
|
||||||
|
transport.onclose = () => sessions.delete(transport!.sessionId!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const destroySockets = trackSockets(httpServer);
|
||||||
|
const port = await getFreePort();
|
||||||
|
await new Promise<void>((resolve) => httpServer.listen(port, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}/`,
|
||||||
|
close: async () => {
|
||||||
|
const closing = [...sessions.values()].map((t) => t.close().catch(() => undefined));
|
||||||
|
sessions.clear();
|
||||||
|
await Promise.all(closing);
|
||||||
|
await destroySockets();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MCP SSRF protection – redirect blocking', () => {
|
||||||
|
let redirectServer: TestServer;
|
||||||
|
let conn: MCPConnection | null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await safeDisconnect(conn);
|
||||||
|
conn = null;
|
||||||
|
if (redirectServer) {
|
||||||
|
await redirectServer.close();
|
||||||
|
}
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not follow redirects from streamable-http to a private IP', async () => {
|
||||||
|
const targetPort = await getFreePort();
|
||||||
|
redirectServer = await createRedirectingServer(
|
||||||
|
`http://127.0.0.1:${targetPort}/latest/meta-data/`,
|
||||||
|
);
|
||||||
|
|
||||||
|
conn = new MCPConnection({
|
||||||
|
serverName: 'redirect-test',
|
||||||
|
serverConfig: { type: 'streamable-http', url: redirectServer.url },
|
||||||
|
useSSRFProtection: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(conn.connect()).rejects.toThrow();
|
||||||
|
expect(redirectServer.redirectHit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not follow redirects even with SSRF protection off (allowlist scenario)', async () => {
|
||||||
|
const targetPort = await getFreePort();
|
||||||
|
redirectServer = await createRedirectingServer(`http://127.0.0.1:${targetPort}/admin`);
|
||||||
|
|
||||||
|
conn = new MCPConnection({
|
||||||
|
serverName: 'redirect-test-2',
|
||||||
|
serverConfig: { type: 'streamable-http', url: redirectServer.url },
|
||||||
|
useSSRFProtection: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(conn.connect()).rejects.toThrow();
|
||||||
|
expect(redirectServer.redirectHit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should connect normally to a non-redirecting streamable-http server', async () => {
|
||||||
|
const realServer = await createStreamableServer();
|
||||||
|
try {
|
||||||
|
conn = new MCPConnection({
|
||||||
|
serverName: 'legit-server',
|
||||||
|
serverConfig: { type: 'streamable-http', url: realServer.url },
|
||||||
|
useSSRFProtection: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await conn.connect();
|
||||||
|
const tools = await conn.fetchTools();
|
||||||
|
expect(tools).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
await safeDisconnect(conn);
|
||||||
|
conn = null;
|
||||||
|
await realServer.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCP SSRF protection – WebSocket DNS resolution', () => {
|
||||||
|
let conn: MCPConnection | null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await safeDisconnect(conn);
|
||||||
|
conn = null;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block WebSocket to host resolving to private IP when SSRF protection is on', async () => {
|
||||||
|
mockedResolveHostnameSSRF.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
conn = new MCPConnection({
|
||||||
|
serverName: 'ws-ssrf-test',
|
||||||
|
serverConfig: { type: 'websocket', url: 'ws://evil.example.com:8080/mcp' },
|
||||||
|
useSSRFProtection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(conn.connect()).rejects.toThrow(/SSRF protection/);
|
||||||
|
expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('evil.example.com'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block WebSocket to host resolving to private IP even with SSRF protection off', async () => {
|
||||||
|
mockedResolveHostnameSSRF.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
conn = new MCPConnection({
|
||||||
|
serverName: 'ws-ssrf-allowlist',
|
||||||
|
serverConfig: { type: 'websocket', url: 'ws://allowlisted.example.com:8080/mcp' },
|
||||||
|
useSSRFProtection: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(conn.connect()).rejects.toThrow(/SSRF protection/);
|
||||||
|
expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('allowlisted.example.com'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow WebSocket to host resolving to public IP', async () => {
|
||||||
|
mockedResolveHostnameSSRF.mockResolvedValueOnce(false);
|
||||||
|
|
||||||
|
conn = new MCPConnection({
|
||||||
|
serverName: 'ws-public-test',
|
||||||
|
serverConfig: { type: 'websocket', url: 'ws://public.example.com:8080/mcp' },
|
||||||
|
useSSRFProtection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Fails on connect (no real server), but the error must not be an SSRF rejection. */
|
||||||
|
await expect(conn.connect()).rejects.not.toThrow(/SSRF protection/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -364,7 +364,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
|
|
||||||
const requestHeaders = getHeaders();
|
const requestHeaders = getHeaders();
|
||||||
if (!requestHeaders) {
|
if (!requestHeaders) {
|
||||||
return undiciFetch(input, { ...init, dispatcher });
|
return undiciFetch(input, { ...init, redirect: 'manual', dispatcher });
|
||||||
}
|
}
|
||||||
|
|
||||||
let initHeaders: Record<string, string> = {};
|
let initHeaders: Record<string, string> = {};
|
||||||
|
|
@ -380,6 +380,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
|
|
||||||
return undiciFetch(input, {
|
return undiciFetch(input, {
|
||||||
...init,
|
...init,
|
||||||
|
redirect: 'manual',
|
||||||
headers: {
|
headers: {
|
||||||
...initHeaders,
|
...initHeaders,
|
||||||
...requestHeaders,
|
...requestHeaders,
|
||||||
|
|
@ -425,21 +426,29 @@ export class MCPConnection extends EventEmitter {
|
||||||
env: { ...getDefaultEnvironment(), ...(options.env ?? {}) },
|
env: { ...getDefaultEnvironment(), ...(options.env ?? {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
case 'websocket':
|
case 'websocket': {
|
||||||
if (!isWebSocketOptions(options)) {
|
if (!isWebSocketOptions(options)) {
|
||||||
throw new Error('Invalid options for websocket transport.');
|
throw new Error('Invalid options for websocket transport.');
|
||||||
}
|
}
|
||||||
this.url = options.url;
|
this.url = options.url;
|
||||||
if (this.useSSRFProtection) {
|
/**
|
||||||
const wsHostname = new URL(options.url).hostname;
|
* SSRF pre-check: always validate resolved IPs for WebSocket, regardless
|
||||||
const isSSRF = await resolveHostnameSSRF(wsHostname);
|
* of allowlist configuration. Allowlisting a domain grants trust to that
|
||||||
if (isSSRF) {
|
* name, not to whatever IP it resolves to at runtime (DNS rebinding).
|
||||||
throw new Error(
|
*
|
||||||
`SSRF protection: WebSocket host "${wsHostname}" resolved to a private/reserved IP address`,
|
* Note: WebSocketClientTransport does its own DNS resolution, creating a
|
||||||
);
|
* small TOCTOU window. This is an SDK limitation — the transport accepts
|
||||||
}
|
* only a URL with no custom DNS lookup hook.
|
||||||
|
*/
|
||||||
|
const wsHostname = new URL(options.url).hostname;
|
||||||
|
const isSSRF = await resolveHostnameSSRF(wsHostname);
|
||||||
|
if (isSSRF) {
|
||||||
|
throw new Error(
|
||||||
|
`SSRF protection: WebSocket host "${wsHostname}" resolved to a private/reserved IP address`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new WebSocketClientTransport(new URL(options.url));
|
return new WebSocketClientTransport(new URL(options.url));
|
||||||
|
}
|
||||||
|
|
||||||
case 'sse': {
|
case 'sse': {
|
||||||
if (!isSSEOptions(options)) {
|
if (!isSSEOptions(options)) {
|
||||||
|
|
@ -486,6 +495,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
);
|
);
|
||||||
return undiciFetch(url, {
|
return undiciFetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
redirect: 'manual',
|
||||||
dispatcher: sseAgent,
|
dispatcher: sseAgent,
|
||||||
headers: fetchHeaders,
|
headers: fetchHeaders,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue