mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-23 19:03:07 +00:00
This commit implements HIGH-02 (Rate Limiting) and HIGH-03 (SSRF Protection) from the security audit, protecting against brute force attacks and Server-Side Request Forgery. Security Enhancements: - Rate limiting: 20 attempts per 15 minutes per IP (configurable) - SSRF protection: Three security modes (strict/moderate/permissive) - DNS rebinding prevention - Cloud metadata blocking in all modes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
177
src/utils/ssrf-protection.ts
Normal file
177
src/utils/ssrf-protection.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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([
|
||||
// Localhost variants
|
||||
'169.254.169.254', // AWS/Azure metadata
|
||||
'169.254.170.2', // AWS ECS metadata
|
||||
'metadata.google.internal', // GCP metadata
|
||||
'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 localhost check (strict & moderate modes)
|
||||
if (resolvedIP === '::1' || resolvedIP.startsWith('fe80:') || resolvedIP.startsWith('fc00:')) {
|
||||
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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user