Skip to main content
Glama

n8n-MCP

by 88-888
ssrf-protection.test.ts14.4 kB
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Mock dns module before importing SSRFProtection vi.mock('dns/promises', () => ({ lookup: vi.fn(), })); import { SSRFProtection } from '../../../src/utils/ssrf-protection'; import * as dns from 'dns/promises'; /** * Unit tests for SSRFProtection with configurable security modes * * SECURITY: These tests verify SSRF protection blocks malicious URLs in all modes * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-03) */ describe('SSRFProtection', () => { const originalEnv = process.env.WEBHOOK_SECURITY_MODE; beforeEach(() => { // Clear all mocks before each test vi.clearAllMocks(); // Default mock: simulate real DNS behavior - return the hostname as IP if it looks like an IP vi.mocked(dns.lookup).mockImplementation(async (hostname: any) => { // Handle special hostname "localhost" if (hostname === 'localhost') { return { address: '127.0.0.1', family: 4 } as any; } // If hostname is an IP address, return it as-is (simulating real DNS behavior) const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; const ipv6Regex = /^([0-9a-fA-F]{0,4}:)+[0-9a-fA-F]{0,4}$/; if (ipv4Regex.test(hostname)) { return { address: hostname, family: 4 } as any; } if (ipv6Regex.test(hostname) || hostname === '::1') { return { address: hostname, family: 6 } as any; } // For actual hostnames, return a public IP by default return { address: '8.8.8.8', family: 4 } as any; }); }); afterEach(() => { // Restore original environment if (originalEnv) { process.env.WEBHOOK_SECURITY_MODE = originalEnv; } else { delete process.env.WEBHOOK_SECURITY_MODE; } vi.restoreAllMocks(); }); describe('Strict Mode (default)', () => { beforeEach(() => { delete process.env.WEBHOOK_SECURITY_MODE; // Use default strict }); it('should block localhost', async () => { const localhostURLs = [ 'http://localhost:3000/webhook', 'http://127.0.0.1/webhook', 'http://[::1]/webhook', ]; for (const url of localhostURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid, `URL ${url} should be blocked but was valid`).toBe(false); expect(result.reason, `URL ${url} should have a reason`).toBeDefined(); } }); it('should block AWS metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://169.254.169.254/latest/meta-data'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block GCP metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://metadata.google.internal/computeMetadata/v1/'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block Alibaba Cloud metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://100.100.100.200/latest/meta-data'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block Oracle Cloud metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://192.0.0.192/opc/v2/instance/'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block private IP ranges', async () => { const privateIPs = [ 'http://10.0.0.1/webhook', 'http://192.168.1.1/webhook', 'http://172.16.0.1/webhook', 'http://172.31.255.255/webhook', ]; for (const url of privateIPs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); } }); it('should allow public URLs', async () => { const publicURLs = [ 'https://hooks.example.com/webhook', 'https://api.external.com/callback', 'http://public-service.com:8080/hook', ]; for (const url of publicURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(true); expect(result.reason).toBeUndefined(); } }); it('should block non-HTTP protocols', async () => { const invalidProtocols = [ 'file:///etc/passwd', 'ftp://internal-server/file', 'gopher://old-service', ]; for (const url of invalidProtocols) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('protocol'); } }); }); describe('Moderate Mode', () => { beforeEach(() => { process.env.WEBHOOK_SECURITY_MODE = 'moderate'; }); it('should allow localhost', async () => { const localhostURLs = [ 'http://localhost:5678/webhook', 'http://127.0.0.1:5678/webhook', 'http://[::1]:5678/webhook', ]; for (const url of localhostURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(true); } }); it('should still block private IPs', async () => { const privateIPs = [ 'http://10.0.0.1/webhook', 'http://192.168.1.1/webhook', 'http://172.16.0.1/webhook', ]; for (const url of privateIPs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); } }); it('should still block cloud metadata', async () => { const metadataURLs = [ 'http://169.254.169.254/latest/meta-data', 'http://metadata.google.internal/computeMetadata/v1/', ]; for (const url of metadataURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('metadata'); } }); it('should allow public URLs', async () => { const result = await SSRFProtection.validateWebhookUrl('https://api.example.com/webhook'); expect(result.valid).toBe(true); }); }); describe('Permissive Mode', () => { beforeEach(() => { process.env.WEBHOOK_SECURITY_MODE = 'permissive'; }); it('should allow localhost', async () => { const result = await SSRFProtection.validateWebhookUrl('http://localhost:5678/webhook'); expect(result.valid).toBe(true); }); it('should allow private IPs', async () => { const privateIPs = [ 'http://10.0.0.1/webhook', 'http://192.168.1.1/webhook', 'http://172.16.0.1/webhook', ]; for (const url of privateIPs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(true); } }); it('should still block cloud metadata', async () => { const metadataURLs = [ 'http://169.254.169.254/latest/meta-data', 'http://metadata.google.internal/computeMetadata/v1/', 'http://169.254.170.2/v2/metadata', ]; for (const url of metadataURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('metadata'); } }); it('should allow public URLs', async () => { const result = await SSRFProtection.validateWebhookUrl('https://api.example.com/webhook'); expect(result.valid).toBe(true); }); }); describe('DNS Rebinding Prevention', () => { it('should block hostname resolving to private IP (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS lookup to return private IP vi.mocked(dns.lookup).mockResolvedValue({ address: '10.0.0.1', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://evil.example.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); }); it('should block hostname resolving to private IP (moderate mode)', async () => { process.env.WEBHOOK_SECURITY_MODE = 'moderate'; // Mock DNS lookup to return private IP vi.mocked(dns.lookup).mockResolvedValue({ address: '192.168.1.100', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://internal.company.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); }); it('should allow hostname resolving to private IP (permissive mode)', async () => { process.env.WEBHOOK_SECURITY_MODE = 'permissive'; // Mock DNS lookup to return private IP vi.mocked(dns.lookup).mockResolvedValue({ address: '192.168.1.100', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://internal.company.com/webhook'); expect(result.valid).toBe(true); }); it('should block hostname resolving to cloud metadata (all modes)', async () => { const modes = ['strict', 'moderate', 'permissive']; for (const mode of modes) { process.env.WEBHOOK_SECURITY_MODE = mode; // Mock DNS lookup to return cloud metadata IP vi.mocked(dns.lookup).mockResolvedValue({ address: '169.254.169.254', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://evil-domain.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('metadata'); } }); it('should block hostname resolving to localhost IP (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS lookup to return localhost IP vi.mocked(dns.lookup).mockResolvedValue({ address: '127.0.0.1', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://suspicious-domain.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toBeDefined(); }); }); describe('IPv6 Protection', () => { it('should block IPv6 localhost (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 localhost vi.mocked(dns.lookup).mockResolvedValue({ address: '::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-test.com/webhook'); expect(result.valid).toBe(false); // Updated: IPv6 localhost is now caught by the localhost check, not IPv6 check expect(result.reason).toContain('Localhost'); }); it('should block IPv6 link-local (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 link-local vi.mocked(dns.lookup).mockResolvedValue({ address: 'fe80::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-local.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv6 unique local (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 unique local vi.mocked(dns.lookup).mockResolvedValue({ address: 'fc00::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-internal.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv6 unique local fd00::/8 (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 unique local fd00::/8 vi.mocked(dns.lookup).mockResolvedValue({ address: 'fd00::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-fd00.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv6 unspecified address (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 unspecified address vi.mocked(dns.lookup).mockResolvedValue({ address: '::', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-unspecified.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv4-mapped IPv6 addresses (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv4-mapped IPv6 address vi.mocked(dns.lookup).mockResolvedValue({ address: '::ffff:127.0.0.1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv4-mapped.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); }); describe('DNS Resolution Failures', () => { it('should handle DNS resolution failure gracefully', async () => { // Mock DNS lookup to fail vi.mocked(dns.lookup).mockRejectedValue(new Error('ENOTFOUND')); const result = await SSRFProtection.validateWebhookUrl('http://non-existent-domain.invalid/webhook'); expect(result.valid).toBe(false); expect(result.reason).toBe('DNS resolution failed'); }); }); describe('Edge Cases', () => { it('should handle malformed URLs', async () => { const malformedURLs = [ 'not-a-url', 'http://', '://missing-protocol.com', ]; for (const url of malformedURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toBe('Invalid URL format'); } }); it('should handle URL with special characters safely', async () => { const result = await SSRFProtection.validateWebhookUrl('https://example.com/webhook?param=value&other=123'); expect(result.valid).toBe(true); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/88-888/n8n-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server