{"record":{"id":"0f9bce3e1f7bc46c","repo":"n8n-io/n8n","slug":"redirect-to-a-different-host-finalurl","errorCode":null,"errorMessage":"Redirect to a different host: ${finalUrl}","messagePattern":"Redirect to a different host: (.+?)","errorType":"exception","errorClass":"CrossHostRedirectError","httpStatus":null,"severity":"warning","filePath":"packages/@n8n/ai-workflow-builder.ee/src/tools/utils/web-fetch.utils.ts","lineNumber":171,"sourceCode":"\tssrf: SsrfGuard,\n\tsignal?: AbortSignal,\n): Promise<FetchResult> {\n\t// Pre-flight: reject before opening any connection.\n\tconst preflight = await ssrf.validateUrl(url);\n\tif (!preflight.ok) return { status: 'blocked' };\n\n\tconst originalHost = normalizeHost(url);\n\n\tconst config: AxiosRequestConfig = {\n\t\t// Honored by Node's http(s) agent on every hop; validates the resolved IP at\n\t\t// connect time, preventing DNS-rebinding TOCTOU.\n\t\tlookup: ssrf.createSecureLookup() as AxiosRequestConfig['lookup'],\n\t\tbeforeRedirect: (opts: Record<string, string>) => {\n\t\t\t// Direct-IP redirect targets do no DNS lookup, so validate them here first.\n\t\t\tssrf.validateRedirectSync(opts.href);\n\t\t\t// Halt auto-follow on cross-host redirects so the caller can run HITL approval.\n\t\t\tif (normalizeHost(opts.href) !== originalHost) {\n\t\t\t\tthrow new CrossHostRedirectError(opts.href);\n\t\t\t}\n\t\t},\n\t\tmaxRedirects: WEB_FETCH_MAX_REDIRECTS,\n\t\ttimeout: WEB_FETCH_TIMEOUT_MS,\n\t\tsignal,\n\t\tresponseType: 'stream',\n\t\t// Let the manual byte-cap own truncation; arraybuffer + maxContentLength would reject.\n\t\tmaxContentLength: Infinity,\n\t\tmaxBodyLength: Infinity,\n\t\tvalidateStatus: () => true,\n\t\theaders: {\n\t\t\t// eslint-disable-next-line @typescript-eslint/naming-convention\n\t\t\t'User-Agent': 'n8n-workflow-builder/1.0',\n\t\t\tAccept: 'text/html,application/xhtml+xml,*/*',\n\t\t},\n\t};\n\n\tlet response;","sourceCodeStart":153,"sourceCodeEnd":189,"githubUrl":"https://github.com/n8n-io/n8n/blob/5ac6606e81f67bb9534255570cd4e86fd8101eee/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/web-fetch.utils.ts#L153-L189","documentation":"fetchUrl (tools/utils/web-fetch.utils.ts) follows redirects up to WEB_FETCH_MAX_REDIRECTS, but its beforeRedirect hook compares each hop's host to the original URL's host. On a host mismatch it throws CrossHostRedirectError so the caller can run human-in-the-loop domain approval before following the redirect. This is the SSRF guard's cross-host halt, not a hard network failure.","triggerScenarios":"Any web fetch where a redirect (3xx) crosses to a different hostname than the original URL: t.co/bit.ly short links, CDN domains, http-to-https host rewrites, login redirects to an IdP, affiliate hops.","commonSituations":"User pastes a short URL; site redirects to a www/alternate domain for content; login-walled pages redirect to an auth host; regional CDN redirect.","solutions":["Surface the returned finalUrl to the user for explicit domain approval, then re-fetch the approved URL directly.","Pre-resolve short links (follow once via a resolver) before calling fetchUrl.","If the cross-host target is known-safe, pass it as the original URL so it becomes the baseline host."],"exampleFix":"// before: short link crosses host on redirect\nconst r = await fetchUrl('https://t.co/abc', ssrf);\n// r.status === 'redirect_new_host', r.finalUrl set\n// after: user approves finalUrl, fetch it directly\nconst r = await fetchUrl(approvedFinalUrl, ssrf);","handlingStrategy":"validation","validationCode":"// Resolve redirects once yourself (or check the short-link) before calling fetchUrl,\n// so the approved final URL becomes the baseline host.\nasync function preResolve(url: string): Promise<string> {\n  // one-hop manual check; if host changes, surface to the user for approval\n  return approvedUrl;\n}\n\nconst finalUrl = await preResolve(userUrl);\nconst result = await fetchUrl(finalUrl, ssrf);","typeGuard":"function isCrossHostRedirectResult(r: unknown): r is { status: 'redirect_new_host'; finalUrl: string } {\n  return typeof r === 'object' && r !== null && (r as any).status === 'redirect_new_host';\n}","tryCatchPattern":"// fetchUrl does not throw on cross-host; it returns a discriminated status.\nconst result = await fetchUrl(url, ssrf);\nif (result.status === 'redirect_new_host') {\n  // surface result.finalUrl to the user for HITL approval, then re-fetch it.\n}","preventionTips":["Pre-resolve short links so the user approves the final host before fetchUrl runs.","Keep a domain allowlist; auto-approve known-safe redirects and prompt for the rest.","Treat redirect_new_host as an approval checkpoint, not an error."],"tags":["web-fetch","ssrf","security","redirects","approval"],"backgroundTag":null,"analyzedSha":"5ac6606e81f67bb9534255570cd4e86fd8101eee","analyzedAt":"2026-08-12T05:26:35.080Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}