Root cause
SSRF & network boundaries introduced
fix: extract instance context from HTTP headers for multi-tenant support
How AI contributed: Direct introductionPublished Apr 8, 2026 in
czlonkowski/n8n-mcpAll 11 findings in this repository
The AI-written multi-tenant HTTP server read the n8n instance address from the caller-supplied X-N8n-Url header and used it to build the instance context that the n8n API client calls. Nothing validated where that URL pointed, so any caller able to authenticate to the server could direct its requests at internal services, cloud metadata endpoints, or localhost services. The fix checks the header URL with existing webhook SSRF protection, adds a synchronous validator that blocks metadata hosts, private ranges, and credential-bearing URLs, and normalizes the base URL again in the API client.
In src/http-server-single-session.ts, the value of X-N8n-Url was accepted verbatim when building instance context and handed to the n8n API client, with no protocol, host, or private-address validation before the outbound request was made.
Root cause
fix: extract instance context from HTTP headers for multi-tenant support
Fix
The fix rejects unsafe instance URLs during header validation and again with a DNS-aware check before use, blocking invalid protocols, embedded credentials, fragments, metadata hosts, localhost, and private addresses.
AI-assisted fix: Claude Code · Romuald Członkowski
Case facts
@@ -999,8 +999,31 @@ export class SingleSessionHTTPServer { sessionType: this.session?.isSSE ? 'SSE' : 'StreamableHTTP', sessionInitialized: this.session?.initialized });- - await this.handleRequest(req, res);++ // Extract instance context from headers if present (for multi-tenant support)+ const instanceContext: InstanceContext | undefined =+ (req.headers['x-n8n-url'] || req.headers['x-n8n-key']) ? {+ n8nApiUrl: req.headers['x-n8n-url'] as string,+ n8nApiKey: req.headers['x-n8n-key'] as string,+ instanceId: req.headers['x-instance-id'] as string,+ sessionId: req.headers['x-session-id'] as string,+ metadata: {+ userAgent: req.headers['user-agent'],+ ip: req.ip+ }+ } : undefined;++ // Log context extraction for debugging (only if context exists)+ if (instanceContext) {+ logger.debug('Instance context extracted from headers', {+ hasUrl: !!instanceContext.n8nApiUrl,+ hasKey: !!instanceContext.n8nApiKey,+ instanceId: instanceContext.instanceId,+ sessionId: instanceContext.sessionId+ });+ }++ await this.handleRequest(req, res, instanceContext); logger.info('POST /mcp request completed - checking response status', { responseHeadersSent: res.headersSent,@@ -99,6 +99,61 @@ if (client) { } ``` +### HTTP Headers for Multi-Tenant Support++When using the HTTP server mode, clients can pass instance-specific configuration via HTTP headers:++```bash+# Example curl request with instance headers+curl -X POST http://localhost:3000/mcp \+ -H "Authorization: Bearer your-auth-token" \+ -H "Content-Type: application/json" \+ -H "X-N8n-Url: https://instance1.n8n.cloud" \+ -H "X-N8n-Key: instance1-api-key" \+ -H "X-Instance-Id: instance-1" \+ -H "X-Session-Id: session-123" \+ -d '{"method": "n8n_list_workflows", "params": {}, "id": 1}'+```++#### Supported Headers++- **X-N8n-Url**: The n8n instance URL (e.g., `https://instance.n8n.cloud`)+- **X-N8n-Key**: The API key for authentication with the n8n instance+- **X-Instance-Id**: A unique identifier for the instance (optional, for tracking)+- **X-Session-Id**: A session identifier (optional, for session tracking)++#### Header Extraction Logic++1. If either `X-N8n-Url` or `X-N8n-Key` header is present, an instance context is created+2. All headers are extracted and passed to the MCP server+3. The server uses the instance-specific configuration instead of environment variables+4. If no headers are present, the server falls back to environment variables (backward compatible)++#### Example: JavaScript Client++```javascript+const headers = {+ 'Authorization': 'Bearer your-auth-token',+ 'Content-Type': 'application/json',+ 'X-N8n-Url': 'https://customer1.n8n.cloud',+ 'X-N8n-Key': 'customer1-api-key',+ 'X-Instance-Id': 'customer-1',+ 'X-Session-Id': 'session-456'+};++const response = await fetch('http://localhost:3000/mcp', {+ method: 'POST',+ headers: headers,+ body: JSON.stringify({+ method: 'n8n_list_workflows',+ params: {},+ id: 1+ })+});++const result = await response.json();+```+ ### HTTP Server Integration ```typescript@@ -208,4 +208,133 @@ describe('Flexible Instance Configuration', () => { expect(isInstanceContext({ n8nApiUrl: 123 })).toBe(false); }); });++ describe('HTTP Header Extraction Logic', () => {+ it('should create instance context from headers', () => {+ // Test the logic that would extract context from headers+ const headers = {+ 'x-n8n-url': 'https://instance1.n8n.cloud',+ 'x-n8n-key': 'test-api-key-123',+ 'x-instance-id': 'instance-test-1',+ 'x-session-id': 'session-test-123',+ 'user-agent': 'test-client/1.0'+ };++ // This simulates the logic in http-server-single-session.ts+ const instanceContext: InstanceContext | undefined =+ (headers['x-n8n-url'] || headers['x-n8n-key']) ? {+ n8nApiUrl: headers['x-n8n-url'] as string,+ n8nApiKey: headers['x-n8n-key'] as string,+ instanceId: headers['x-instance-id'] as string,+ sessionId: headers['x-session-id'] as string,+ metadata: {+ userAgent: headers['user-agent'],+ ip: '127.0.0.1'+ }+ } : undefined;++ expect(instanceContext).toBeDefined();+ expect(instanceContext?.n8nApiUrl).toBe('https://instance1.n8n.cloud');+ expect(instanceContext?.n8nApiKey).toBe('test-api-key-123');+ expect(instanceContext?.instanceId).toBe('instance-test-1');+ expect(instanceContext?.sessionId).toBe('session-test-123');+ expect(instanceContext?.metadata?.userAgent).toBe('test-client/1.0');+ });++ it('should not create context when headers are missing', () => {+ // Test when no relevant headers are present+ const headers: Record<string, string | undefined> = {+ 'content-type': 'application/json',+ 'user-agent': 'test-client/1.0'+ };++ const instanceContext: InstanceContext | undefined =+ (headers['x-n8n-url'] || headers['x-n8n-key']) ? {+ n8nApiUrl: headers['x-n8n-url'] as string,+ n8nApiKey: headers['x-n8n-key'] as string,+ instanceId: headers['x-instance-id'] as string,+ sessionId: headers['x-session-id'] as string,+ metadata: {+ userAgent: headers['user-agent'],+ ip: '127.0.0.1'+ }+ } : undefined;++ expect(instanceContext).toBeUndefined();+ });++ it('should create context with partial headers', () => {+ // Test when only some headers are present+ const headers: Record<string, string | undefined> = {+ 'x-n8n-url': 'https://partial.n8n.cloud',+ 'x-instance-id': 'partial-instance'+ // Missing x-n8n-key and x-session-id+ };++ const instanceContext: InstanceContext | undefined =+ (headers['x-n8n-url'] || headers['x-n8n-key']) ? {+ n8nApiUrl: headers['x-n8n-url'] as string,+ n8nApiKey: headers['x-n8n-key'] as string,+ instanceId: headers['x-instance-id'] as string,+ sessionId: headers['x-session-id'] as string,+ metadata: undefined+ } : undefined;++ expect(instanceContext).toBeDefined();+ expect(instanceContext?.n8nApiUrl).toBe('https://partial.n8n.cloud');+ expect(instanceContext?.n8nApiKey).toBeUndefined();+ expect(instanceContext?.instanceId).toBe('partial-instance');+ expect(instanceContext?.sessionId).toBeUndefined();+ });++ it('should prioritize x-n8n-key for context creation', () => {+ // Test when only API key is present+ const headers: Record<string, string | undefined> = {+ 'x-n8n-key': 'key-only-test',+ 'x-instance-id': 'key-only-instance'+ // Missing x-n8n-url+ };@@ -531,9 +531,32 @@ export class SingleSessionHTTPServer { // Wrap all operations to prevent console interference return this.consoleManager.wrapOperation(async () => { try {+ // SECURITY (GHSA-4ggg-h7ph-26qr): validate instance-supplied URL.+ if (instanceContext?.n8nApiUrl) {+ const { SSRFProtection } = await import('./utils/ssrf-protection');+ const ssrfResult = await SSRFProtection.validateWebhookUrl(instanceContext.n8nApiUrl);+ if (!ssrfResult.valid) {+ logger.warn('SSRF protection blocked instance context URL', {+ reason: ssrfResult.reason,+ instanceId: instanceContext.instanceId+ });+ if (!res.headersSent) {+ res.status(400).json({+ jsonrpc: '2.0',+ error: {+ code: -32602,+ message: 'Invalid instance configuration'+ },+ id: req.body?.id ?? null+ });+ }+ return;+ }+ }+ const sessionId = req.headers['mcp-session-id'] as string | undefined; const isInitialize = req.body ? isInitializeRequest(req.body) : false;- + // Log comprehensive incoming request details for debugging logger.info('handleRequest: Processing MCP request - SDK PATTERN', { requestId: req.get('x-request-id') || 'unknown',@@ -1354,43 +1377,52 @@ export class SingleSessionHTTPServer { }); // Extract instance context from headers if present (for multi-tenant support)- const instanceContext: InstanceContext | undefined = (() => {+ let instanceContext: InstanceContext | undefined;+ { // Use type-safe header extraction const headers = extractMultiTenantHeaders(req); const hasUrl = headers['x-n8n-url']; const hasKey = headers['x-n8n-key']; - if (!hasUrl && !hasKey) return undefined;+ if (hasUrl || hasKey) {+ // Create context with proper type handling+ const candidate: InstanceContext = {+ n8nApiUrl: hasUrl || undefined,+ n8nApiKey: hasKey || undefined,+ instanceId: headers['x-instance-id'] || undefined,+ sessionId: headers['x-session-id'] || undefined+ }; - // Create context with proper type handling- const context: InstanceContext = {- n8nApiUrl: hasUrl || undefined,- n8nApiKey: hasKey || undefined,- instanceId: headers['x-instance-id'] || undefined,- sessionId: headers['x-session-id'] || undefined- };+ // Add metadata if available+ if (req.headers['user-agent'] || req.ip) {+ candidate.metadata = {+ userAgent: req.headers['user-agent'] as string | undefined,+ ip: req.ip+ };+ } - // Add metadata if available- if (req.headers['user-agent'] || req.ip) {- context.metadata = {- userAgent: req.headers['user-agent'] as string | undefined,- ip: req.ip- };- }+ // SECURITY (GHSA-4ggg-h7ph-26qr): fail closed on invalid context.+ const validation = validateInstanceContext(candidate);+ if (!validation.valid) {+ logger.warn('Invalid instance context from headers', {+ errors: validation.errors,+ hasUrl: !!hasUrl,+ hasKey: !!hasKey+ });+ res.status(400).json({+ jsonrpc: '2.0',+ error: {+ code: -32602,@@ -184,4 +184,66 @@ export class SSRFProtection { return { valid: false, reason: 'Invalid URL format' }; } }++ /**+ * Synchronous URL validation with no DNS resolution.+ *+ * Suitable for sync callers that cannot await DNS lookups. Pair with+ * {@link validateWebhookUrl} at async boundaries for full protection.+ *+ * @param urlString - URL to validate (raw input, not parsed)+ * @returns Validation result with optional reason on failure+ *+ * @security See GHSA-4ggg-h7ph-26qr.+ */+ static validateUrlSync(urlString: string): { valid: boolean; reason?: string } {+ if (typeof urlString !== 'string' || urlString.includes('#')) {+ return { valid: false, reason: 'URL fragments are not allowed' };+ }++ let url: URL;+ try {+ url = new URL(urlString);+ } catch {+ return { valid: false, reason: 'Invalid URL format' };+ }++ if (!['http:', 'https:'].includes(url.protocol)) {+ return { valid: false, reason: 'Invalid protocol. Only HTTP/HTTPS allowed.' };+ }++ if (url.username !== '' || url.password !== '') {+ return { valid: false, reason: 'Userinfo in URL is not allowed' };+ }++ let hostname = url.hostname.toLowerCase();+ if (hostname.startsWith('[') && hostname.endsWith(']')) {+ hostname = hostname.slice(1, -1);+ }++ if (CLOUD_METADATA.has(hostname)) {+ return { valid: false, reason: 'Cloud metadata endpoint blocked' };+ }++ const mode: SecurityMode = (process.env.WEBHOOK_SECURITY_MODE || 'strict') as SecurityMode;++ if (mode === 'permissive') {+ return { valid: true };+ }++ if (mode === 'strict' && LOCALHOST_PATTERNS.has(hostname)) {+ return { valid: false, reason: 'Localhost access is blocked in strict mode' };+ }++ if (PRIVATE_IP_RANGES.some(regex => regex.test(hostname))) {+ return {+ valid: false,+ reason: mode === 'strict'+ ? 'Private IP addresses not allowed'+ : 'Private IP addresses not allowed (use WEBHOOK_SECURITY_MODE=permissive if needed)'+ };+ }++ return { valid: true };+ } }@@ -58,12 +58,28 @@ export class N8nApiClient { const { baseUrl, apiKey, timeout = 30000, maxRetries = 3 } = config; this.maxRetries = maxRetries;- this.baseUrl = baseUrl;++ // SECURITY (GHSA-4ggg-h7ph-26qr): defense-in-depth baseUrl normalization.+ let normalizedBase: string;+ try {+ const parsed = new URL(baseUrl);+ parsed.hash = '';+ parsed.username = '';+ parsed.password = '';+ normalizedBase = parsed.toString().replace(/\/$/, '');+ } catch {+ // Unparseable input falls through to raw; downstream axios call will+ // fail cleanly. Preserves backward compat for tests that pass+ // placeholder strings.+ normalizedBase = baseUrl;+ }++ this.baseUrl = normalizedBase; // Ensure baseUrl ends with /api/v1- const apiUrl = baseUrl.endsWith('/api/v1')- ? baseUrl- : `${baseUrl.replace(/\/$/, '')}/api/v1`;+ const apiUrl = normalizedBase.endsWith('/api/v1')+ ? normalizedBase+ : `${normalizedBase}/api/v1`; this.client = axios.create({ baseURL: apiUrl,AI-assisted change cb97b440f405c5bc5c65d8f1d04c66944d5eef19172370f026ba694903112aaa · Fix 39f3b02a15b86382e691df358261c528375b2ba83a4f961cd6ebb315ba588b07