Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.71.0] - 2026-08-18

### Fixed

- **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)
- **`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.
- **`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.

## [2.70.4] - 2026-08-18

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.70.4",
"version": "2.71.0",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
136 changes: 131 additions & 5 deletions src/services/n8n-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ export class N8nApiClient {
private personalProjectId: string | null = null;
// SECURITY (GHSA-cmrh-wvq6-wm9r): cached pinned transport agents.
private pinnedAgentsPromise: Promise<PinnedAgents> | null = null;
// #978/#989/#990: when the cached agents were last (re-)resolved, so a
// long-lived client periodically re-validates DNS instead of pinning to
// one address (possibly a stale CDN/Cloudflare edge) for its whole life.
private pinnedAgentsResolvedAt = 0;
private static readonly PINNED_AGENTS_TTL_MS = 60_000;
private cfClientId?: string;
private cfClientSecret?: string;
/**
Expand Down Expand Up @@ -230,37 +235,156 @@ export class N8nApiClient {
}
);

// Response interceptor for logging
// Response interceptor for logging + connection-failure retry
this.client.interceptors.response.use(
(response: any) => {
logger.debug(`n8n API Response: ${response.status} ${response.config.url}`);
return response;
},
(error: unknown) => {
async (error: unknown) => {
// #978/#989/#990: retry connection-level failures (no response at
// all) before mapping to N8nApiError. Re-issuing goes back through
// this same interceptor pipeline, so a further failure is retried
// again automatically until maxRetries is exhausted.
const retryAttempt = this.tryRetry(error);
if (retryAttempt) {
return retryAttempt;
}

const n8nError = handleN8nApiError(error);
if (n8nError.code === 'NO_RESPONSE') {
// SECURITY (GHSA-cmrh-wvq6-wm9r resilience): the pinned IP may be
// dead (CDN edge rotated, instance moved) - clear the cache so the
// *next* request re-resolves DNS instead of retrying the same bad
// address forever.
this.pinnedAgentsPromise = null;
}
logN8nError(n8nError, 'n8n API Response');
return Promise.reject(n8nError);
}
);
}

/**
* Retry a connection-level axios failure (no response received) when it
* looks safe to retry and attempts remain. Returns a promise for the
* retried request when a retry is attempted, or `undefined` when the
* caller should fall through to normal error mapping.
*
* @security GHSA-cmrh-wvq6-wm9r follow-up (#978/#989/#990) - the failure
* may mean the pinned IP has gone stale, so the pinned-agent cache is
* cleared before each retry to force fresh DNS resolution.
*/
private tryRetry(error: unknown): Promise<any> | undefined {
const axiosError = error as any;
const config = axiosError?.config;
const noResponse = !!(axiosError && axiosError.request && !axiosError.response);
if (!noResponse || !config) {
return undefined;
}

const retryCount = (config as any).__retryCount || 0;
if (retryCount >= this.maxRetries) {
return undefined;
}

// Default to a non-idempotent classification when the method is missing:
// only pre-connection failures are then eligible for retry.
const method = String(config.method || '');
if (!this.isRetryableConnectionError(axiosError, method)) {
return undefined;
}

(config as any).__retryCount = retryCount + 1;
// Force fresh DNS on the retried attempt.
this.pinnedAgentsPromise = null;

const backoffMs = 250 * Math.pow(2, retryCount);
return new Promise((resolve, reject) => {
setTimeout(() => {
this.client.request(config).then(resolve, reject);
}, backoffMs);
});
}

/**
* Whether a connection-level axios error is safe to retry for the given
* HTTP method. Errors that occurred before any bytes reached the wire
* (connection refused/unreachable/DNS failure) are safe to retry
* regardless of method - the server never saw the request. Errors that may
* have interrupted an in-flight request (reset, timeout) are only retried
* for idempotent methods.
*/
private isRetryableConnectionError(axiosError: any, method: string): boolean {
const codes = this.extractErrorCodes(axiosError);
if (codes.length === 0) return false;

const isIdempotent = method.toUpperCase() === 'GET' || method.toUpperCase() === 'HEAD';
const anyMethodCodes = new Set(['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'EAI_AGAIN']);
const idempotentOnlyCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED']);

return codes.some(code => anyMethodCodes.has(code) || (isIdempotent && idempotentOnlyCodes.has(code)));
}

/**
* Collect every error `code` relevant to the retry decision: the error's
* own code, plus each member's code when the error is an AggregateError
* (e.g. from `autoSelectFamily` trying multiple pinned addresses).
*/
private extractErrorCodes(error: any): string[] {
const codes: string[] = [];
if (error?.code) codes.push(error.code);

const aggregateMembers = error?.errors ?? error?.cause?.errors;
if (Array.isArray(aggregateMembers)) {
for (const member of aggregateMembers) {
if (member?.code) codes.push(member.code);
}
}
return codes;
}

/**
* Resolve the configured baseUrl once and return HTTP/HTTPS agents that
* pin every connection to the validated IP.
* pin every connection to the validated address(es). Re-resolved when the
* cache is empty, has expired (TTL), or was invalidated after a
* connection failure — see {@link tryRetry} and the NO_RESPONSE branch of
* the response interceptor.
*
* @security GHSA-cmrh-wvq6-wm9r — without this, axios performs an
* independent DNS lookup on every request, opening a TOCTOU window.
*/
private getPinnedAgents(): Promise<PinnedAgents> {
const isExpired = this.pinnedAgentsPromise !== null &&
Date.now() - this.pinnedAgentsResolvedAt > N8nApiClient.PINNED_AGENTS_TTL_MS;
if (isExpired) {
// #978/#989/#990: don't stay pinned to a possibly-stale address (e.g.
// a rotated CDN/Cloudflare edge) for the whole process lifetime.
this.pinnedAgentsPromise = null;
}

if (!this.pinnedAgentsPromise) {
const promise = (async () => {
const { SSRFProtection } = await import('../utils/ssrf-protection');
const validation = await SSRFProtection.validateWebhookUrl(this.baseUrl);
if (!validation.valid || !validation.address || !validation.family) {
throw new Error(`SSRF protection: ${validation.reason || 'baseUrl rejected'}`);
}
return SSRFProtection.createPinnedAgents(validation.address, validation.family);
return SSRFProtection.createPinnedAgents(
validation.addresses ?? [{ address: validation.address, family: validation.family }]
);
})();
// Stamp at dispatch so concurrent callers during an in-flight
// re-resolution see a fresh TTL and don't each kick off their own
// lookup; refresh on fulfillment (only while still the current
// promise) so the window restarts from when the addresses actually
// became valid.
this.pinnedAgentsResolvedAt = Date.now();
promise.then(() => {
if (this.pinnedAgentsPromise === promise) {
this.pinnedAgentsResolvedAt = Date.now();
}
}, () => {});
// Reset on rejection so transient DNS failures don't brick the client.
promise.catch(() => {
if (this.pinnedAgentsPromise === promise) {
Expand Down Expand Up @@ -1037,7 +1161,9 @@ export class N8nApiClient {

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

// Create a new axios instance for webhook requests to avoid API interceptors
Expand Down
15 changes: 13 additions & 2 deletions src/triggers/handlers/chat-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ export class ChatHandler extends BaseTriggerHandler<ChatTriggerInput> {

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

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

// Try to extract execution ID from error if available
const errorDetails = (error as any)?.response?.data;
Expand Down
15 changes: 13 additions & 2 deletions src/triggers/handlers/form-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,9 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {

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

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

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

// Try to extract execution ID from error if available
const errorDetails = (error as any)?.response?.data;
Expand Down
70 changes: 66 additions & 4 deletions src/utils/n8n-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,13 @@ export function handleN8nApiError(error: unknown): N8nApiError {
return new N8nApiError(message, status, 'API_ERROR', data);
}
} else if (axiosError.request) {
// Request was made but no response received
return new N8nApiError('No response from n8n server', undefined, 'NO_RESPONSE');
// Request was made but no response received. Name which address(es)
// failed so "no response" is diagnosable instead of opaque (#978/#989/#990).
const detail = describeConnectionFailure(axiosError);
const message = detail
? `No response from n8n server (${detail})`
: 'No response from n8n server';
return new N8nApiError(message, undefined, 'NO_RESPONSE');
} else {
// Something happened in setting up the request
return new N8nApiError(axiosError.message, undefined, 'REQUEST_ERROR');
Expand Down Expand Up @@ -135,6 +140,49 @@ function folderPlacementHint(error: N8nApiError): string {
return ' Note: workflow folder placement (parentFolderId) requires n8n 2.32 or later - retry without parentFolderId, or upgrade the instance.';
}

/**
* Build a short "CODE address:port" detail string from a connection-level
* axios error, for the NO_RESPONSE message (#978/#989/#990). When the
* underlying failure is an AggregateError (`autoSelectFamily` trying
* multiple pinned addresses), lists each member deduped so a multi-address
* failure reads as e.g. "ECONNREFUSED 127.0.0.1:5678, ECONNREFUSED
* [::1]:5678" instead of the generic top-level message alone. Returns ''
* when no code-bearing detail is available.
*/
function describeConnectionFailure(axiosError: any): string {
const parts: string[] = [];
const seen = new Set<string>();

const addPart = (source: any) => {
if (!source || !source.code) return;
let part = String(source.code);
if (source.address) {
const host = String(source.address).includes(':') ? `[${source.address}]` : source.address;
part += source.port !== undefined ? ` ${host}:${source.port}` : ` ${host}`;
}
if (!seen.has(part)) {
seen.add(part);
parts.push(part);
}
};

const aggregateMembers = axiosError?.errors ?? axiosError?.cause?.errors;
if (Array.isArray(aggregateMembers) && aggregateMembers.length > 0) {
aggregateMembers.forEach(addPart);
}
// Fall back to the wrapper, then its cause: axios copies `code` onto the
// AxiosError but the syscall address/port may live only on the underlying
// error, and aggregate members without codes contribute nothing above.
if (parts.length === 0) {
addPart(axiosError);
}
if (parts.length === 0) {
addPart(axiosError?.cause);
}

return parts.join(', ');
}

function safeStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? '';
Expand All @@ -154,8 +202,22 @@ export function getUserFriendlyErrorMessage(error: N8nApiError): string {
return `Invalid request: ${error.message}${folderPlacementHint(error)}`;
case 'RATE_LIMIT_ERROR':
return 'Too many requests. Please wait a moment and try again.';
case 'NO_RESPONSE':
return 'Unable to connect to n8n. Please check the server URL and ensure n8n is running.';
case 'NO_RESPONSE': {
// #978/#989/#990: append the connection detail from the enriched
// message (e.g. "(ECONNREFUSED 127.0.0.1:5678)") when present, so the
// generic sentence doesn't hide which address actually failed.
const generic = 'Unable to connect to n8n. Please check the server URL and ensure n8n is running.';
// Plain string scan instead of a trailing-group regex (CodeQL
// js/polynomial-redos): take a non-empty parenthesized suffix that
// contains no nested parens, which is the only shape
// describeConnectionFailure produces.
const message = error.message.trimEnd();
const open = message.lastIndexOf('(');
const detail = message.endsWith(')') && open !== -1
? message.slice(open + 1, -1)
: '';
return detail && !detail.includes(')') ? `${generic} (${detail})` : generic;
}
case 'SERVER_ERROR':
// For server errors, we should not show generic message
// Callers should check for execution context and use formatExecutionError instead
Expand Down
Loading
Loading