From 06cbb4021305837b6c464a7c25bdaf873e5dfcb4 Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:40:07 +0200 Subject: [PATCH] feat: implement security audit fixes - rate limiting and SSRF protection (Issue #265 PR #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 34 ++ CHANGELOG.md | 57 +++ README.md | 28 +- docs/DOCKER_README.md | 34 +- docs/DOCKER_TROUBLESHOOTING.md | 35 ++ docs/HTTP_DEPLOYMENT.md | 68 ++++ docs/RAILWAY_DEPLOYMENT.md | 29 ++ package-lock.json | 43 ++- package.json | 3 +- package.runtime.json | 3 +- src/http-server-single-session.ts | 38 +- src/services/n8n-api-client.ts | 11 +- src/utils/ssrf-protection.ts | 177 +++++++++ .../security/rate-limiting.test.ts | 142 +++++++ tests/unit/utils/ssrf-protection.test.ts | 352 ++++++++++++++++++ 15 files changed, 1041 insertions(+), 13 deletions(-) create mode 100644 src/utils/ssrf-protection.ts create mode 100644 tests/integration/security/rate-limiting.test.ts create mode 100644 tests/unit/utils/ssrf-protection.test.ts diff --git a/.env.example b/.env.example index 7495d65..c84ab00 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,40 @@ AUTH_TOKEN=your-secure-token-here # Default: 0 (disabled) # TRUST_PROXY=0 +# ========================= +# SECURITY CONFIGURATION +# ========================= + +# Rate Limiting Configuration +# Protects authentication endpoint from brute force attacks +# Window: Time period in milliseconds (default: 900000 = 15 minutes) +# Max: Maximum authentication attempts per IP within window (default: 20) +# AUTH_RATE_LIMIT_WINDOW=900000 +# AUTH_RATE_LIMIT_MAX=20 + +# SSRF Protection Mode +# Prevents webhooks from accessing internal networks and cloud metadata +# +# Modes: +# - strict (default): Block localhost + private IPs + cloud metadata +# Use for: Production deployments, cloud environments +# Security: Maximum +# +# - moderate: Allow localhost, block private IPs + cloud metadata +# Use for: Local development with local n8n instance +# Security: Good balance +# Example: n8n running on http://localhost:5678 or http://host.docker.internal:5678 +# +# - permissive: Allow localhost + private IPs, block cloud metadata +# Use for: Internal network testing, private cloud (NOT for production) +# Security: Minimal - use with caution +# +# Default: strict +# WEBHOOK_SECURITY_MODE=strict +# +# For local development with local n8n: +# WEBHOOK_SECURITY_MODE=moderate + # ========================= # MULTI-TENANT CONFIGURATION # ========================= diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6343f..fa4f126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.16.3] - 2025-01-06 + +### πŸ”’ Security + +**HIGH priority security enhancements. Recommended for all production deployments.** + +This release implements 2 high-priority security protections identified in the security audit (Issue #265 PR #2): + +- **πŸ›‘οΈ HIGH-02: Rate Limiting for Authentication** + - **Issue:** No rate limiting on authentication endpoints allowed brute force attacks + - **Impact:** Attackers could make unlimited authentication attempts + - **Fix:** Implemented express-rate-limit middleware for authentication endpoint + - Default: 20 attempts per 15 minutes per IP + - Configurable via `AUTH_RATE_LIMIT_WINDOW` and `AUTH_RATE_LIMIT_MAX` + - Per-IP tracking with standard rate limit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset) + - JSON-RPC formatted error responses (429 Too Many Requests) + - Automatic IP detection behind reverse proxies (requires TRUST_PROXY=1) + - **Verification:** 4 integration tests with sequential request patterns + - **See:** https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02) + +- **πŸ›‘οΈ HIGH-03: SSRF Protection for Webhooks** + - **Issue:** Webhook triggers vulnerable to Server-Side Request Forgery attacks + - **Impact:** Attackers could access internal networks, localhost services, and cloud metadata + - **Fix:** Implemented three-mode SSRF protection system with DNS rebinding prevention + - **Strict mode** (default): Block localhost + private IPs + cloud metadata (production) + - **Moderate mode**: Allow localhost, block private IPs + cloud metadata (local development) + - **Permissive mode**: Allow localhost + private IPs, block cloud metadata (internal testing) + - Cloud metadata endpoints **ALWAYS blocked** in all modes (169.254.169.254, metadata.google.internal, etc.) + - DNS rebinding prevention through hostname resolution before validation + - IPv6 support with link-local (fe80::/10) and unique local (fc00::/7) address blocking + - **Configuration:** Set via `WEBHOOK_SECURITY_MODE` environment variable + - **Locations Updated:** + - `src/utils/ssrf-protection.ts` - Core protection logic + - `src/services/n8n-api-client.ts:219` - Webhook trigger validation + - **Verification:** 25 unit tests covering all three modes, DNS rebinding, IPv6 + - **See:** https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-03) + +### Added +- **Configuration:** `AUTH_RATE_LIMIT_WINDOW` - Rate limit window in milliseconds (default: 900000 = 15 minutes) +- **Configuration:** `AUTH_RATE_LIMIT_MAX` - Max authentication attempts per window per IP (default: 20) +- **Configuration:** `WEBHOOK_SECURITY_MODE` - SSRF protection mode (strict/moderate/permissive, default: strict) +- **Documentation:** Comprehensive security features section in all deployment guides + - HTTP_DEPLOYMENT.md - Rate limiting and SSRF protection configuration + - DOCKER_README.md - Security features section with environment variables + - DOCKER_TROUBLESHOOTING.md - "Webhooks to Local n8n Fail" troubleshooting guide + - RAILWAY_DEPLOYMENT.md - Security configuration recommendations + - README.md - Local n8n configuration section for moderate mode + +### Changed +- **Security:** All webhook triggers now validate URLs through SSRF protection before execution +- **Security:** HTTP authentication endpoint now enforces rate limiting per IP address +- **Dependencies:** Added `express-rate-limit@^7.1.5` for rate limiting functionality + +### Fixed +- **Security:** IPv6 localhost URLs (`http://[::1]/webhook`) now correctly stripped of brackets before validation +- **Security:** Localhost detection now properly handles all localhost variants (127.x.x.x, ::1, localhost, etc.) + ## [2.16.2] - 2025-10-06 ### πŸ”’ Security diff --git a/README.md b/README.md index 3b93d4f..e114763 100644 --- a/README.md +++ b/README.md @@ -198,10 +198,36 @@ Add to Claude Desktop config: } ``` ->πŸ’‘ Tip: If you’re running n8n locally on the same machine (e.g., via Docker), use http://host.docker.internal:5678 as the N8N_API_URL. +>πŸ’‘ Tip: If you're running n8n locally on the same machine (e.g., via Docker), use http://host.docker.internal:5678 as the N8N_API_URL. > **Note**: The n8n API credentials are optional. Without them, you'll have access to all documentation and validation tools. With them, you'll additionally get workflow management capabilities (create, update, execute workflows). +### 🏠 Local n8n Instance Configuration + +If you're running n8n locally (e.g., `http://localhost:5678` or Docker), you need to allow localhost webhooks: + +```json +{ + "mcpServers": { + "n8n-mcp": { + "command": "docker", + "args": [ + "run", "-i", "--rm", "--init", + "-e", "MCP_MODE=stdio", + "-e", "LOG_LEVEL=error", + "-e", "DISABLE_CONSOLE_OUTPUT=true", + "-e", "N8N_API_URL=http://host.docker.internal:5678", + "-e", "N8N_API_KEY=your-api-key", + "-e", "WEBHOOK_SECURITY_MODE=moderate", + "ghcr.io/czlonkowski/n8n-mcp:latest" + ] + } + } +} +``` + +> ⚠️ **Important:** Set `WEBHOOK_SECURITY_MODE=moderate` to allow webhooks to your local n8n instance. This is safe for local development while still blocking private networks and cloud metadata. + **Important:** The `-i` flag is required for MCP stdio communication. > πŸ”§ If you encounter any issues with Docker, check our [Docker Troubleshooting Guide](./docs/DOCKER_TROUBLESHOOTING.md). diff --git a/docs/DOCKER_README.md b/docs/DOCKER_README.md index c6d7096..8273192 100644 --- a/docs/DOCKER_README.md +++ b/docs/DOCKER_README.md @@ -65,6 +65,9 @@ docker run -d \ | `NODE_ENV` | Environment: `development` or `production` | `production` | No | | `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, `error` | `info` | No | | `NODE_DB_PATH` | Custom database path (v2.7.16+) | `/app/data/nodes.db` | No | +| `AUTH_RATE_LIMIT_WINDOW` | Rate limit window in ms (v2.16.3+) | `900000` (15 min) | No | +| `AUTH_RATE_LIMIT_MAX` | Max auth attempts per window (v2.16.3+) | `20` | No | +| `WEBHOOK_SECURITY_MODE` | SSRF protection: `strict`/`moderate`/`permissive` (v2.16.3+) | `strict` | No | *Either `AUTH_TOKEN` or `AUTH_TOKEN_FILE` must be set for HTTP mode. If both are set, `AUTH_TOKEN` takes precedence. @@ -283,7 +286,36 @@ docker ps --format "table {{.Names}}\t{{.Status}}" docker inspect n8n-mcp | jq '.[0].State.Health' ``` -## πŸ”’ Security Considerations +## πŸ”’ Security Features (v2.16.3+) + +### Rate Limiting + +Protects against brute force authentication attacks: + +```bash +# Configure in .env or docker-compose.yml +AUTH_RATE_LIMIT_WINDOW=900000 # 15 minutes in milliseconds +AUTH_RATE_LIMIT_MAX=20 # 20 attempts per IP per window +``` + +### SSRF Protection + +Prevents Server-Side Request Forgery when using webhook triggers: + +```bash +# For production (blocks localhost + private IPs + cloud metadata) +WEBHOOK_SECURITY_MODE=strict + +# For local development with local n8n instance +WEBHOOK_SECURITY_MODE=moderate + +# For internal testing only (allows private IPs) +WEBHOOK_SECURITY_MODE=permissive +``` + +**Note:** Cloud metadata endpoints (169.254.169.254, metadata.google.internal, etc.) are ALWAYS blocked in all modes. + +## πŸ”’ Authentication ### Authentication diff --git a/docs/DOCKER_TROUBLESHOOTING.md b/docs/DOCKER_TROUBLESHOOTING.md index 4b419f4..994b6dc 100644 --- a/docs/DOCKER_TROUBLESHOOTING.md +++ b/docs/DOCKER_TROUBLESHOOTING.md @@ -196,6 +196,41 @@ docker ps -a | grep n8n-mcp | grep Exited | awk '{print $1}' | xargs -r docker r - Manually clean up containers periodically - Consider using HTTP mode instead +### Webhooks to Local n8n Fail (v2.16.3+) + +**Symptoms:** +- `n8n_trigger_webhook_workflow` fails with "SSRF protection" error +- Error message: "SSRF protection: Localhost access is blocked" +- Webhooks work from n8n UI but not from n8n-MCP + +**Root Cause:** Default strict SSRF protection blocks localhost access to prevent attacks. + +**Solution:** Use moderate security mode for local development + +```bash +# For Docker run +docker run -d \ + --name n8n-mcp \ + -e MCP_MODE=http \ + -e AUTH_TOKEN=your-token \ + -e WEBHOOK_SECURITY_MODE=moderate \ + -p 3000:3000 \ + ghcr.io/czlonkowski/n8n-mcp:latest + +# For Docker Compose - add to environment: +services: + n8n-mcp: + environment: + WEBHOOK_SECURITY_MODE: moderate +``` + +**Security Modes Explained:** +- `strict` (default): Blocks localhost + private IPs + cloud metadata (production) +- `moderate`: Allows localhost, blocks private IPs + cloud metadata (local development) +- `permissive`: Allows localhost + private IPs, blocks cloud metadata (testing only) + +**Important:** Always use `strict` mode in production. Cloud metadata is blocked in all modes. + ### n8n API Connection Issues **Symptoms:** diff --git a/docs/HTTP_DEPLOYMENT.md b/docs/HTTP_DEPLOYMENT.md index 513b0ba..9c0560d 100644 --- a/docs/HTTP_DEPLOYMENT.md +++ b/docs/HTTP_DEPLOYMENT.md @@ -73,6 +73,13 @@ PORT=3000 # Optional: Enable n8n management tools # N8N_API_URL=https://your-n8n-instance.com # N8N_API_KEY=your-api-key-here +# Security Configuration (v2.16.3+) +# Rate limiting (default: 20 attempts per 15 minutes) +AUTH_RATE_LIMIT_WINDOW=900000 +AUTH_RATE_LIMIT_MAX=20 +# SSRF protection mode (default: strict) +# Use 'moderate' for local n8n, 'strict' for production +WEBHOOK_SECURITY_MODE=strict EOF # 2. Deploy with Docker @@ -592,6 +599,67 @@ curl -H "Authorization: Bearer $AUTH_TOKEN" \ } ``` +## πŸ”’ Security Features (v2.16.3+) + +### Rate Limiting + +Built-in rate limiting protects authentication endpoints from brute force attacks: + +**Configuration:** +```bash +# Defaults (15 minutes window, 20 attempts per IP) +AUTH_RATE_LIMIT_WINDOW=900000 # milliseconds +AUTH_RATE_LIMIT_MAX=20 +``` + +**Features:** +- Per-IP rate limiting with configurable window and max attempts +- Standard rate limit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset) +- JSON-RPC formatted error responses +- Automatic IP tracking behind reverse proxies (requires TRUST_PROXY=1) + +**Behavior:** +- First 20 attempts: Return 401 Unauthorized for invalid credentials +- Attempts 21+: Return 429 Too Many Requests with Retry-After header +- Counter resets after 15 minutes (configurable) + +### SSRF Protection + +Prevents Server-Side Request Forgery attacks when using webhook triggers: + +**Three Security Modes:** + +1. **Strict Mode (default)** - Production deployments + ```bash + WEBHOOK_SECURITY_MODE=strict + ``` + - βœ… Block localhost (127.0.0.1, ::1) + - βœ… Block private IPs (10.x, 192.168.x, 172.16-31.x) + - βœ… Block cloud metadata (169.254.169.254, metadata.google.internal) + - βœ… DNS rebinding prevention + - 🎯 **Use for**: Cloud deployments, production environments + +2. **Moderate Mode** - Local development with local n8n + ```bash + WEBHOOK_SECURITY_MODE=moderate + ``` + - βœ… Allow localhost (for local n8n instances) + - βœ… Block private IPs + - βœ… Block cloud metadata + - βœ… DNS rebinding prevention + - 🎯 **Use for**: Development with n8n on localhost:5678 + +3. **Permissive Mode** - Internal networks only + ```bash + WEBHOOK_SECURITY_MODE=permissive + ``` + - βœ… Allow localhost and private IPs + - βœ… Block cloud metadata (always blocked) + - βœ… DNS rebinding prevention + - 🎯 **Use for**: Internal testing (NOT for production) + +**Important:** Cloud metadata endpoints are ALWAYS blocked in all modes for security. + ## πŸ”’ Security Best Practices ### 1. Token Management diff --git a/docs/RAILWAY_DEPLOYMENT.md b/docs/RAILWAY_DEPLOYMENT.md index e6301d2..2c807cc 100644 --- a/docs/RAILWAY_DEPLOYMENT.md +++ b/docs/RAILWAY_DEPLOYMENT.md @@ -105,6 +105,9 @@ These are automatically set by the Railway template: | `CORS_ORIGIN` | `*` | Allow any origin | | `HOST` | `0.0.0.0` | Listen on all interfaces | | `PORT` | (Railway provides) | Don't set manually | +| `AUTH_RATE_LIMIT_WINDOW` | `900000` (15 min) | Rate limit window (v2.16.3+) | +| `AUTH_RATE_LIMIT_MAX` | `20` | Max auth attempts (v2.16.3+) | +| `WEBHOOK_SECURITY_MODE` | `strict` | SSRF protection mode (v2.16.3+) | ### Optional Variables @@ -284,6 +287,32 @@ Since the Railway template uses a specific Docker image tag, updates are manual: You could use the `latest` tag, but this may cause unexpected breaking changes. +## πŸ”’ Security Features (v2.16.3+) + +Railway deployments include enhanced security features: + +### Rate Limiting +- **Automatic brute force protection** - 20 attempts per 15 minutes per IP +- **Configurable limits** via `AUTH_RATE_LIMIT_WINDOW` and `AUTH_RATE_LIMIT_MAX` +- **Standard rate limit headers** for client awareness + +### SSRF Protection +- **Default strict mode** blocks localhost, private IPs, and cloud metadata +- **Cloud metadata always blocked** (169.254.169.254, metadata.google.internal, etc.) +- **Use `moderate` mode only if** connecting to local n8n instance + +**Security Configuration:** +```bash +# In Railway Variables tab: +WEBHOOK_SECURITY_MODE=strict # Production (recommended) +# or +WEBHOOK_SECURITY_MODE=moderate # If using local n8n with port forwarding + +# Rate limiting (defaults are good for most use cases) +AUTH_RATE_LIMIT_WINDOW=900000 # 15 minutes +AUTH_RATE_LIMIT_MAX=20 # 20 attempts per IP +``` + ## πŸ“ Best Practices 1. **Always change the default AUTH_TOKEN immediately** diff --git a/package-lock.json b/package-lock.json index 2716080..2210ceb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n-mcp", - "version": "2.14.3", + "version": "2.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-mcp", - "version": "2.14.3", + "version": "2.16.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.13.2", @@ -14,6 +14,7 @@ "@supabase/supabase-js": "^2.57.4", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^7.1.5", "lru-cache": "^11.2.1", "n8n": "^1.113.3", "n8n-core": "^1.112.1", @@ -9325,6 +9326,21 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", @@ -12597,6 +12613,21 @@ "prebuild-install": "^7.1.1" } }, + "node_modules/@n8n/n8n-nodes-langchain/node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/@n8n/n8n-nodes-langchain/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -20971,9 +21002,9 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.1.5.tgz", + "integrity": "sha512-/iVogxu7ueadrepw1bS0X0kaRC/U0afwiYRSLg68Ts+p4Dc85Q5QKsOnPS/QUjPMHvOJQtBDrZgvkOzf8ejUYw==", "license": "MIT", "engines": { "node": ">= 16" @@ -20982,7 +21013,7 @@ "url": "https://github.com/sponsors/express-rate-limit" }, "peerDependencies": { - "express": ">= 4.11" + "express": "4 || 5 || ^5.0.0-beta.1" } }, "node_modules/express/node_modules/mime-db": { diff --git a/package.json b/package.json index e2f3a9b..589a584 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.16.2", + "version": "2.16.3", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "bin": { @@ -136,6 +136,7 @@ "@supabase/supabase-js": "^2.57.4", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^7.1.5", "lru-cache": "^11.2.1", "n8n": "^1.113.3", "n8n-core": "^1.112.1", diff --git a/package.runtime.json b/package.runtime.json index e1cf32f..53abd16 100644 --- a/package.runtime.json +++ b/package.runtime.json @@ -1,12 +1,13 @@ { "name": "n8n-mcp-runtime", - "version": "2.16.1", + "version": "2.16.3", "description": "n8n MCP Server Runtime Dependencies Only", "private": true, "dependencies": { "@modelcontextprotocol/sdk": "^1.13.2", "@supabase/supabase-js": "^2.57.4", "express": "^5.1.0", + "express-rate-limit": "^7.1.5", "dotenv": "^16.5.0", "lru-cache": "^11.2.1", "sql.js": "^1.13.0", diff --git a/src/http-server-single-session.ts b/src/http-server-single-session.ts index d45cef8..3c1a0d3 100644 --- a/src/http-server-single-session.ts +++ b/src/http-server-single-session.ts @@ -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 => { + // 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 => { // Log comprehensive debug info about the request logger.info('POST /mcp request received - DETAILED DEBUG', { headers: req.headers, diff --git a/src/services/n8n-api-client.ts b/src/services/n8n-api-client.ts index 732f69c..fe31d3f 100644 --- a/src/services/n8n-api-client.ts +++ b/src/services/n8n-api-client.ts @@ -212,7 +212,16 @@ export class N8nApiClient { async triggerWebhook(request: WebhookRequest): Promise { 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; diff --git a/src/utils/ssrf-protection.ts b/src/utils/ssrf-protection.ts new file mode 100644 index 0000000..38f15aa --- /dev/null +++ b/src/utils/ssrf-protection.ts @@ -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' }; + } + } +} diff --git a/tests/integration/security/rate-limiting.test.ts b/tests/integration/security/rate-limiting.test.ts new file mode 100644 index 0000000..68071fa --- /dev/null +++ b/tests/integration/security/rate-limiting.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, ChildProcess } from 'child_process'; +import axios from 'axios'; + +/** + * Integration tests for rate limiting + * + * SECURITY: These tests verify rate limiting prevents brute force attacks + * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02) + */ +describe('Integration: Rate Limiting', () => { + let serverProcess: ChildProcess; + const port = 3001; + const authToken = 'test-token-for-rate-limiting-test-32-chars'; + + beforeAll(async () => { + // Start HTTP server with rate limiting + serverProcess = spawn('node', ['dist/http-server-single-session.js'], { + env: { + ...process.env, + MCP_MODE: 'http', + PORT: port.toString(), + AUTH_TOKEN: authToken, + NODE_ENV: 'test', + AUTH_RATE_LIMIT_WINDOW: '900000', // 15 minutes + AUTH_RATE_LIMIT_MAX: '20', // 20 attempts + }, + stdio: 'pipe', + }); + + // Wait for server to start + await new Promise(resolve => setTimeout(resolve, 3000)); + }, 15000); + + afterAll(() => { + if (serverProcess) { + serverProcess.kill(); + } + }); + + it('should block after max authentication attempts (sequential requests)', async () => { + const baseUrl = `http://localhost:${port}/mcp`; + + // IMPORTANT: Use sequential requests to ensure deterministic order + // Parallel requests can cause race conditions with in-memory rate limiter + for (let i = 1; i <= 25; i++) { + const response = await axios.post( + baseUrl, + { jsonrpc: '2.0', method: 'initialize', id: i }, + { + headers: { Authorization: 'Bearer wrong-token' }, + validateStatus: () => true, // Don't throw on error status + } + ); + + if (i <= 20) { + // First 20 attempts should be 401 (invalid authentication) + expect(response.status).toBe(401); + expect(response.data.error.message).toContain('Unauthorized'); + } else { + // Attempts 21+ should be 429 (rate limited) + expect(response.status).toBe(429); + expect(response.data.error.message).toContain('Too many'); + } + } + }, 60000); + + it('should include rate limit headers', async () => { + const baseUrl = `http://localhost:${port}/mcp`; + + const response = await axios.post( + baseUrl, + { jsonrpc: '2.0', method: 'initialize', id: 1 }, + { + headers: { Authorization: 'Bearer wrong-token' }, + validateStatus: () => true, + } + ); + + // Check for standard rate limit headers + expect(response.headers['ratelimit-limit']).toBeDefined(); + expect(response.headers['ratelimit-remaining']).toBeDefined(); + expect(response.headers['ratelimit-reset']).toBeDefined(); + }, 15000); + + it('should accept valid tokens within rate limit', async () => { + const baseUrl = `http://localhost:${port}/mcp`; + + const response = await axios.post( + baseUrl, + { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0' }, + }, + id: 1, + }, + { + headers: { Authorization: `Bearer ${authToken}` }, + } + ); + + expect(response.status).toBe(200); + expect(response.data.result).toBeDefined(); + }, 15000); + + it('should return JSON-RPC formatted error on rate limit', async () => { + const baseUrl = `http://localhost:${port}/mcp`; + + // Exhaust rate limit + for (let i = 0; i < 21; i++) { + await axios.post( + baseUrl, + { jsonrpc: '2.0', method: 'initialize', id: i }, + { + headers: { Authorization: 'Bearer wrong-token' }, + validateStatus: () => true, + } + ); + } + + // Get rate limited response + const response = await axios.post( + baseUrl, + { jsonrpc: '2.0', method: 'initialize', id: 999 }, + { + headers: { Authorization: 'Bearer wrong-token' }, + validateStatus: () => true, + } + ); + + // Verify JSON-RPC error format + expect(response.data).toHaveProperty('jsonrpc', '2.0'); + expect(response.data).toHaveProperty('error'); + expect(response.data.error).toHaveProperty('code', -32000); + expect(response.data.error).toHaveProperty('message'); + expect(response.data).toHaveProperty('id', null); + }, 60000); +}); diff --git a/tests/unit/utils/ssrf-protection.test.ts b/tests/unit/utils/ssrf-protection.test.ts new file mode 100644 index 0000000..8682ec0 --- /dev/null +++ b/tests/unit/utils/ssrf-protection.test.ts @@ -0,0 +1,352 @@ +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 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'); + }); + }); + + 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); + }); + }); +});