payloadcms/payload · critical · Error

${error.cause.message}

Error message

${error.cause.message}

What it means

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.

Source

Thrown at packages/payload/src/uploads/safeFetch.ts:101

      hostname = hostname.slice(1, -1)
    }

    if (ipaddr.isValid(hostname)) {
      if (!isSafeIp(hostname)) {
        throw new Error(`Blocked unsafe attempt to ${hostname}`)
      }
    }
    return (await undiciFetch(url, {
      ...options,
      dispatcher: getSafeDispatcher(),
      redirect: 'manual', // Prevent automatic redirects
    })) as unknown as Response
  } catch (error) {
    if (error instanceof Error) {
      if (error.cause instanceof Error && error.cause.message.includes('unsafe')) {
        // Errors thrown from within interceptors always have 'fetch error' as the message
        // The desired message we want to bubble up is in the cause
        throw new Error(error.cause.message)
      } else {
        let stringifiedUrl: string | undefined = undefined
        if (typeof unverifiedUrl === 'string') {
          stringifiedUrl = unverifiedUrl
        } else if (unverifiedUrl instanceof URL) {
          stringifiedUrl = unverifiedUrl.toString()
        } else if (unverifiedUrl instanceof Request) {
          stringifiedUrl = unverifiedUrl.url
        }

        throw new Error(`Failed to fetch from ${stringifiedUrl}, ${error.message}`)
      }
    }
    throw error
  }
}

View on GitHub (pinned to 00c58b35c0)

Solutions

  1. Confirm the URL is meant to be publicly reachable; the block is correct if the domain resolves internally.
  2. Use a publicly resolvable hostname for stored/pasted URLs.
  3. If a trusted internal service must be fetched, add it to `upload.skipSafeFetch` or `pasteURL.allowList` (bypasses `safeFetch`/its interceptor).
  4. Investigate possible DNS-rebinding if the domain is user-supplied.
  5. For dev, point the hostname at a public IP or use `skipSafeFetch` for the dev domain.
  6. Keep the SSRF filter enabled in production — do not disable `safeFetch` globally.

Example fix

// before
const Media = {
  slug: 'media',
  upload: { /* pasteURL: true — safeFetch enforced, internal domain blocked */ },
}

// after — explicitly trust an internal hostname
const Media = {
  slug: 'media',
  upload: {
    skipSafeFetch: [{ hostname: 'internal-files.corp' }],
  },
}
Defensive patterns

Strategy: try-catch

Validate before calling

import { lookup } from 'node:dns/promises'
import ipaddr from 'ipaddr.js'

async function resolvesToPublicOnly(hostname: string): Promise<boolean> {
  try {
    const { address } = await lookup(hostname)
    return ipaddr.parse(address).range() === 'unicast'
  } catch {
    return false
  }
}

if (!(await resolvesToPublicOnly(new URL(url).hostname))) {
  throw new Error('Hostname resolves to a non-unicast IP — possible SSRF')
}

Type guard

async function isSafeToFetch(url: string): Promise<boolean> {
  try {
    const h = new URL(url).hostname
    const { address } = await lookup(h)
    return ipaddr.parse(address).range() === 'unicast'
  } catch { return false }
}

Try / catch

try {
  await payload.update({ collection: 'media', id, data: { url } })
} catch (err) {
  if (err instanceof Error && /blocked unsafe attempt/i.test(err.message)) {
    // DNS resolved to a private/reserved IP — reject or explicitly allow via skipSafeFetch
  } else throw err
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of payloadcms/payload@00c58b35c0 (2026-08-12). Data as JSON: /api/errors/c9fec66c1cf0b711. Report an issue: GitHub.