{"record":{"id":"c9fec66c1cf0b711","repo":"payloadcms/payload","slug":"error-cause-message","errorCode":null,"errorMessage":"${error.cause.message}","messagePattern":"\\$\\{error\\.cause\\.message\\}","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"packages/payload/src/uploads/safeFetch.ts","lineNumber":101,"sourceCode":"      hostname = hostname.slice(1, -1)\n    }\n\n    if (ipaddr.isValid(hostname)) {\n      if (!isSafeIp(hostname)) {\n        throw new Error(`Blocked unsafe attempt to ${hostname}`)\n      }\n    }\n    return (await undiciFetch(url, {\n      ...options,\n      dispatcher: getSafeDispatcher(),\n      redirect: 'manual', // Prevent automatic redirects\n    })) as unknown as Response\n  } catch (error) {\n    if (error instanceof Error) {\n      if (error.cause instanceof Error && error.cause.message.includes('unsafe')) {\n        // Errors thrown from within interceptors always have 'fetch error' as the message\n        // The desired message we want to bubble up is in the cause\n        throw new Error(error.cause.message)\n      } else {\n        let stringifiedUrl: string | undefined = undefined\n        if (typeof unverifiedUrl === 'string') {\n          stringifiedUrl = unverifiedUrl\n        } else if (unverifiedUrl instanceof URL) {\n          stringifiedUrl = unverifiedUrl.toString()\n        } else if (unverifiedUrl instanceof Request) {\n          stringifiedUrl = unverifiedUrl.url\n        }\n\n        throw new Error(`Failed to fetch from ${stringifiedUrl}, ${error.message}`)\n      }\n    }\n    throw error\n  }\n}\n","sourceCodeStart":83,"sourceCodeEnd":118,"githubUrl":"https://github.com/payloadcms/payload/blob/00c58b35c0ed348ddc22daabf467b139727214fd/packages/payload/src/uploads/safeFetch.ts#L83-L118","documentation":"Even when the hostname is a domain (not a literal IP), `safeFetch` resolves it through a custom DNS lookup interceptor (`ssrfFilterInterceptor`) that checks every resolved address with `isSafeIp`. If any resolved IP is unsafe (private/loopback/etc.), the interceptor throws inside undici with message containing `unsafe`; `safeFetch` detects the `error.cause.message` and re-throws a plain `Error` with that cause message (e.g. `Blocked unsafe attempt to evil.com`). This blocks DNS-rebinding and domain-to-private-IP SSRF.","triggerScenarios":"An external-file/paste-URL fetch where the URL hostname is a domain that resolves (via DNS) to a private/reserved/loopback IP. Common in DNS-rebinding attacks or attacker-controlled domains with internal A/AAAA records. The literal-IP precheck passes (it's a domain), but the lookup-time check fails.","commonSituations":"An attacker controls a domain whose DNS returns `127.0.0.1`/`169.254.169.254` to bypass the literal-IP check. A staging environment uses a `.local`/internal domain that resolves privately. Split-horizon DNS returns internal IPs to the server. A legit internal hostname being fetched server-side.","solutions":["Confirm the URL is meant to be publicly reachable; the block is correct if the domain resolves internally.","Use a publicly resolvable hostname for stored/pasted URLs.","If a trusted internal service must be fetched, add it to `upload.skipSafeFetch` or `pasteURL.allowList` (bypasses `safeFetch`/its interceptor).","Investigate possible DNS-rebinding if the domain is user-supplied.","For dev, point the hostname at a public IP or use `skipSafeFetch` for the dev domain.","Keep the SSRF filter enabled in production — do not disable `safeFetch` globally."],"exampleFix":"// before\nconst Media = {\n  slug: 'media',\n  upload: { /* pasteURL: true — safeFetch enforced, internal domain blocked */ },\n}\n\n// after — explicitly trust an internal hostname\nconst Media = {\n  slug: 'media',\n  upload: {\n    skipSafeFetch: [{ hostname: 'internal-files.corp' }],\n  },\n}","handlingStrategy":"try-catch","validationCode":"import { lookup } from 'node:dns/promises'\nimport ipaddr from 'ipaddr.js'\n\nasync function resolvesToPublicOnly(hostname: string): Promise<boolean> {\n  try {\n    const { address } = await lookup(hostname)\n    return ipaddr.parse(address).range() === 'unicast'\n  } catch {\n    return false\n  }\n}\n\nif (!(await resolvesToPublicOnly(new URL(url).hostname))) {\n  throw new Error('Hostname resolves to a non-unicast IP — possible SSRF')\n}","typeGuard":"async function isSafeToFetch(url: string): Promise<boolean> {\n  try {\n    const h = new URL(url).hostname\n    const { address } = await lookup(h)\n    return ipaddr.parse(address).range() === 'unicast'\n  } catch { return false }\n}","tryCatchPattern":"try {\n  await payload.update({ collection: 'media', id, data: { url } })\n} catch (err) {\n  if (err instanceof Error && /blocked unsafe attempt/i.test(err.message)) {\n    // DNS resolved to a private/reserved IP — reject or explicitly allow via skipSafeFetch\n  } else throw err\n}","preventionTips":["Treat user-supplied URLs as untrusted; expect DNS-rebinding.","For trusted internal hosts, add them to `upload.skipSafeFetch`/`pasteURL.allowList`.","Pre-resolve and validate hostnames before persisting user URLs."],"tags":["security","ssrf","dns","network","safe-fetch"],"backgroundTag":null,"analyzedSha":"00c58b35c0ed348ddc22daabf467b139727214fd","analyzedAt":"2026-08-12T20:45:03.758Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}