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
      end

View on GitHub (pinned to d038eaa8c7)

Solutions

  1. Point the HTTPEndpoint at a publicly routable hostname/IP for the target service
  2. Expose the internal service through a reverse proxy / public ingress and use that URL
  3. 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
  4. 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

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


AI-assisted analysis of postalserver/postal@d038eaa8c7 (2026-08-21). Data as JSON: /api/errors/48c8aedb769d7942. Report an issue: GitHub.