mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-09 06:43:08 +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:
@@ -5,6 +5,7 @@
|
||||
* while maintaining simplicity for single-player use case
|
||||
*/
|
||||
import express from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { N8NDocumentationMCPServer } from './mcp/server';
|
||||
@@ -989,8 +990,41 @@ export class SingleSessionHTTPServer {
|
||||
});
|
||||
|
||||
|
||||
// Main MCP endpoint with authentication
|
||||
app.post('/mcp', jsonParser, async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
// SECURITY: Rate limiting for authentication endpoint
|
||||
// Prevents brute force attacks and DoS
|
||||
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02)
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW || '900000'), // 15 minutes
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX || '20'), // 20 authentication attempts per IP
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Too many authentication attempts. Please try again later.'
|
||||
},
|
||||
id: null
|
||||
},
|
||||
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable `X-RateLimit-*` headers
|
||||
handler: (req, res) => {
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
event: 'rate_limit'
|
||||
});
|
||||
res.status(429).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Too many authentication attempts'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Main MCP endpoint with authentication and rate limiting
|
||||
app.post('/mcp', authLimiter, jsonParser, async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
// Log comprehensive debug info about the request
|
||||
logger.info('POST /mcp request received - DETAILED DEBUG', {
|
||||
headers: req.headers,
|
||||
|
||||
@@ -212,7 +212,16 @@ export class N8nApiClient {
|
||||
async triggerWebhook(request: WebhookRequest): Promise<any> {
|
||||
try {
|
||||
const { webhookUrl, httpMethod, data, headers, waitForResponse = true } = request;
|
||||
|
||||
|
||||
// SECURITY: Validate URL for SSRF protection (includes DNS resolution)
|
||||
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-03)
|
||||
const { SSRFProtection } = await import('../utils/ssrf-protection');
|
||||
const validation = await SSRFProtection.validateWebhookUrl(webhookUrl);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(`SSRF protection: ${validation.reason}`);
|
||||
}
|
||||
|
||||
// Extract path from webhook URL
|
||||
const url = new URL(webhookUrl);
|
||||
const webhookPath = url.pathname;
|
||||
|
||||
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