{"record":{"id":"48c8aedb769d7942","repo":"postalserver/postal","slug":"destination-host-address-is-not-permitt","errorCode":null,"errorMessage":"Destination '#{@host}' (#{address}) is not permitted","messagePattern":"Destination '#(.+?)' \\(#(.+?)\\) is not permitted","errorType":"exception","errorClass":"Postal::HTTP::BlockedDestinationError","httpStatus":null,"severity":"error","filePath":"lib/postal/http/address_guard.rb","lineNumber":113,"sourceCode":"      def safe_connect_address\n        if @host.empty?\n          raise BlockedDestinationError, \"No host was given for the request\"\n        end\n\n        addresses = resolve\n        if addresses.empty?\n          raise BlockedDestinationError, \"Could not resolve '#{@host}' to any IP address\"\n        end\n\n        # Reject the whole request if *any* resolved address is blocked. This is\n        # checked before the reachability filtering below so that a blocked\n        # destination is always reported as such, regardless of which address\n        # families this particular server can reach. It also defeats DNS\n        # responses that mix a public and a private address to slip past.\n        addresses.each do |address|\n          next unless blocked?(address)\n\n          raise BlockedDestinationError,\n                \"Destination '#{@host}' (#{address}) is not permitted\"\n        end\n\n        # Only connect to an address whose family this server can actually\n        # reach. Otherwise we might pin the connection to an IPv6 address on a\n        # host without IPv6 connectivity and fail to connect even when a usable\n        # IPv4 address was available.\n        usable = addresses.select { |address| family_reachable?(address) }\n        if usable.empty?\n          raise SocketError,\n                \"'#{@host}' only resolves to addresses this server cannot reach \" \\\n                \"(#{addresses.join(', ')})\"\n        end\n\n        # Prefer IPv4 for predictability; only use IPv6 when it is the only\n        # reachable option.\n        (usable.find(&:ipv4?) || usable.first).to_s\n      end","sourceCodeStart":95,"sourceCodeEnd":131,"githubUrl":"https://github.com/postalserver/postal/blob/d038eaa8c763d3cafa797ccd6f773d53470bd336/lib/postal/http/address_guard.rb#L95-L131","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before\nendpoint.url = \"http://10.0.0.5:9000/webhook\"  # BlockedDestinationError on delivery\n\n# after\nendpoint.url = \"https://hooks.internal.example.com/webhook\"  # public ingress/proxy to 10.0.0.5","handlingStrategy":"validation","validationCode":"# pre-check that a URL's resolved IPs are all public before relying on it\nrequire \"ipaddr\"\naddrs = Resolv::DNS.open { |dns| dns.getresources(host, Resolv::DNS::Resource::IN::A).map(&:address) }\nblocked = addrs.any? { |ip| IPAddr.new(ip).private? || IPAddr.new(ip).loopback? || IPAddr.new(ip).link_local? }\nraise ArgumentError, \"#{host} resolves to a blocked range\" if blocked","typeGuard":"def public_destination?(host)\n  require \"ipaddr\"\n  addrs = Resolv.getaddresses(host)\n  addrs.any? && addrs.none? do |ip|\n    a = IPAddr.new(ip)\n    a.private? || a.loopback? || a.link_local? || a.to_s.start_with?(\"169.254.\")\n  end\nend","tryCatchPattern":"begin\n  Postal::HTTP.request(...)\nrescue Postal::HTTP::BlockedDestinationError => e\n  # expected when the target is internal: report to the endpoint owner,\n  # never try to bypass the guard or re-resolve until it passes\nend","preventionTips":["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)"],"tags":["postal","ssrf","webhook","http-endpoint","security","network"],"backgroundTag":"ssrf-protection","analyzedSha":"d038eaa8c763d3cafa797ccd6f773d53470bd336","analyzedAt":"2026-08-21T13:52:57.446Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}