From 4a8a5b5994531ec2e2e4929f35e446a9be8da208 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 7 Mar 2026 20:13:52 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20fix:=20Hex-normalized=20IPv4-map?= =?UTF-8?q?ped=20IPv6=20in=20Domain=20Validation=20(#12130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔒 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. --- packages/api/src/auth/domain.spec.ts | 253 +++++++++++++++- packages/api/src/auth/domain.ts | 100 +++++-- .../mcp/__tests__/MCPConnectionSSRF.test.ts | 277 ++++++++++++++++++ packages/api/src/mcp/connection.ts | 30 +- 4 files changed, 627 insertions(+), 33 deletions(-) create mode 100644 packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts diff --git a/packages/api/src/auth/domain.spec.ts b/packages/api/src/auth/domain.spec.ts index 5f6187c9b4..9812960cd9 100644 --- a/packages/api/src/auth/domain.spec.ts +++ b/packages/api/src/auth/domain.spec.ts @@ -153,8 +153,9 @@ describe('isSSRFTarget', () => { 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.1.2.3')).toBe(true); }); it('should allow public IPs', () => { @@ -230,8 +231,36 @@ describe('isPrivateIP', () => { 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.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', () => { @@ -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', () => { afterEach(() => { jest.clearAllMocks(); @@ -298,16 +465,50 @@ describe('resolveHostnameSSRF', () => { 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); + it('should detect private literal IPv4 addresses without DNS lookup', async () => { + 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(); }); - it('should skip literal IPv6 addresses', async () => { - expect(await resolveHostnameSSRF('::1')).toBe(false); + it('should allow public literal IPv4 addresses without DNS lookup', async () => { + expect(await resolveHostnameSSRF('8.8.8.8')).toBe(false); + expect(await resolveHostnameSSRF('93.184.216.34')).toBe(false); 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 () => { mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND')); 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); }); }); + + 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, + ); + }); + }); }); diff --git a/packages/api/src/auth/domain.ts b/packages/api/src/auth/domain.ts index f2e86875d4..2761a80b55 100644 --- a/packages/api/src/auth/domain.ts +++ b/packages/api/src/auth/domain.ts @@ -24,26 +24,79 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] | 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 { - if (a === 127) { + if (a === 0) { return true; } if (a === 10) { 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) { return true; } if (a === 192 && b === 168) { return true; } - if (a === 169 && b === 254) { + if (a === 192 && b === 0 && c === 0) { return true; } - if (a === 0 && b === 0 && c === 0) { + if (a === 198 && (b === 18 || b === 19)) { 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; } @@ -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). */ 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})$/); if (mappedMatch) { @@ -60,42 +116,52 @@ export function isPrivateIP(ip: string): boolean { 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})$/); if (ipv4Match) { const [, a, b, c] = ipv4Match.map(Number); return isPrivateIPv4(a, b, c); } - const ipv6 = normalized.replace(/^\[|\]$/g, ''); if ( - ipv6 === '::1' || - ipv6 === '::' || - ipv6.startsWith('fc') || - ipv6.startsWith('fd') || - ipv6.startsWith('fe80') + normalized === '::1' || + normalized === '::' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe80') ) { return true; } + if (hasPrivateEmbeddedIPv4(normalized)) { + return true; + } + return false; } /** - * Resolves a hostname via DNS and checks if any resolved address is a private/reserved IP. - * Detects DNS-based SSRF bypasses (e.g., nip.io wildcard DNS, attacker-controlled nameservers). - * Fails open: returns false if DNS resolution fails, since hostname-only checks still apply - * and the actual HTTP request would also fail. + * Checks if a hostname resolves to a private/reserved IP address. + * Directly validates literal IPv4 and IPv6 addresses without DNS lookup. + * For hostnames, resolves via DNS and checks all returned addresses. + * Fails open on DNS errors (returns false), since the HTTP request would also fail. */ export async function resolveHostnameSSRF(hostname: string): Promise { const normalizedHost = hostname.toLowerCase().trim(); 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, ''); if (ipv6Check.includes(':')) { - return false; + return isPrivateIP(ipv6Check); } try { diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts new file mode 100644 index 0000000000..b4eb58dbef --- /dev/null +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -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 { + if (!conn) { + return; + } + (conn as unknown as { shouldStopReconnecting: boolean }).shouldStopReconnecting = true; + conn.removeAllListeners(); + await conn.disconnect(); +} + +interface TestServer { + url: string; + redirectHit: boolean; + close: () => Promise; +} + +function getFreePort(): Promise { + 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 { + const sockets = new Set(); + httpServer.on('connection', (socket: Socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + return () => + new Promise((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 { + 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((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((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> { + const sessions = new Map(); + + 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((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/); + }); +}); diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 6e2633b758..83f1af1824 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -364,7 +364,7 @@ export class MCPConnection extends EventEmitter { const requestHeaders = getHeaders(); if (!requestHeaders) { - return undiciFetch(input, { ...init, dispatcher }); + return undiciFetch(input, { ...init, redirect: 'manual', dispatcher }); } let initHeaders: Record = {}; @@ -380,6 +380,7 @@ export class MCPConnection extends EventEmitter { return undiciFetch(input, { ...init, + redirect: 'manual', headers: { ...initHeaders, ...requestHeaders, @@ -425,21 +426,29 @@ export class MCPConnection extends EventEmitter { env: { ...getDefaultEnvironment(), ...(options.env ?? {}) }, }); - case 'websocket': + case 'websocket': { if (!isWebSocketOptions(options)) { throw new Error('Invalid options for websocket transport.'); } this.url = options.url; - if (this.useSSRFProtection) { - 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`, - ); - } + /** + * SSRF pre-check: always validate resolved IPs for WebSocket, regardless + * of allowlist configuration. Allowlisting a domain grants trust to that + * name, not to whatever IP it resolves to at runtime (DNS rebinding). + * + * 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)); + } case 'sse': { if (!isSSEOptions(options)) { @@ -486,6 +495,7 @@ export class MCPConnection extends EventEmitter { ); return undiciFetch(url, { ...init, + redirect: 'manual', dispatcher: sseAgent, headers: fetchHeaders, });