postalserver/postal · error · Postal::HTTP::BlockedDestinationError
Destination '#{@host}' (#{address}) is not permitted
Error message
Destination '#{@host}' (#{address}) is not permitted What it means
AddressGuard's core SSRF rule: after resolving the host, if ANY resolved IP falls in a blocked range (loopback, RFC1918 private, link-local, etc.) the whole request is rejected with BlockedDestinationError, even when other resolved addresses look public. This deliberately defeats DNS rebinding and responses that mix a public and a private address to slip past the check. Postal::HTTP turns this into a failed request (code -4) for the endpoint.
Source
Thrown at lib/postal/http/address_guard.rb:113
def safe_connect_address
if @host.empty?
raise BlockedDestinationError, "No host was given for the request"
end
addresses = resolve
if addresses.empty?
raise BlockedDestinationError, "Could not resolve '#{@host}' to any IP address"
end
# Reject the whole request if *any* resolved address is blocked. This is
# checked before the reachability filtering below so that a blocked
# destination is always reported as such, regardless of which address
# families this particular server can reach. It also defeats DNS
# responses that mix a public and a private address to slip past.
addresses.each do |address|
next unless blocked?(address)
raise BlockedDestinationError,
"Destination '#{@host}' (#{address}) is not permitted"
end
# Only connect to an address whose family this server can actually
# reach. Otherwise we might pin the connection to an IPv6 address on a
# host without IPv6 connectivity and fail to connect even when a usable
# IPv4 address was available.
usable = addresses.select { |address| family_reachable?(address) }
if usable.empty?
raise SocketError,
"'#{@host}' only resolves to addresses this server cannot reach " \
"(#{addresses.join(', ')})"
end
# Prefer IPv4 for predictability; only use IPv6 when it is the only
# reachable option.
(usable.find(&:ipv4?) || usable.first).to_s
endView on GitHub (pinned to d038eaa8c7)
Solutions
- Point the HTTPEndpoint at a publicly routable hostname/IP for the target service
- Expose the internal service through a reverse proxy / public ingress and use that URL
- If the service legitimately lives in a private network next to Postal, run a relay that Postal can reach publicly instead of weakening the guard
- Do not remove or bypass AddressGuard - the block is a security boundary against SSRF
Example fix
# before endpoint.url = "http://10.0.0.5:9000/webhook" # BlockedDestinationError on delivery # after endpoint.url = "https://hooks.internal.example.com/webhook" # public ingress/proxy to 10.0.0.5
Defensive patterns
Strategy: validation
Validate before calling
# pre-check that a URL's resolved IPs are all public before relying on it
require "ipaddr"
addrs = Resolv::DNS.open { |dns| dns.getresources(host, Resolv::DNS::Resource::IN::A).map(&:address) }
blocked = addrs.any? { |ip| IPAddr.new(ip).private? || IPAddr.new(ip).loopback? || IPAddr.new(ip).link_local? }
raise ArgumentError, "#{host} resolves to a blocked range" if blocked Type guard
def public_destination?(host)
require "ipaddr"
addrs = Resolv.getaddresses(host)
addrs.any? && addrs.none? do |ip|
a = IPAddr.new(ip)
a.private? || a.loopback? || a.link_local? || a.to_s.start_with?("169.254.")
end
end Try / catch
begin Postal::HTTP.request(...) rescue Postal::HTTP::BlockedDestinationError => e # expected when the target is internal: report to the endpoint owner, # never try to bypass the guard or re-resolve until it passes end
Prevention
- Standardize on publicly reachable ingress hostnames for anything Postal must call
- Never configure webhooks with raw RFC1918/loopback/link-local IPs or cloud metadata addresses
- Watch for re-introduced private records when a domain's DNS is edited (split-horizon DNS especially)
- Treat repeated blocks from one endpoint as a signal its DNS is being changed underneath you (possible rebinding)
When it happens
Trigger: An HTTPEndpoint/webhook pointing at an internal address (http://127.0.0.1:8080, http://10.0.0.5/hook, http://192.168.1.10/, link-local 169.254.169.254 cloud metadata), or a public hostname whose DNS includes a private-range record; also intentional rebinding setups where the record flips between public and private IPs.
Common situations: Trying to wire Postal webhooks to an internal service (erp/monitoring) behind the same NAT; copy-pasting a LAN URL from local testing into production endpoint config; attempts to reach cloud instance metadata through Postal; split-horizon DNS where the name resolves internally to a private IP.
Related errors
- Could not resolve '#{@host}' to any IP address
- No host was given for the request
- '#{@host}' only resolves to addresses this server cannot rea
- Error when scanning with rspamd (#{e.class})
- Invalid email address
AI-assisted analysis of postalserver/postal@d038eaa8c7 (2026-08-21).
Data as JSON: /api/errors/48c8aedb769d7942.
Report an issue: GitHub.