Skip to main content
Glama

n8n-MCP

by 88-888
ssrf-protection.tsβ€’6.71 kB
import { URL } from 'url'; import { lookup } from 'dns/promises'; import { logger } from './logger'; /** * SSRF Protection Utility with Configurable Security Modes * * Validates URLs to prevent Server-Side Request Forgery attacks including DNS rebinding * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-03) * * Security Modes: * - strict (default): Block localhost + private IPs + cloud metadata (production) * - moderate: Allow localhost, block private IPs + cloud metadata (local dev) * - permissive: Allow localhost + private IPs, block cloud metadata (testing only) */ // Security mode type type SecurityMode = 'strict' | 'moderate' | 'permissive'; // Cloud metadata endpoints (ALWAYS blocked in all modes) const CLOUD_METADATA = new Set([ // AWS/Azure '169.254.169.254', // AWS/Azure metadata '169.254.170.2', // AWS ECS metadata // Google Cloud 'metadata.google.internal', // GCP metadata 'metadata', // Alibaba Cloud '100.100.100.200', // Alibaba Cloud metadata // Oracle Cloud '192.0.0.192', // Oracle Cloud metadata ]); // Localhost patterns const LOCALHOST_PATTERNS = new Set([ 'localhost', '127.0.0.1', '::1', '0.0.0.0', 'localhost.localdomain', ]); // Private IP ranges (regex for IPv4) const PRIVATE_IP_RANGES = [ /^10\./, // 10.0.0.0/8 /^192\.168\./, // 192.168.0.0/16 /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 /^169\.254\./, // 169.254.0.0/16 (Link-local) /^127\./, // 127.0.0.0/8 (Loopback) /^0\./, // 0.0.0.0/8 (Invalid) ]; export class SSRFProtection { /** * Validate webhook URL for SSRF protection with configurable security modes * * @param urlString - URL to validate * @returns Promise with validation result * * @security Uses DNS resolution to prevent DNS rebinding attacks * * @example * // Production (default strict mode) * const result = await SSRFProtection.validateWebhookUrl('http://localhost:5678'); * // { valid: false, reason: 'Localhost not allowed' } * * @example * // Local development (moderate mode) * process.env.WEBHOOK_SECURITY_MODE = 'moderate'; * const result = await SSRFProtection.validateWebhookUrl('http://localhost:5678'); * // { valid: true } */ static async validateWebhookUrl(urlString: string): Promise<{ valid: boolean; reason?: string }> { try { const url = new URL(urlString); const mode: SecurityMode = (process.env.WEBHOOK_SECURITY_MODE || 'strict') as SecurityMode; // Step 1: Must be HTTP/HTTPS (all modes) if (!['http:', 'https:'].includes(url.protocol)) { return { valid: false, reason: 'Invalid protocol. Only HTTP/HTTPS allowed.' }; } // Get hostname and strip IPv6 brackets if present let hostname = url.hostname.toLowerCase(); // Remove IPv6 brackets for consistent comparison if (hostname.startsWith('[') && hostname.endsWith(']')) { hostname = hostname.slice(1, -1); } // Step 2: ALWAYS block cloud metadata endpoints (all modes) if (CLOUD_METADATA.has(hostname)) { logger.warn('SSRF blocked: Cloud metadata endpoint', { hostname, mode }); return { valid: false, reason: 'Cloud metadata endpoint blocked' }; } // Step 3: Resolve DNS to get actual IP address // This prevents DNS rebinding attacks where hostname resolves to different IPs let resolvedIP: string; try { const { address } = await lookup(hostname); resolvedIP = address; logger.debug('DNS resolved for SSRF check', { hostname, resolvedIP, mode }); } catch (error) { logger.warn('DNS resolution failed for webhook URL', { hostname, error: error instanceof Error ? error.message : String(error) }); return { valid: false, reason: 'DNS resolution failed' }; } // Step 4: ALWAYS block cloud metadata IPs (all modes) if (CLOUD_METADATA.has(resolvedIP)) { logger.warn('SSRF blocked: Hostname resolves to cloud metadata IP', { hostname, resolvedIP, mode }); return { valid: false, reason: 'Hostname resolves to cloud metadata endpoint' }; } // Step 5: Mode-specific validation // MODE: permissive - Allow everything except cloud metadata if (mode === 'permissive') { logger.warn('SSRF protection in permissive mode (localhost and private IPs allowed)', { hostname, resolvedIP }); return { valid: true }; } // Check if target is localhost const isLocalhost = LOCALHOST_PATTERNS.has(hostname) || resolvedIP === '::1' || resolvedIP.startsWith('127.'); // MODE: strict - Block localhost and private IPs if (mode === 'strict' && isLocalhost) { logger.warn('SSRF blocked: Localhost not allowed in strict mode', { hostname, resolvedIP }); return { valid: false, reason: 'Localhost access is blocked in strict mode' }; } // MODE: moderate - Allow localhost, block private IPs if (mode === 'moderate' && isLocalhost) { logger.info('Localhost webhook allowed (moderate mode)', { hostname, resolvedIP }); return { valid: true }; } // Step 6: Check private IPv4 ranges (strict & moderate modes) if (PRIVATE_IP_RANGES.some(regex => regex.test(resolvedIP))) { logger.warn('SSRF blocked: Private IP address', { hostname, resolvedIP, mode }); return { valid: false, reason: mode === 'strict' ? 'Private IP addresses not allowed' : 'Private IP addresses not allowed (use WEBHOOK_SECURITY_MODE=permissive if needed)' }; } // Step 7: IPv6 private address check (strict & moderate modes) if (resolvedIP === '::1' || // Loopback resolvedIP === '::' || // Unspecified address resolvedIP.startsWith('fe80:') || // Link-local resolvedIP.startsWith('fc00:') || // Unique local (fc00::/7) resolvedIP.startsWith('fd00:') || // Unique local (fd00::/8) resolvedIP.startsWith('::ffff:')) { // IPv4-mapped IPv6 logger.warn('SSRF blocked: IPv6 private address', { hostname, resolvedIP, mode }); return { valid: false, reason: 'IPv6 private address not allowed' }; } return { valid: true }; } catch (error) { return { valid: false, reason: 'Invalid URL format' }; } } }

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