{"record":{"id":"17c38b938e59f333","repo":"payloadcms/payload","slug":"blocked-unsafe-attempt-to-hostname","errorCode":null,"errorMessage":"Blocked unsafe attempt to ${hostname}","messagePattern":"Blocked unsafe attempt to (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"packages/payload/src/uploads/safeFetch.ts","lineNumber":88,"sourceCode":" * - Validates domain names by resolving them to IP addresses and checking if they're safe.\n * - Undici was used because it supported interceptors as well as \"credentials: include\". Native fetch\n */\nexport const safeFetch = async (...args: Parameters<typeof undiciFetch>): Promise<Response> => {\n  const [unverifiedUrl, options] = args\n\n  try {\n    const url = new URL(unverifiedUrl)\n\n    let hostname = url.hostname\n\n    // Strip brackets from IPv6 addresses (e.g., \"[::1]\" => \"::1\")\n    if (hostname.startsWith('[') && hostname.endsWith(']')) {\n      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) {","sourceCodeStart":70,"sourceCodeEnd":106,"githubUrl":"https://github.com/payloadcms/payload/blob/00c58b35c0ed348ddc22daabf467b139727214fd/packages/payload/src/uploads/safeFetch.ts#L70-L106","documentation":"`safeFetch` is Payload's SSRF defense for outbound fetches (paste-URL, external file fetch, webhooks). It parses the URL's hostname; if the hostname is a literal IP that fails `isSafeIp` (anything whose `ipaddr.js` range is not `unicast` — loopback, private, link-local, multicast, reserved), it throws a plain `Error` `Blocked unsafe attempt to <hostname>` before any network call. This blocks direct-IP SSRF (e.g. `http://169.254.169.254/`, `http://127.0.0.1/`).","triggerScenarios":"An upload collection fetches an external file (`getExternalFile`) or paste-URL whose URL hostname is a disallowed IP literal — e.g. `http://127.0.0.1`, `http://169.254.169.254` (AWS metadata), `http://10.0.0.1`, `http://[::1]`. `safeFetch` is used unless the URL matches `upload.skipSafeFetch` or `pasteURL.allowList`.","commonSituations":"A user pastes a URL pointing at internal infrastructure (intentional SSRF probe). A stored document URL was changed to a private IP. Local development with `localhost`/`127.0.0.1` URLs being fetched server-side. IPv6 loopback `[::1]`. A test that uses a literal IP instead of a hostname.","solutions":["Use a public, routable hostname (the SSRF filter allows only `unicast` ranges).","For legitimate internal endpoints, add the URL to `upload.skipSafeFetch` (AllowList) or `pasteURL.allowList` — but only for trusted, intentionally-exposed services.","For local dev, use a hostname that resolves externally, or add a `skipSafeFetch` entry scoped to the dev host.","Never paste cloud metadata IPs (`169.254.169.254`) — the block is intentional and correct.","Audit pasted URLs at the application layer and reject private ranges before they reach `safeFetch`."],"exampleFix":"// before — fetching a private/metadata IP\nawait payload.update({ collection: 'media', id, data: { url: 'http://169.254.169.254/latest/meta-data/' } })\n\n// after — use a public URL, or explicitly allow a trusted internal host\nconst Media = {\n  slug: 'media',\n  upload: {\n    pasteURL: { allowList: [{ hostname: 'trusted-internal.corp' }] },\n  },\n}","handlingStrategy":"validation","validationCode":"import ipaddr from 'ipaddr.js'\n\nfunction isSafeIp(ip: string): boolean {\n  try { return ipaddr.parse(ip).range() === 'unicast' } catch { return false }\n}\n\nfunction isLiteralSafeUrl(raw: string): boolean {\n  try {\n    const u = new URL(raw)\n    let h = u.hostname\n    if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1)\n    return !ipaddr.isValid(h) || isSafeIp(h)\n  } catch { return false }\n}\n\nif (!isLiteralSafeUrl(pastedUrl)) {\n  throw new Error('Refusing URL that points at a non-unicast IP')\n}","typeGuard":"const isLiteralSafeUrl = (raw: string): boolean => {\n  try {\n    const u = new URL(raw); let h = u.hostname\n    if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1)\n    return !ipaddr.isValid(h) || ipaddr.parse(h).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    // reject the URL or add to skipSafeFetch/allowList only for trusted services\n  } else throw err\n}","preventionTips":["Never paste URLs pointing at private/loopback/metadata IPs.","Validate URL hostnames at the application layer before storage.","Keep `safeFetch` enabled in production; only bypass via `skipSafeFetch` for trusted internal services."],"tags":["security","ssrf","network","upload","safe-fetch"],"backgroundTag":null,"analyzedSha":"00c58b35c0ed348ddc22daabf467b139727214fd","analyzedAt":"2026-08-12T20:45:03.758Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}