Skip to content

Commit cbdf4b6

Browse files
czlonkowskiclaude
andcommitted
fix: pin the SSRF transport to every validated address and retry connection failures (v2.71.0)
The DNS-pinning protection resolved one address and pinned it for the process lifetime: localhost resolving ::1-first broke IPv4-only n8n instances on macOS, and CDN-fronted instances stayed nailed to a single possibly-dead edge. Resolve the full record set, validate every answer (fail closed on any disallowed record, all modes), pin the whole validated set with autoSelectFamily fallback, re-resolve after a 60s TTL and on connection failure, wire N8N_API_MAX_RETRIES into a real retry path (pre-connection failures any method, reset/timeout reads only), and name the failing address in NO_RESPONSE errors. Fixes #978 Fixes #989 Fixes #990 Reported with detailed root-cause analyses by @ConnorCloze (#978) and @Boulevard-Dreams (#989, #990). Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f551a67 commit cbdf4b6

10 files changed

Lines changed: 784 additions & 108 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [Unreleased]
11+
12+
## [2.71.0] - 2026-08-18
13+
14+
### Fixed
15+
16+
- **The SSRF-pinned transport now fails over across every validated address instead of hard-failing on the first DNS answer.** The DNS-pinning protection (GHSA-cmrh-wvq6-wm9r) resolved the API hostname with a single-answer lookup and pinned all connections to that one address for the life of the process. Two real-world casualties: on macOS, `localhost` answers `::1` first, so an n8n listening only on IPv4 loopback (the Docker Desktop default) failed every management tool with an unexplained `NO_RESPONSE` even though `curl` worked; and a CDN-fronted instance (Cloudflare) stayed nailed to whichever single edge answered first at startup, so one bad edge meant every call failed until the process restarted. The validator now resolves the full record set, validates **every** address against the SSRF policy — failing closed if any record is disallowed, which also closes the mixed-record variant of DNS rebinding and, deliberately, now applies to metadata addresses in any record position even in permissive mode — and the transport pins the whole validated set, letting the socket layer try each candidate in turn. The pinned agents are also re-resolved after a 60-second TTL and after any connection failure, so a rotated edge or moved instance heals on the next call. (#978, #989, #990)
17+
- **`N8N_API_MAX_RETRIES` does something now.** It was validated, documented, stored — and never read: no retry logic existed on the n8n API client at all. Connection-level failures are now retried up to that count with exponential backoff and a fresh DNS resolution per attempt. Failures that occur before the connection is established (refused, unreachable, DNS) retry for any method; failures that may have interrupted an in-flight request (reset, timeout) retry only for reads, so a create is never double-executed. DNS-resolution failures inside URL validation still fail fast — the cache resets, so the next call re-resolves.
18+
- **`NO_RESPONSE` errors say which address failed.** `Unable to connect to n8n…` now carries the failing code and address, e.g. `(ECONNREFUSED 127.0.0.1:5678, ECONNREFUSED [::1]:5678)`, naming each attempted candidate — the difference between a two-minute diagnosis and a dead end where n8n looks healthy and the MCP looks broken.
19+
1020
## [2.70.4] - 2026-08-18
1121

1222
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-mcp",
3-
"version": "2.70.4",
3+
"version": "2.71.0",
44
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/services/n8n-api-client.ts

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ export class N8nApiClient {
149149
private personalProjectId: string | null = null;
150150
// SECURITY (GHSA-cmrh-wvq6-wm9r): cached pinned transport agents.
151151
private pinnedAgentsPromise: Promise<PinnedAgents> | null = null;
152+
// #978/#989/#990: when the cached agents were last (re-)resolved, so a
153+
// long-lived client periodically re-validates DNS instead of pinning to
154+
// one address (possibly a stale CDN/Cloudflare edge) for its whole life.
155+
private pinnedAgentsResolvedAt = 0;
156+
private static readonly PINNED_AGENTS_TTL_MS = 60_000;
152157
private cfClientId?: string;
153158
private cfClientSecret?: string;
154159
/**
@@ -230,37 +235,156 @@ export class N8nApiClient {
230235
}
231236
);
232237

233-
// Response interceptor for logging
238+
// Response interceptor for logging + connection-failure retry
234239
this.client.interceptors.response.use(
235240
(response: any) => {
236241
logger.debug(`n8n API Response: ${response.status} ${response.config.url}`);
237242
return response;
238243
},
239-
(error: unknown) => {
244+
async (error: unknown) => {
245+
// #978/#989/#990: retry connection-level failures (no response at
246+
// all) before mapping to N8nApiError. Re-issuing goes back through
247+
// this same interceptor pipeline, so a further failure is retried
248+
// again automatically until maxRetries is exhausted.
249+
const retryAttempt = this.tryRetry(error);
250+
if (retryAttempt) {
251+
return retryAttempt;
252+
}
253+
240254
const n8nError = handleN8nApiError(error);
255+
if (n8nError.code === 'NO_RESPONSE') {
256+
// SECURITY (GHSA-cmrh-wvq6-wm9r resilience): the pinned IP may be
257+
// dead (CDN edge rotated, instance moved) - clear the cache so the
258+
// *next* request re-resolves DNS instead of retrying the same bad
259+
// address forever.
260+
this.pinnedAgentsPromise = null;
261+
}
241262
logN8nError(n8nError, 'n8n API Response');
242263
return Promise.reject(n8nError);
243264
}
244265
);
245266
}
246267

268+
/**
269+
* Retry a connection-level axios failure (no response received) when it
270+
* looks safe to retry and attempts remain. Returns a promise for the
271+
* retried request when a retry is attempted, or `undefined` when the
272+
* caller should fall through to normal error mapping.
273+
*
274+
* @security GHSA-cmrh-wvq6-wm9r follow-up (#978/#989/#990) - the failure
275+
* may mean the pinned IP has gone stale, so the pinned-agent cache is
276+
* cleared before each retry to force fresh DNS resolution.
277+
*/
278+
private tryRetry(error: unknown): Promise<any> | undefined {
279+
const axiosError = error as any;
280+
const config = axiosError?.config;
281+
const noResponse = !!(axiosError && axiosError.request && !axiosError.response);
282+
if (!noResponse || !config) {
283+
return undefined;
284+
}
285+
286+
const retryCount = (config as any).__retryCount || 0;
287+
if (retryCount >= this.maxRetries) {
288+
return undefined;
289+
}
290+
291+
// Default to a non-idempotent classification when the method is missing:
292+
// only pre-connection failures are then eligible for retry.
293+
const method = String(config.method || '');
294+
if (!this.isRetryableConnectionError(axiosError, method)) {
295+
return undefined;
296+
}
297+
298+
(config as any).__retryCount = retryCount + 1;
299+
// Force fresh DNS on the retried attempt.
300+
this.pinnedAgentsPromise = null;
301+
302+
const backoffMs = 250 * Math.pow(2, retryCount);
303+
return new Promise((resolve, reject) => {
304+
setTimeout(() => {
305+
this.client.request(config).then(resolve, reject);
306+
}, backoffMs);
307+
});
308+
}
309+
310+
/**
311+
* Whether a connection-level axios error is safe to retry for the given
312+
* HTTP method. Errors that occurred before any bytes reached the wire
313+
* (connection refused/unreachable/DNS failure) are safe to retry
314+
* regardless of method - the server never saw the request. Errors that may
315+
* have interrupted an in-flight request (reset, timeout) are only retried
316+
* for idempotent methods.
317+
*/
318+
private isRetryableConnectionError(axiosError: any, method: string): boolean {
319+
const codes = this.extractErrorCodes(axiosError);
320+
if (codes.length === 0) return false;
321+
322+
const isIdempotent = method.toUpperCase() === 'GET' || method.toUpperCase() === 'HEAD';
323+
const anyMethodCodes = new Set(['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'EAI_AGAIN']);
324+
const idempotentOnlyCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED']);
325+
326+
return codes.some(code => anyMethodCodes.has(code) || (isIdempotent && idempotentOnlyCodes.has(code)));
327+
}
328+
329+
/**
330+
* Collect every error `code` relevant to the retry decision: the error's
331+
* own code, plus each member's code when the error is an AggregateError
332+
* (e.g. from `autoSelectFamily` trying multiple pinned addresses).
333+
*/
334+
private extractErrorCodes(error: any): string[] {
335+
const codes: string[] = [];
336+
if (error?.code) codes.push(error.code);
337+
338+
const aggregateMembers = error?.errors ?? error?.cause?.errors;
339+
if (Array.isArray(aggregateMembers)) {
340+
for (const member of aggregateMembers) {
341+
if (member?.code) codes.push(member.code);
342+
}
343+
}
344+
return codes;
345+
}
346+
247347
/**
248348
* Resolve the configured baseUrl once and return HTTP/HTTPS agents that
249-
* pin every connection to the validated IP.
349+
* pin every connection to the validated address(es). Re-resolved when the
350+
* cache is empty, has expired (TTL), or was invalidated after a
351+
* connection failure — see {@link tryRetry} and the NO_RESPONSE branch of
352+
* the response interceptor.
250353
*
251354
* @security GHSA-cmrh-wvq6-wm9r — without this, axios performs an
252355
* independent DNS lookup on every request, opening a TOCTOU window.
253356
*/
254357
private getPinnedAgents(): Promise<PinnedAgents> {
358+
const isExpired = this.pinnedAgentsPromise !== null &&
359+
Date.now() - this.pinnedAgentsResolvedAt > N8nApiClient.PINNED_AGENTS_TTL_MS;
360+
if (isExpired) {
361+
// #978/#989/#990: don't stay pinned to a possibly-stale address (e.g.
362+
// a rotated CDN/Cloudflare edge) for the whole process lifetime.
363+
this.pinnedAgentsPromise = null;
364+
}
365+
255366
if (!this.pinnedAgentsPromise) {
256367
const promise = (async () => {
257368
const { SSRFProtection } = await import('../utils/ssrf-protection');
258369
const validation = await SSRFProtection.validateWebhookUrl(this.baseUrl);
259370
if (!validation.valid || !validation.address || !validation.family) {
260371
throw new Error(`SSRF protection: ${validation.reason || 'baseUrl rejected'}`);
261372
}
262-
return SSRFProtection.createPinnedAgents(validation.address, validation.family);
373+
return SSRFProtection.createPinnedAgents(
374+
validation.addresses ?? [{ address: validation.address, family: validation.family }]
375+
);
263376
})();
377+
// Stamp at dispatch so concurrent callers during an in-flight
378+
// re-resolution see a fresh TTL and don't each kick off their own
379+
// lookup; refresh on fulfillment (only while still the current
380+
// promise) so the window restarts from when the addresses actually
381+
// became valid.
382+
this.pinnedAgentsResolvedAt = Date.now();
383+
promise.then(() => {
384+
if (this.pinnedAgentsPromise === promise) {
385+
this.pinnedAgentsResolvedAt = Date.now();
386+
}
387+
}, () => {});
264388
// Reset on rejection so transient DNS failures don't brick the client.
265389
promise.catch(() => {
266390
if (this.pinnedAgentsPromise === promise) {
@@ -1037,7 +1161,9 @@ export class N8nApiClient {
10371161

10381162
// SECURITY (GHSA-cmrh-wvq6-wm9r): pin transport to validated IP.
10391163
const pinned = validation.address && validation.family
1040-
? SSRFProtection.createPinnedAgents(validation.address, validation.family)
1164+
? SSRFProtection.createPinnedAgents(
1165+
validation.addresses ?? [{ address: validation.address, family: validation.family }]
1166+
)
10411167
: undefined;
10421168

10431169
// Create a new axios instance for webhook requests to avoid API interceptors

src/triggers/handlers/chat-handler.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ export class ChatHandler extends BaseTriggerHandler<ChatTriggerInput> {
9191

9292
// SECURITY (GHSA-cmrh-wvq6-wm9r): pin transport to validated IP.
9393
const pinned = validation.address && validation.family
94-
? SSRFProtection.createPinnedAgents(validation.address, validation.family)
94+
? SSRFProtection.createPinnedAgents(
95+
validation.addresses ?? [{ address: validation.address, family: validation.family }]
96+
)
9597
: undefined;
9698

9799
// Generate or use provided session ID
@@ -139,7 +141,16 @@ export class ChatHandler extends BaseTriggerHandler<ChatTriggerInput> {
139141
},
140142
});
141143
} catch (error) {
142-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
144+
let errorMessage = error instanceof Error ? error.message : 'Unknown error';
145+
if (!errorMessage) {
146+
// An AggregateError from a multi-address connection failure
147+
// (autoSelectFamily across the pinned set) has an empty message;
148+
// summarize its members instead of reporting nothing.
149+
const members = (error as any)?.errors ?? (error as any)?.cause?.errors;
150+
errorMessage = (Array.isArray(members)
151+
? members.map((m: any) => m?.code || m?.message).filter(Boolean).join(', ')
152+
: '') || 'Connection failed';
153+
}
143154

144155
// Try to extract execution ID from error if available
145156
const errorDetails = (error as any)?.response?.data;

src/triggers/handlers/form-handler.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
269269

270270
// SECURITY (GHSA-cmrh-wvq6-wm9r): pin transport to validated IP.
271271
const pinned = validation.address && validation.family
272-
? SSRFProtection.createPinnedAgents(validation.address, validation.family)
272+
? SSRFProtection.createPinnedAgents(
273+
validation.addresses ?? [{ address: validation.address, family: validation.family }]
274+
)
273275
: undefined;
274276

275277
// Build multipart/form-data (required by n8n form triggers)
@@ -443,7 +445,16 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
443445

444446
return result;
445447
} catch (error) {
446-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
448+
let errorMessage = error instanceof Error ? error.message : 'Unknown error';
449+
if (!errorMessage) {
450+
// An AggregateError from a multi-address connection failure
451+
// (autoSelectFamily across the pinned set) has an empty message;
452+
// summarize its members instead of reporting nothing.
453+
const members = (error as any)?.errors ?? (error as any)?.cause?.errors;
454+
errorMessage = (Array.isArray(members)
455+
? members.map((m: any) => m?.code || m?.message).filter(Boolean).join(', ')
456+
: '') || 'Connection failed';
457+
}
447458

448459
// Try to extract execution ID from error if available
449460
const errorDetails = (error as any)?.response?.data;

src/utils/n8n-errors.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,13 @@ export function handleN8nApiError(error: unknown): N8nApiError {
8585
return new N8nApiError(message, status, 'API_ERROR', data);
8686
}
8787
} else if (axiosError.request) {
88-
// Request was made but no response received
89-
return new N8nApiError('No response from n8n server', undefined, 'NO_RESPONSE');
88+
// Request was made but no response received. Name which address(es)
89+
// failed so "no response" is diagnosable instead of opaque (#978/#989/#990).
90+
const detail = describeConnectionFailure(axiosError);
91+
const message = detail
92+
? `No response from n8n server (${detail})`
93+
: 'No response from n8n server';
94+
return new N8nApiError(message, undefined, 'NO_RESPONSE');
9095
} else {
9196
// Something happened in setting up the request
9297
return new N8nApiError(axiosError.message, undefined, 'REQUEST_ERROR');
@@ -135,6 +140,49 @@ function folderPlacementHint(error: N8nApiError): string {
135140
return ' Note: workflow folder placement (parentFolderId) requires n8n 2.32 or later - retry without parentFolderId, or upgrade the instance.';
136141
}
137142

143+
/**
144+
* Build a short "CODE address:port" detail string from a connection-level
145+
* axios error, for the NO_RESPONSE message (#978/#989/#990). When the
146+
* underlying failure is an AggregateError (`autoSelectFamily` trying
147+
* multiple pinned addresses), lists each member deduped so a multi-address
148+
* failure reads as e.g. "ECONNREFUSED 127.0.0.1:5678, ECONNREFUSED
149+
* [::1]:5678" instead of the generic top-level message alone. Returns ''
150+
* when no code-bearing detail is available.
151+
*/
152+
function describeConnectionFailure(axiosError: any): string {
153+
const parts: string[] = [];
154+
const seen = new Set<string>();
155+
156+
const addPart = (source: any) => {
157+
if (!source || !source.code) return;
158+
let part = String(source.code);
159+
if (source.address) {
160+
const host = String(source.address).includes(':') ? `[${source.address}]` : source.address;
161+
part += source.port !== undefined ? ` ${host}:${source.port}` : ` ${host}`;
162+
}
163+
if (!seen.has(part)) {
164+
seen.add(part);
165+
parts.push(part);
166+
}
167+
};
168+
169+
const aggregateMembers = axiosError?.errors ?? axiosError?.cause?.errors;
170+
if (Array.isArray(aggregateMembers) && aggregateMembers.length > 0) {
171+
aggregateMembers.forEach(addPart);
172+
}
173+
// Fall back to the wrapper, then its cause: axios copies `code` onto the
174+
// AxiosError but the syscall address/port may live only on the underlying
175+
// error, and aggregate members without codes contribute nothing above.
176+
if (parts.length === 0) {
177+
addPart(axiosError);
178+
}
179+
if (parts.length === 0) {
180+
addPart(axiosError?.cause);
181+
}
182+
183+
return parts.join(', ');
184+
}
185+
138186
function safeStringify(value: unknown): string {
139187
try {
140188
return JSON.stringify(value) ?? '';
@@ -154,8 +202,14 @@ export function getUserFriendlyErrorMessage(error: N8nApiError): string {
154202
return `Invalid request: ${error.message}${folderPlacementHint(error)}`;
155203
case 'RATE_LIMIT_ERROR':
156204
return 'Too many requests. Please wait a moment and try again.';
157-
case 'NO_RESPONSE':
158-
return 'Unable to connect to n8n. Please check the server URL and ensure n8n is running.';
205+
case 'NO_RESPONSE': {
206+
// #978/#989/#990: append the connection detail from the enriched
207+
// message (e.g. "(ECONNREFUSED 127.0.0.1:5678)") when present, so the
208+
// generic sentence doesn't hide which address actually failed.
209+
const generic = 'Unable to connect to n8n. Please check the server URL and ensure n8n is running.';
210+
const detailMatch = error.message.match(/\(([^)]+)\)\s*$/);
211+
return detailMatch ? `${generic} (${detailMatch[1]})` : generic;
212+
}
159213
case 'SERVER_ERROR':
160214
// For server errors, we should not show generic message
161215
// Callers should check for execution context and use formatExecutionError instead

0 commit comments

Comments
 (0)