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
- 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.
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
- 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.
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
- Blocked unsafe attempt to ${hostname}
- Failed to fetch from ${stringifiedUrl}, ${error.message}
- Redirect target is not allowed.
- File URLs must use http or https.
- Failed to download: ${url}
AI-assisted analysis of payloadcms/payload@00c58b35c0 (2026-08-12).
Data as JSON: /api/errors/c9fec66c1cf0b711.
Report an issue: GitHub.