fix: pin the SSRF transport to every validated address and retry connection failures (v2.71.0) - #1010
Conversation
There was a problem hiding this comment.
Pull request overview
Improves resilience of the GHSA-cmrh-wvq6-wm9r SSRF DNS-pinning transport used by the n8n management client and trigger handlers by validating/pinning all DNS answers (fail-closed on mixed disallowed records), enabling multi-address connection fallback, adding TTL-based re-resolution + retry-on-connection-failure behavior, and enriching NO_RESPONSE errors with attempted address details.
Changes:
- Update SSRF validation/agent pinning to resolve
{ all: true }, validate every answer, and pin the full validated set withautoSelectFamilyfallback. - Add pinned-agent TTL + invalidate-on-failure behavior and implement connection-level retries driven by
N8N_API_MAX_RETRIES. - Improve diagnosability of
NO_RESPONSE(including AggregateError member summaries) and add unit coverage for the new behaviors.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/utils/ssrf-protection.ts |
Validate every DNS answer and pin a multi-address set with Happy-Eyeballs fallback. |
src/services/n8n-api-client.ts |
Add pinned-agent TTL, invalidate on failures, and implement safe connection-level retries. |
src/utils/n8n-errors.ts |
Enrich NO_RESPONSE messaging with connection failure details (incl. AggregateError members). |
src/triggers/handlers/chat-handler.ts |
Use multi-address pinned agents; improve empty-message AggregateError reporting. |
src/triggers/handlers/form-handler.ts |
Use multi-address pinned agents; improve empty-message AggregateError reporting. |
tests/unit/utils/ssrf-protection.test.ts |
Add/adjust coverage for multi-address validation/pinning and autoSelectFamily wiring. |
tests/unit/services/n8n-api-client.test.ts |
Add coverage for TTL, invalidation, fresh-DNS-on-retry, and retry matrix. |
tests/unit/utils/n8n-errors.test.ts |
Add coverage for NO_RESPONSE detail formatting and user-facing message propagation. |
CHANGELOG.md |
Document the behavioral fixes in 2.71.0. |
package.json |
Bump version to 2.71.0. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| static createPinnedAgents(addresses: Array<{ address: string; family: 4 | 6 }>): PinnedAgents { | ||
| if (!addresses || addresses.length === 0) { | ||
| throw new Error('createPinnedAgents requires at least one validated address'); | ||
| } | ||
|
|
There was a problem hiding this comment.
Declining the compat overload: SSRFProtection is an internal utility of this server, not part of the embedding API surface (mcp-engine.ts is), and all four in-repo call sites are updated in this PR. An overload keeping the single-address form would preserve exactly the first-answer-only pinning these issues exist to remove, so leaving the old signature callable works against the fix.
Test Results Summary📊 ArtifactsGenerated at Tue, 18 Aug 2026 20:51:22 GMT |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/utils/ssrf-protection.ts:471
pinnedLookupassumes the 3-argumentlookup(hostname, options, callback)signature. Node’s lookup contract also allowslookup(hostname, callback); if any caller (now or in future Node versions) invokes the pinned lookup with 2 args,callbackwill beundefinedand this will throw when calling it.
Make the pinned lookup robust to the 2-arg overload by normalizing options/callback when options is a function.
const pinnedLookup = (
_hostname: string,
options: any,
callback: any
): void => {
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…ection 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>
CodeQL flagged /\(([^)]+)\)\s*$/ as polynomially backtracking on
'('-heavy input. Scan for the parenthesized suffix with lastIndexOf
instead; same accepted shape.
Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2e81b11 to
d20753c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/utils/ssrf-protection.ts:34
autoSelectFamilyfallback is only enabled whennet.getDefaultAutoSelectFamilyexists (Node >=18.13), but this repo’s runtime engines allow Node >=18.0.0. On Node 18.0–18.12, the pinned lookup will still effectively pick only the first DNS answer, so the #978 localhost (::1-first) failure can still reproduce on a supported runtime. Consider either bumping the supported Node version to >=18.13.0 (so the fix is guaranteed) or implementing an explicit cross-address fallback for older Node versions.
// SECURITY (#978/#989/#990 resilience follow-up to GHSA-cmrh-wvq6-wm9r):
// `autoSelectFamily`/`autoSelectFamilyAttemptTimeout` were added in Node 18.13
// and are stable by 20.x. Guard for older runtimes; computed once at module
// scope rather than probed per-socket.
const supportsAutoSelectFamily = typeof (net as any).getDefaultAutoSelectFamily === 'function';
src/services/n8n-api-client.ts:316
- The JSDoc for
isRetryableConnectionErrorsays in-flight failures are retried for “idempotent methods”, but the implementation only treats GET/HEAD as idempotent. Either broaden the predicate (if intended) or adjust the comment to match the current behavior so readers don’t assume PUT/DELETE are retried too.
* 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.
Fixes #978. Fixes #989. Fixes #990.
Thanks to @ConnorCloze (#978) and @Boulevard-Dreams (#989/#990) for unusually precise root-cause analyses — both traced the installed package to the exact lines, and both were confirmed verbatim against
main.The problem
The DNS-pinning SSRF protection (GHSA-cmrh-wvq6-wm9r) resolved the API hostname with a single-answer
dns.lookup(), pinned every connection to that one address, cached it for the life of the process, and had no retry path (N8N_API_MAX_RETRIESwas stored and never read). Consequences:localhostto::1first; an n8n listening only on IPv4 loopback (Docker Desktop's-p 127.0.0.1:5678:5678) fails every management tool with an opaqueNO_RESPONSEwhilecurlworks.The fix
ssrf-protection.ts—validateWebhookUrlresolves{all: true}and validates every record via a shared per-address policy helper, failing closed if any record is disallowed. This also closes the mixed-record variant of DNS rebinding, and deliberately applies to cloud-metadata addresses in any record position in permissive mode too (previously only the first answer was ever checked).createPinnedAgentspins the full validated set: the custom lookup hands back all candidates, andautoSelectFamily(guarded for old Node) letsnet.connecttry each in turn — every candidate has already passed validation, so the pinning guarantee is unchanged.n8n-api-client.ts— the cached pinned agents get a 60s TTL (stamped at dispatch to prevent a re-resolution stampede, refreshed on fulfillment only while still current) and are invalidated after any terminalNO_RESPONSE.maxRetriesnow drives a real retry path in the response interceptor: pre-connection failures (ECONNREFUSED/EHOSTUNREACH/ENETUNREACH/ENOTFOUND/EAI_AGAIN, including AggregateError members) retry any method;ECONNRESET/ETIMEDOUT/ECONNABORTEDretry only GET/HEAD, so a non-idempotent request that may have reached the server is never re-executed. Each retry clears the pinned cache first, so the retried attempt resolves fresh DNS — this is what heals the rotated-CDN-edge case mid-session.n8n-errors.ts—NO_RESPONSEnow names what failed:No response from n8n server (ECONNREFUSED 127.0.0.1:5678, ECONNREFUSED [::1]:5678), reading detail from the error, itscause, or each AggregateError member; the user-facing message carries it through. The trigger handlers also summarize AggregateError members instead of surfacing an empty message.Review notes
Gauntlet: code-reviewer + Codex (adversarial), both confirmed the core invariant — no connection can reach an address that did not pass validation — and both verified the axios re-issue mechanics against the installed axios 1.18.1 (
__retryCountsurvivesmergeConfig; serialized bodies aren't double-transformed; recursion bounded). Applied from review: the TTL stampede fix, thecausefallback in error details, non-idempotent default for a missing method, AggregateError messages in trigger handlers, and coverage for autoSelectFamily wiring, permissive-mode fail-close, concurrent-expiry, and fresh-DNS-on-retry. Declined: retrying DNS-resolution failures inside URL validation (fail-fast is intentional; the cache resets so the next call re-resolves).Behavior changes to be aware of: a hostname whose record set mixes allowed and disallowed addresses now fails closed even when the allowed one comes first (stricter; previously only the first answer was checked), and permissive mode now rejects tunneled/metadata records in any position.
Verification
npm run typecheckclean.createConnection, TTL expiry + concurrent-expiry single-flight, cache invalidation onNO_RESPONSE, retry matrix (GET/POST × refused/reset/timeout), fresh DNS on retried attempts, enriched error details incl.causeand code-less aggregates.Conceived by Romuald Członkowski - www.aiadvisors.pl/en
🤖 Generated with Claude Code